---
title: "Generate and edit images"
description: "pirouter generates images on the two endpoints of the OpenAI Images protocol and on Gemini's generateContent, and charges for text input, image input and image output tokens."
canonical: "https://pirouter.ai/docs/guides/images"
last-updated: "2026-10-17"
lang: "en"
source: "content/docs/en/guides/images.mdx"
---

# Generate and edit images

pirouter generates images on the two endpoints of the OpenAI Images protocol and on Gemini's generateContent, and charges for text input, image input and image output tokens.

To generate an image from a prompt, use the generations endpoint of the OpenAI Images protocol; to change an existing image, use the edits endpoint. Gemini image models also return images from `generateContent` on the Gemini protocol. The three ways in:

| Endpoint                                      | What it does                                                               | Request body          |
| --------------------------------------------- | -------------------------------------------------------------------------- | --------------------- |
| `POST /v1/images/generations`                 | Generates images from a prompt (OpenAI protocol)                           | JSON                  |
| `POST /v1/images/edits`                       | Edits one or more uploaded images, as a prompt describes (OpenAI protocol) | `multipart/form-data` |
| `POST /v1beta/models/{model}:generateContent` | Called on an image model, returns images in the response (Gemini protocol) | JSON                  |

This page follows one kite through both: generate a red kite, then turn it blue.

## Which models you can use

| Model                                | Generate | Edit | Gemini protocol |
| ------------------------------------ | -------- | ---- | --------------- |
| `openai/gpt-image-2`                 | Yes      | Yes  | No              |
| `openai/gpt-image-2.5-flare`         | Yes      | Yes  | No              |
| `openai/gpt-image-2.5-sunburst`      | Yes      | Yes  | No              |
| `google/gemini-3-pro-image`          | Yes      | No   | Yes             |
| `google/gemini-3.1-flash-image`      | Yes      | No   | Yes             |
| `google/gemini-3.1-flash-lite-image` | Yes      | No   | Yes             |
| `google/gemini-nano-banana-2.1`      | Yes      | No   | Yes             |

