Guides

Image generation

Two routes draw frames, and both take the same geometry. This page is about asking for the frame you actually want — and about what the size of it does to the bill.

Image models answer on two routes: /api/v1/chat/completions, where the prompt arrives as a conversation, and /api/v1/images, where it arrives as a single prompt with reference frames beside it. Both take the same size fields, the same output settings and the same references; they differ only in the envelope. Which models draw rather than write is a property of the catalogue entry, not of the route.

Browse the catalogue

One request, end to end

Prompt A quiet harbour at dawn, long exposure, model openai/gpt-5.4-image-2, with aspect_ratio: "9:16" and image_size: "2K".

  • Delivered: one PNG, 1440x2560, in tens of seconds rather than milliseconds — image calls need a generous client timeout.
  • Billed: nine prompt tokens and 7,370 output tokens — the count a 1440x2560 frame takes on this model. A tier is not an exact size, but the frame it lands on is what gets counted.
  • usage.cost on that reply was 0.199055 — the exact amount taken off the balance. The rates behind it are in the catalogue; the number here is one run, not a published price.

Finding image models

There is one catalogue and it is public — no key, no per-provider records, no second endpoint for image models. An entry describes what the model draws and what it costs:

shell
curl https://api.gtokens.io/api/v1/models
Catalogue fields that matter for images
FieldTypeDescription
architecture.output_modalitiesstring[]["image"] on a model that draws. This is the field to filter on — guessing from the identifier breaks on the first model whose name does not say what it makes.
statusstringavailable or coming_soon. A listed but unconnected model is refused with a plain error; it is never quietly replaced by a similar one.
pricing.image_outputstringOutput rate, per token. The frame is output, and how many tokens it takes is set by its shape — see what a frame costs.
pricing.promptstringInput rate, per token. The prompt text and attached images are billed with it, so this is the rate that turns references into money.
pricing.imagestringPrice of one attached input image at that rate — 2,900 input tokens, already multiplied out.

Scroll the table sideways for the description.

The full catalogue record

Sending a request

A generation is a JSON POST with the model, the prompt and — if you care about the shape of the frame — the geometry. Everything else has a default.

shell
curl https://api.gtokens.io/api/v1/chat/completions \
  -H "Authorization: Bearer sk-sl-your-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-5.4-image-2",
    "messages": [
      {"role": "user", "content": "A quiet harbour at dawn, long exposure"}
    ],
    "image_config": {"aspect_ratio": "9:16", "image_size": "2K"}
  }'
main.py
import base64
import requests

r = requests.post(
    "https://api.gtokens.io/api/v1/chat/completions",
    headers={"Authorization": "Bearer sk-sl-your-key"},
    json={
        "model": "openai/gpt-5.4-image-2",
        "messages": [
            {"role": "user", "content": "A quiet harbour at dawn, long exposure"}
        ],
        "image_config": {"aspect_ratio": "9:16", "image_size": "2K"},
    },
    timeout=300,   # a frame takes tens of seconds, not milliseconds
)
r.raise_for_status()
body = r.json()

url = body["choices"][0]["message"]["images"][0]["image_url"]["url"]
with open("frame.png", "wb") as f:
    f.write(base64.b64decode(url.split(",", 1)[1]))

print(body["usage"]["cost"], "charged")
main.ts
import { writeFile } from 'node:fs/promises'

const r = await fetch('https://api.gtokens.io/api/v1/chat/completions', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer sk-sl-your-key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'openai/gpt-5.4-image-2',
    messages: [
      { role: 'user', content: 'A quiet harbour at dawn, long exposure' },
    ],
    image_config: { aspect_ratio: '9:16', image_size: '2K' },
  }),
})
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
const body = await r.json()

const url = body.choices[0].message.images[0].image_url.url
await writeFile('frame.png', Buffer.from(url.split(',')[1], 'base64'))
console.log(body.usage.cost, 'charged')

The edit route takes the same fields with the prompt at the top level and the frames to work from in input_references. It is also the route that returns several variants of one edit, which is what n is for:

