# Images

Generate an image from a text prompt, or edit existing images with a prompt. The endpoints are compatible with OpenAI Images, so the official OpenAI SDKs work with the base URL `https://kenari.id/v1`. Only image models are served here. Sending a text model returns `400`.

`POST /v1/images/generations`

`POST /v1/images/edits`

Find the image models, with their prices, in the model list:

```bash
curl -s https://kenari.id/v1/models \
  | jq '.data[] | select(.endpoints | index("images")) | {id, pricing_lines}'
```

## Request

### Generations

`POST /v1/images/generations` takes a JSON body.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Image model id, for example `gpt-image-2`. |
| `prompt` | string | yes | Description of the image. |
| `n` | integer | no | Number of images, 1 to 10. Default `1`. A value outside the range is moved to the nearest limit, and you are billed for that number. |
| `size` | string | no | Image size, for example `"1024x1024"`. A hint, not a guarantee. See [Image size](#image-size). |
| `background` | string | no | `"transparent"`, `"opaque"` or `"auto"`. Any other value returns `400`. Whether the image really has a transparent background depends on the model. |
| `response_format` | string | no | `"url"` or `"b64_json"`. Support varies by model, so leave it out to get the model's default. See [Response](#response). |
| `user`, `safety_identifier` | string | no | Accepted for SDK compatibility, not forwarded. |
| `stream` | boolean | no | Streaming is not supported. `true` returns `400`. |

### Edits

`POST /v1/images/edits` changes existing images according to a prompt. The request is `multipart/form-data` because the images are sent as files. Repeat the `image` field (or `image[]`) once per source file.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Image model id, for example `gpt-image-2`. |
| `image` | file | yes | Source image. Send PNG, JPEG or WebP. Repeat the field for up to 16 files, each up to 25 MiB. |
| `prompt` | string | yes | Description of the change. Up to 8,192 bytes. |
| `mask` | file | no | A PNG mask, up to 25 MiB. As in OpenAI Images, transparent areas mark where the image is edited. Without a mask, the whole image can change. |
| `n` | integer | no | Number of output images, 1 to 10. Default `1`. It does not depend on how many source files you send. |
| `size` | string | no | Image size, for example `"1024x1024"`. A hint, not a guarantee. See [Image size](#image-size). |
| `background` | string | no | `"opaque"` or `"auto"`. `"transparent"` is not supported on edits and returns `400`. Use generations for a transparent background. |
| `response_format` | string | no | `"url"` or `"b64_json"`. Same notes as generations. |
| `quality` | string | no | `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`, `"auto"` or `"standard"`. Applied when the model supports it. Any other value returns `400`. |
| `output_format` | string | no | `"png"`, `"jpeg"` or `"webp"`. Applied when the model supports it. Any other value returns `400`. |
| `output_compression` | integer | no | Compression for `jpeg` or `webp` output, a whole number from 0 to 100. Applied when the model supports it. Any other value returns `400`. |
| `input_fidelity` | string | no | `"high"` or `"low"`: how closely the result keeps the details of the source images. Applied when the model supports it. Any other value returns `400`. |
| `user`, `safety_identifier` | string | no | Accepted for SDK compatibility, not forwarded. |
| `stream` | boolean | no | Streaming is not supported. `true` returns `400`. |

The whole request body can be up to 32 MiB. A 17th image, a file above its limit or a body above 32 MiB is rejected before anything is charged. Some models accept a single source image or no mask. Those models answer `400` when you send more.

## Response

Both endpoints return the OpenAI Images shape: `created` and a `data` array with one item per image.

```json
{
  "created": 1718700000,
  "data": [
    { "b64_json": "iVBORw0KGgoAAAANSUhEUg..." }
  ]
}
```

Each item carries `b64_json`, `url`, or both, depending on the model and on `response_format`. A `url` is a self-contained `data:` URI with the image inside it, not a link to a hosted file. Read whichever field is present and decode the base64 part. The response can also include `usage`, `size`, `quality`, `output_format` and a per-image `revised_prompt` when the model reports them.

## Image size

Models treat `size` in three ways. Some use it as given, some ignore it, and some understand only aspect ratios. For a ratio-based model, kenari maps the size you send to the nearest ratio the model supports, so `"1792x1024"` gives a wide 16:9 image at whatever pixel count that model produces, not exactly 1792x1024. The shape lands as close as the model allows, but the pixel count can differ. If exact pixel dimensions matter, check the dimensions of the returned image.

## Long-running requests

An image can take several minutes. If the result is not ready within 45 seconds, kenari sends the `200` status and headers right away, then writes one space every 20 seconds to keep proxies from closing the connection, and finishes with the JSON body. Leading whitespace is valid JSON, so parse the complete body as usual. Set your client timeout to at least 5 minutes.

<aside class="warn"><strong>Warning</strong> A failure after the first 45 seconds arrives as HTTP <code>200</code> with an <code>error</code> object in the body instead of <code>data</code>. Read the full body, then check for <code>error</code> before reading <code>data</code>. Failures before that point keep their real status, such as <code>400</code>, <code>402</code> or <code>503</code>.</aside>

## Examples

### curl

```bash
curl https://kenari.id/v1/images/generations \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-image-2",
    "prompt": "A red panda writing code on a laptop",
    "n": 1,
    "size": "1024x1024"
  }' \
  --max-time 300 \
  --output response.json

if jq -e '.error' response.json > /dev/null; then
  jq -r '.error.message' response.json >&2
else
  jq -r '.data[0] | .b64_json // (.url | split(",")[1])' response.json \
    | base64 -d > image.png
fi
```

### Python

```python
import base64
import os

from openai import OpenAI

client = OpenAI(
    base_url="https://kenari.id/v1",
    api_key=os.environ["KENARI_API_KEY"],
)

result = client.images.generate(
    model="gpt-image-2",
    prompt="A red panda writing code on a laptop",
    n=1,
    size="1024x1024",
)

error = getattr(result, "error", None)
if error or not result.data:
    raise RuntimeError(f"Image request failed: {error}")

item = result.data[0]
b64 = item.b64_json or item.url.split(",", 1)[1]
with open("image.png", "wb") as f:
    f.write(base64.b64decode(b64))
```

### JavaScript

```js
import fs from "node:fs";
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://kenari.id/v1",
  apiKey: process.env.KENARI_API_KEY,
});

const result = await client.images.generate({
  model: "gpt-image-2",
  prompt: "A red panda writing code on a laptop",
  n: 1,
  size: "1024x1024",
});

if (result.error || !result.data?.length) {
  throw new Error(`Image request failed: ${JSON.stringify(result.error)}`);
}

const item = result.data[0];
const b64 = item.b64_json ?? item.url.split(",")[1];
fs.writeFileSync("image.png", Buffer.from(b64, "base64"));
```

### Edit with curl

```bash
curl https://kenari.id/v1/images/edits \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -F "model=gpt-image-2" \
  -F "image=@source.png" \
  -F "image=@reference.png" \
  -F "prompt=Change the sky to sunset, in the style of the second image" \
  -F "n=1" \
  --max-time 300 \
  --output response.json

if jq -e '.error' response.json > /dev/null; then
  jq -r '.error.message' response.json >&2
else
  jq -r '.data[0] | .b64_json // (.url | split(",")[1])' response.json \
    | base64 -d > edited.png
fi
```

### Edit with Python

```python
import base64
import os

from openai import OpenAI

client = OpenAI(
    base_url="https://kenari.id/v1",
    api_key=os.environ["KENARI_API_KEY"],
)

result = client.images.edit(
    model="gpt-image-2",
    image=[open("source.png", "rb"), open("reference.png", "rb")],
    prompt="Change the sky to sunset, in the style of the second image",
    n=1,
)

error = getattr(result, "error", None)
if error or not result.data:
    raise RuntimeError(f"Image request failed: {error}")

item = result.data[0]
b64 = item.b64_json or item.url.split(",", 1)[1]
with open("edited.png", "wb") as f:
    f.write(base64.b64decode(b64))
```

### Edit with JavaScript

```js
import fs from "node:fs";
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://kenari.id/v1",
  apiKey: process.env.KENARI_API_KEY,
});

const result = await client.images.edit({
  model: "gpt-image-2",
  image: [fs.createReadStream("source.png"), fs.createReadStream("reference.png")],
  prompt: "Change the sky to sunset, in the style of the second image",
  n: 1,
});

if (result.error || !result.data?.length) {
  throw new Error(`Image request failed: ${JSON.stringify(result.error)}`);
}

const item = result.data[0];
const b64 = item.b64_json ?? item.url.split(",")[1];
fs.writeFileSync("edited.png", Buffer.from(b64, "base64"));
```

## Billing

Images are billed per image: `n` times the model's price per image, taken from your balance. Four images cost four times one image, and the number of source files on an edit does not change the price. The amount is held when the request is accepted. You pay for the images actually delivered, so a provider that returns fewer than `n` images costs you less, and a response with no usable image is a failure that releases the whole hold. See [How billing works](/en/docs/billing) for the rest.

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | The model is not an image model, `prompt`, `model` or `image` is missing, `background` is invalid, `stream` is `true`, or an edit has more than 16 images, a file above 25 MiB, a prompt above 8,192 bytes, or a `quality`, `output_format`, `output_compression` or `input_fidelity` value outside its list. |
| 402 | `insufficient_balance` | The balance does not cover `n` images. |
| 413 | none | The request body is above 32 MiB. The response is plain text. |
| 503 | `all_providers_failed`, `upstream_error` | No provider could produce the image. Nothing is charged. Retry. |

See [Errors](/en/docs/errors) for every other code.

## See also

- [Models and pricing](/en/docs/models)
- [How billing works](/en/docs/billing)
- [Images and files as input](/en/docs/vision)
- [Errors](/en/docs/errors)