The endpoints a model serves are the `supported_endpoints` returned by `GET /v1/models/{vendor}/{model}/endpoints`. See [Models & protocols](https://pirouter.ai/docs/models.md). Calling a model on an endpoint it does not serve returns `422`, and the `message` lists the endpoints it does serve.

## Generate an image

cURL:

```bash
curl https://api.pirouter.ai/v1/images/generations \
  -H "Authorization: Bearer $PIROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-image-2.5-sunburst",
    "prompt": "A flat vector icon of a red paper kite on a plain white background",
    "size": "1024x1024",
    "quality": "low"
  }'
```

Python:

```python
import base64
import os

from openai import OpenAI

client = OpenAI(base_url="https://api.pirouter.ai/v1", api_key=os.environ["PIROUTER_API_KEY"])
result = client.images.generate(
    model="openai/gpt-image-2.5-sunburst",
    prompt="A flat vector icon of a red paper kite on a plain white background",
    size="1024x1024",
    quality="low",
)
open("kite.png", "wb").write(base64.b64decode(result.data[0].b64_json))
```

The image comes back base64-encoded in `data[0].b64_json`. Decoded, it is a PNG file. `response_format: "url"` is not supported and returns `400 unknown_parameter`.

## Edit the image

Upload `kite.png` from the previous step as `image[]`, and say in `prompt` what to change:

cURL:

```bash
curl https://api.pirouter.ai/v1/images/edits \
  -H "Authorization: Bearer $PIROUTER_API_KEY" \
  -F model=openai/gpt-image-2.5-sunburst \
  -F "prompt=Make the kite blue; keep the background and composition unchanged" \
  -F "image[]=@kite.png" \
  -F size=1024x1024 \
  -F quality=low
```

Python:

```python
result = client.images.edit(
    model="openai/gpt-image-2.5-sunburst",
    image=[open("kite.png", "rb")],
    prompt="Make the kite blue; keep the background and composition unchanged",
    size="1024x1024",
    quality="low",
)
open("kite-blue.png", "wb").write(base64.b64decode(result.data[0].b64_json))
```

| Original                                                             | Edited                                                 |
| -------------------------------------------------------------------- | ------------------------------------------------------ |
| ![A red paper kite on a white background](/docs/images/kite-red.jpg) | ![The same kite, now blue](/docs/images/kite-blue.jpg) |

The response has the same shape as a generation: the new image is in `data[0].b64_json`. Apart from `model` and `stream`, every form field (`size`, `quality`, `n`, `output_format` and so on) goes to the model unchanged.

The routing fields `provider`, `models`, `route` and `fallbacks` have no effect on the edit endpoint and are removed before forwarding, so an edit request does not [fall back](https://pirouter.ai/docs/guides/fallbacks.md) to another model.

The whole request body can be at most 32 MiB. A larger one returns `413` and is neither forwarded nor charged; see [Requests that fail](https://pirouter.ai/docs/guides/billing.md#requests-that-fail).

### Edit only part of the image

Also upload a `mask`: a PNG the same size as the image, transparent where the model may make changes.

```bash
curl https://api.pirouter.ai/v1/images/edits \
  -H "Authorization: Bearer $PIROUTER_API_KEY" \
  -F model=openai/gpt-image-2.5-sunburst \
  -F "prompt=Add a small bright yellow sun in the masked area of the sky; keep everything else unchanged" \
  -F "image[]=@kite.png" \
  -F "mask=@mask.png"
```

### Combine several images

`image[]` can repeat. This combines the kite and a seascape into one poster with the official SDKs:

Python:

```python
result = client.images.edit(
    model="openai/gpt-image-2.5-sunburst",
    image=[open("kite.png", "rb"), open("sea.png", "rb")],
    prompt="Combine the two pictures into one poster: the kite flying over the watercolor sea",
    size="1024x1024",
    quality="low",
)
```

TypeScript:

```ts
import fs from 'node:fs'
import OpenAI, { toFile } from 'openai'

const client = new OpenAI({ baseURL: 'https://api.pirouter.ai/v1', apiKey: process.env.PIROUTER_API_KEY })
const images = await Promise.all(
  ['kite.png', 'sea.png'].map((path) => toFile(fs.createReadStream(path), path, { type: 'image/png' })),
)
const result = await client.images.edit({
  model: 'openai/gpt-image-2.5-sunburst',
  image: images,
  prompt: 'Combine the two pictures into one poster: the kite flying over the watercolor sea',
  size: '1024x1024',
  quality: 'low',
})
```

| Second input                              | Result                                                                  |
| ----------------------------------------- | ----------------------------------------------------------------------- |
| ![A watercolor sea](/docs/images/sea.jpg) | ![Kites flying over the watercolor sea](/docs/images/kite-over-sea.jpg) |

## Generate with the Gemini protocol

The Gemini protocol has no separate image endpoint: call `generateContent` on an image model with `IMAGE` in `generationConfig.responseModalities`, and the response carries the image. Authenticate as for any Gemini request, with `Authorization: Bearer`; the gateway does not read `x-goog-api-key`.

```bash
curl "https://api.pirouter.ai/v1beta/models/google/gemini-nano-banana-2.1:generateContent" \
  -H "Authorization: Bearer $PIROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contents": [{ "role": "user", "parts": [{ "text": "A flat vector icon of a red paper kite on a plain white background" }] }],
    "generationConfig": { "responseModalities": ["TEXT", "IMAGE"] }
  }'
```

The image is base64 in an `inlineData` part of `candidates[0].content.parts[]`, and `mimeType` names its format. Asking for `TEXT` as well does not guarantee a text part; this response has the image only. A response, with `data` cut short:

```json
{
  "candidates": [
    {
      "content": {
        "role": "model",
        "parts": [
          { "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgoAAAANSUhEUgAA..." } }
        ]
      },
      "finishReason": "STOP"
    }
  ],
  "modelVersion": "google/gemini-nano-banana-2.1",
  "usageMetadata": {
    "promptTokenCount": 14,
    "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 14 }],
    "candidatesTokenCount": 1120,
    "candidatesTokensDetails": [{ "modality": "IMAGE", "tokenCount": 1120 }],
    "thoughtsTokenCount": 1144,
    "totalTokenCount": 2278
  }
}
```

Billing follows the per-modality counts in `usageMetadata`: the `IMAGE` tokens of `candidatesTokensDetails` are charged at the image output price and the rest at the text output price; when the request carries images, the `IMAGE` share of `promptTokensDetails` is charged at the image input price. `thoughtsTokenCount` counts the model's thinking tokens; they are not part of `candidatesTokenCount` and are charged separately at the reasoning output price. Each `inlineData` part in the response counts as one image.

## Stream previews

Both endpoints accept `stream: true` (a form field, `-F stream=true`, on the edit endpoint). Set `partial_images` to the number of previews you want. The response becomes a stream of SSE events:

| Endpoint | Events, in order                                                     |
| -------- | -------------------------------------------------------------------- |
| Generate | `image_generation.partial_image` → `image_generation.completed`      |
| Edit     | `image_edit.partial_image` → `image_edit.completed` → `data: [DONE]` |

The `completed` event carries the final image and `usage`. The generation stream has no `data: [DONE]`: the `completed` event ends it. A model may send fewer previews than `partial_images` asks for, or none, and go straight to `completed`.

A large high-quality image can take over a minute. If nothing has been returned after 90 seconds, the gateway answers `200` first and sends keepalive content, so that nothing in between closes the connection. See [Long requests](https://pirouter.ai/docs/guides/timeouts.md#long-requests).

## Billing

An image request is charged for three kinds of tokens, at each model's prices:

| Charged for  | What it counts                                    | `pricing` field |
| ------------ | ------------------------------------------------- | --------------- |
| Text input   | The tokens of `prompt`                            | `prompt`        |
| Image input  | Uploaded images and masks, as image tokens        | `image_token`   |
| Image output | The generated images, including streamed previews | `image_output`  |

The usage is in the response's `usage`: `input_tokens_details` splits `text_tokens` from `image_tokens`, and `output_tokens_details.image_tokens` is the image output. A generation has no image input.

The request above that turned the kite blue used 27 text input, 1,024 image input and 196 image output tokens. At the list prices of `openai/gpt-image-2.5-sunburst` ($5 text input, $8 image input and $30 image output, per million tokens), that is $0.014207. When the model has a limited-time discount, the discounted price is charged; see [Limited-time discounts](https://pirouter.ai/docs/guides/billing.md#limited-time-discounts).

Two things cost more than you might expect:

- With `n` above 1, the input is charged once per image. An edit with `n=2` is charged twice the text and image input of the same edit with `n=1`.
- Streamed previews count as image output. The same edit with one preview had 273 image output tokens instead of 196.

A request with no image or no `prompt` returns `400` and is not charged. For other failures, see [Requests that fail](https://pirouter.ai/docs/guides/billing.md#requests-that-fail).

## Next steps

- [Create image API](https://pirouter.ai/docs/api/images.md): Every parameter and response of the generation endpoint.
- [Edit image API](https://pirouter.ai/docs/api/image-edits.md): Every parameter and response of the edit endpoint.