json
{
  "model": "openai/gpt-5.4-image-2",
  "prompt": "Replace the sky with a clear night",
  "size": "1080x1920",
  "n": 3,
  "input_references": [
    {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
  ]
}

Text and images are read from every message of the conversation, not only from the last one; system and developer messages are placed first, since a style instruction is a frame for everything after it. On the edit route prompt is required and must not be empty, and the same now holds on the conversation route: messages that carry no text at all are refused before the balance is touched, rather than drawn from an empty prompt and billed in full.

What comes back

Frames arrive as base64 bytes, never as a link to fetch later, and usage carries both the token counters and cost — the amount actually charged for this request. On the conversation route the frame is a data URL inside the message:

json
{
  "id": "gen-4f1c2b9a7d5e0846",
  "object": "chat.completion",
  "created": 1748372400,
  "model": "openai/gpt-5.4-image-2",
  "choices": [
    {
      "index": 0,
      "finish_reason": "stop",
      "message": {
        "role": "assistant",
        "content": "",
        "images": [
          {
            "type": "image_url",
            "image_url": {"url": "data:image/png;base64,<base64-encoded-image>"}
          }
        ]
      }
    }
  ],
  "usage": {
    "prompt_tokens": 9,
    "completion_tokens": 7370,
    "total_tokens": 7379,
    "cost": 0.199055
  }
}

On the edit route the frames come as raw base64 with the type beside each of them — one entry per delivered frame, which is what the body above asked for. The media_type is read from the bytes that are being returned, so it describes the file you got and not the format you asked for:

json
{
  "created": 1748372400,
  "data": [
    {"b64_json": "<base64-encoded-image>", "media_type": "image/png"},
    {"b64_json": "<base64-encoded-image>", "media_type": "image/png"},
    {"b64_json": "<base64-encoded-image>", "media_type": "image/png"}
  ],
  "usage": {
    "prompt_tokens": 2908,
    "completion_tokens": 15750,
    "total_tokens": 18658,
    "cost": 0.446188
  }
}

With stream: true both routes answer as server-sent events instead. There are no partial frames: a frame is rendered whole, so the stream is one terminal event per frame and then the closing sentinel. How the stream ends

Asking for a size

There are two ways to ask, and they promise different things. Both are read at the top level of the body and inside image_config, so it does not matter which of the two your client puts them in.

Exact pixels — a promise

json
{
  "model": "openai/gpt-5.4-image-2",
  "prompt": "A quiet harbour at dawn",
  "size": "1080x1920"
}
  • size: "1080x1920" comes back as 1080x1920, to the pixel. If the renderer worked at a different size, the frame is resampled to the size you named — resampled, not cropped and not padded, so the composition survives and only a few percent of scale changes.
  • Pixels are accepted in resolution and image_size too: an unambiguous size is an unambiguous size whatever field it arrives in.
  • An exact size is drawn at the nearest size the renderer accepts and then resampled to the pixels you named. The token count — and so the price — follows the frame that was drawn, which is why a tier and exact pixels that land on the same frame cost the same. See what a frame costs.

A tier and an aspect ratio — a wish

json
{
  "model": "openai/gpt-5.4-image-2",
  "prompt": "A quiet harbour at dawn",
  "image_size": "2K",
  "aspect_ratio": "9:16"
}
  • resolution, its alias image_size, and a tier written into size all mean the same thing: a normalised level, not a pixel count. The tier sets the short edge of the delivered frame.
  • aspect_ratio is any W:H, fractional sides included (9:19.5 is a phone screen and is accepted as such), or auto for "no requirement". The alias aspect is read as well.
  • A tier without an aspect ratio still works: the ratio is then taken from the frame that was drawn, and only the short edge is set from the tier. Nothing about the shape is invented.
  • A tier is a wish, so it bends where it has to: an aspect ratio steeper than 3:1 is delivered as drawn rather than stretched into a strip, and the tier size is clamped by the same ceilings as an exact size.
Tier to delivered short edge
TierShort edgeExample delivery
512512 px512x512 at 1:1
1K720 px720x1280 at 9:16
2K1440 px1440x2560 at 9:16
4K2880 px3840x2160 at 16:9, clamped by the ceilings below

Scroll the table horizontally for the example.

When the fields disagree

Two contradictory wishes are a 400, never a silent choice of one of them: exact pixels next to a tier of a different class, or next to an aspect ratio the pixels do not match, name both fields in the error. A value none of these fields understands is a 400 as well, and it says which field it came from — a typo in aspect_ratio that was swallowed quietly would be discovered in the layout, days later, and blamed on the prompt.

Limits and refusals

Everything here is checked before the balance is touched, so a request that trips one of these limits costs nothing.

  • Frame size — at most 3840 px on a side and 8.29 megapixels (8,294,400 px) of area, at least 16 px on a side. 3840x2160 satisfies both and is the largest frame there is. A larger size is refused outright rather than quietly shrunk to something that fits.
  • Frames per request — n from 1 to 10, one by default. A larger number is clamped to 10; something that is not a number is a 400. Fewer frames than asked for may come back, and you are billed for the ones that did.
  • Reference images — up to 16 per request, up to 20 MB each.
  • Balance — a request the balance cannot cover comes back 402 payment_required and is not charged. Balance and throughput

Quality and format

json
{
  "model": "openai/gpt-5.4-image-2",
  "prompt": "A product shot of a ceramic mug",
  "quality": "high",
  "output_format": "webp",
  "output_compression": 90,
  "background": "opaque"
}
  • quality — auto, low, medium or high; high by default. A renderer without a quality control ignores it. An unrecognised value is a 400 rather than a quiet fallback to the expensive setting.
  • output_format — png, jpeg or webp. Omit it and the frame arrives as it was rendered. Conversion happens on delivery, and media_type always describes the bytes you received.
  • output_compression — 0 to 100 for jpeg and webp, ignored for png. A frame that is already in the requested format is passed through untouched, so it is not recompressed either — a second lossy pass would cost quality and buy nothing.
  • background — auto, transparent or opaque. transparent needs a format that carries alpha (png or webp) and a model that supports it — only some do, and for a few the requested frame matters too. Where it is not supported the request is a 400 before you are charged, and the refusal names the model, not the frame size, because there is nothing to honour it with.
  • seed and user are accepted and not executed. There is no reproducible seed here: the same body twice gives two different frames, and a request that depends on that must keep the frame, not the seed.

Reference images

To draw from an image rather than from words, attach it. On the conversation route content becomes an array of parts — text and image_url — and images from every message of the conversation are collected. On the edit route the same frames go into a flat input_references array, either as bare strings or in the same image_url shape. Put the text before the images: the prompt is assembled in the order it arrives, and an instruction that follows its attachments reads as an afterthought.

json
{
  "model": "openai/gpt-5.4-image-2",
  "messages": [
    {
      "role": "user",
      "content": [
        {"type": "text", "text": "Redraw this scene as a watercolour"},
        {"type": "image_url", "image_url": {"url": "https://example.com/photo.jpg"}}
      ]
    }
  ]
}
  • A URL is the cheaper form for anything publicly reachable: nothing has to be encoded on your side, and the request body stays small.
  • A data URL is the only form for local or private files. The shape is exactly data:image/<type>;base64,<bytes>; a data: url in any other shape is refused rather than passed on as a broken attachment.
main.py
import base64
import requests

# A local file has to travel as a data url: nothing on this machine
# is reachable from the outside.
with open("photo.jpg", "rb") as f:
    ref = "data:image/jpeg;base64," + base64.b64encode(f.read()).decode()

r = requests.post(
    "https://api.gtokens.io/api/v1/chat/completions",
    headers={"Authorization": "Bearer sk-sl-your-key"},
    json={
        "model": "openai/gpt-5.4-image-2",
        "messages": [{
            "role": "user",
            "content": [
                {"type": "text", "text": "Redraw this scene as a watercolour"},
                {"type": "image_url", "image_url": {"url": ref}},
            ],
        }],
        "image_config": {"aspect_ratio": "1:1"},
    },
    timeout=300,
)
r.raise_for_status()
main.ts
import { readFile } from 'node:fs/promises'

// A local file has to travel as a data url: nothing on this machine
// is reachable from the outside.
const bytes = await readFile('photo.jpg')
const ref = `data:image/jpeg;base64,${bytes.toString('base64')}`

const r = await fetch('https://api.gtokens.io/api/v1/chat/completions', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer sk-sl-your-key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'openai/gpt-5.4-image-2',
    messages: [{
      role: 'user',
      content: [
        { type: 'text', text: 'Redraw this scene as a watercolour' },
        { type: 'image_url', image_url: { url: ref } },
      ],
    }],
    image_config: { aspect_ratio: '1:1' },
  }),
})
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
shell
# The edit route takes the same references in a flat array.
REF="data:image/jpeg;base64,$(base64 -i photo.jpg | tr -d '\n')"

curl https://api.gtokens.io/api/v1/images \
  -H "Authorization: Bearer sk-sl-your-key" \
  -H "Content-Type: application/json" \
  -d "{
    \"model\": \"openai/gpt-5.4-image-2\",
    \"prompt\": \"Redraw this scene as a watercolour\",
    \"input_references\": [\"$REF\"]
  }"

A URL is fetched from our side, which is why it comes with conditions: http and https only, ten seconds to answer, 20 MB at most, a content type starting with image/, no redirects followed, and no addresses that resolve to private or local ranges. Any of those failing is a 400 image_download_failed — raised before the balance is touched, so an unreachable reference costs nothing. Any image/ type is accepted and passed through; png, jpeg and webp are the set that every renderer here understands.

What a frame costs

A frame is not one price. An image model is metered in tokens like a text model: the prompt and every attached image are input at pricing.prompt, the frame is output at pricing.image_output, and the charge is the two counters of usage multiplied by those two rates. What makes a frame different from a paragraph is where the output count comes from: it is set by the shape of the frame the renderer draws, not by its area. Measured on the image model in the catalogue:

Output tokens by frame shape
FrameShapeOutput tokensAbout
1280x72016:93,787$0.10
1024x10241:17,024$0.19
1536x10243:25,488$0.15
1440x14401:19,386$0.25
2560x144016:97,370$0.20
2048x20481:114,272$0.39
3840x216016:913,342$0.36

Scroll the table horizontally for the last column.

Two things the table shows that a per-pixel intuition gets wrong. A square costs more than an oblong frame of the same size: 1024x1024 takes 7,024 tokens where 1536x1024 — half as large again — takes 5,488. And orientation does not matter: 1536x1024 and 1024x1536 take exactly the same count. The last column is the count at the current output rate, before any input, and it is a guide rather than a price: the charge follows the tokens the renderer reports for the frame it actually drew.

A worked example, not a rate table

image_size: "2K" with aspect_ratio: "9:16" lands on 1440x2560, an oblong frame of 7,370 output tokens — about $0.20 at the current rate, plus nine input tokens for a short prompt. The same request with aspect_ratio: "1:1" lands on 1440x1440, a square of 9,386 tokens: a smaller frame that costs more. Six attached references add about 17,400 input tokens — 2,900 each, plus about one token per four characters of prompt text — more tokens than the frame itself, though at the input rate they come to about $0.13, well under the frame.

The count follows the frame that is drawn, not the one you named. An exact size is drawn at the nearest size the renderer accepts and resampled to your pixels, so 1080x1920 is billed for the frame the renderer produced for it; a tier lands on a frame the same way, and a tier and exact pixels that land on the same frame cost the same. When the renderer reports no count for a frame, one is estimated from the size and shape of the frame it drew — the oblong curve for an oblong frame, the square curve for a square one; only when the shape is not known does the estimate fall back to the square curve, which errs on the high side.

Billing itself is settled per request, not per attempt. The balance is held at an estimate for the size and the number of frames you asked for, before anything is sent onward; when the reply comes back the hold is settled against the tokens actually reported, and the difference returns to the balance. A request that does not produce frames — a refusal, an unreachable reference, a busy pool, a provider error — releases the hold in full and is not billed. The number to reconcile against is always usage.cost on the reply.

Request parameters

Every field an image request accepts — including the ones that are accepted and not executed, and the defaults applied when a field is omitted — is listed in one table on the parameters page, alongside the fields of the other two routes.

Every request parameter

Before you build anything against the numbers on this page, read pricing for the model from the catalogue: prompt and image_output are the two rates a frame is billed at, and usage on each reply is the count the charge was computed from. Read the catalogue