Image generation API: aspect ratio, 2K tiers and exact size

Ask for exact pixels or a tier and an aspect ratio: what size comes back, where the fields go, what is refused with 400 and what sets the price.

GTokens teamUpdated

The short answer

For an exact frame, name the pixels: size: "1080x1920" comes back at 1080x1920, to the pixel. For a shape at a standard level, send a tier and a ratio: image_size: "2K" with aspect_ratio: "9:16" comes back at 1440x2560. That promise is made on chat/completions. Both fields are also read on /api/v1/images, at the top level of the body and inside image_config, but there they go to the model as sent, and the size of the frame is not promised.

  • On chat completions exact pixels are a promise. A tier is a level: it sets the short edge, and the ratio sets the rest.
  • On chat completions the frame is a data URL in choices[0].message.images[0].image_url.url; on the image route it is raw base64 in data[].b64_json.
  • Values that contradict each other, values no field understands and frames past the size ceilings are refused with 400 before anything is charged.
  • A frame is billed in tokens, and the count follows the shape of the frame that was drawn, not its area.

Exact pixels or a tier: two different promises

There are two ways to ask for the size of a frame, and on chat/completions they promise different things. Exact pixels are a promise: the frame you get is the frame you named. A tier is a normalised level, not a pixel count: it fixes the short edge of the delivered frame, and the aspect ratio decides the other side. Mixing the two up is the usual reason a reply is a perfectly good image at the wrong size, with no error to point at it.

What you send and what comes back on chat completions
You sendWhat comes back
size: "1080x1920"Exactly 1080x1920. If the model drew at another size, the frame is resampled to yours — not cropped and not padded, so the composition survives.
image_size: "2K" + aspect_ratio: "9:16"1440x2560: the tier sets the short edge, the ratio sets the long one.
image_size: "2K"A 1440 px short edge. The ratio is taken from the frame that was drawn; nothing about the shape is invented.
aspect_ratio: "9:16"The shape alone. size and the tier stay at their default, auto, which declines to ask for a size.

Scroll the table sideways for what comes back.

The tier goes in image_size or resolution — the two names are one field — and a tier written into size means the same thing. It works the other way round too: pixels sent in image_size or resolution are exact pixels. The ratio is any W:H, fractional sides included — 9:19.5 is a phone screen and is accepted as such — and is also read under the name aspect. Each tier lands on one short edge:

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 size ceilings

Scroll the table sideways for the example.

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. When the layout depends on the pixels, name the pixels. The GPT Image 2.5 models draw the pixels you name themselves rather than having the frame resampled, so a size such a model cannot draw comes back as a 400 in its own words.

The image route makes no promise about size. There the tier is the model’s own level and the ratio is limited to the shapes the model draws, so 2K at 9:16 can come back smaller than the 1440x2560 above. Read the size from the frame that came back, or call chat/completions when the pixels matter.

Ask for a tier and a ratio on chat completions

An image model is called on /api/v1/chat/completions with the same body as a text model: the model, the messages, and the geometry. The geometry fields are read both at the top level of the body and inside image_config, and the two places mean the same thing, so it does not matter which one your client fills in. With the OpenAI client library the field travels in extra_body in Python and as an extra property in TypeScript:

tier.py
import base64
from openai import OpenAI

client = OpenAI(
    base_url="https://api.gtokens.io/api/v1",
    api_key="sk-sl-your-key",
)

completion = client.chat.completions.create(
    model="google/gemini-nano-banana-2.1",
    messages=[{"role": "user", "content": "A quiet harbour at dawn, long exposure"}],
    # geometry is a gateway field, so it travels in extra_body
    extra_body={"image_config": {"aspect_ratio": "9:16", "image_size": "2K"}},
)

url = completion.choices[0].message.images[0]["image_url"]["url"]
header, data = url.split(",", 1)              # data:image/png;base64,<bytes>
ext = header.split(";")[0].split("/")[1]
with open(f"harbour.{ext}", "wb") as f:
    f.write(base64.b64decode(data))
print(completion.usage.cost, "USD charged")
tier.ts
import OpenAI from 'openai'
import { writeFile } from 'node:fs/promises'

const client = new OpenAI({
  baseURL: 'https://api.gtokens.io/api/v1',
  apiKey: 'sk-sl-your-key',
})

const completion = await client.chat.completions.create({
  model: 'google/gemini-nano-banana-2.1',
  messages: [{ role: 'user', content: 'A quiet harbour at dawn, long exposure' }],
  // @ts-expect-error image_config is a gateway field
  image_config: { aspect_ratio: '9:16', image_size: '2K' },
})

const [image] = (completion.choices[0].message as any).images
const [header, data] = image.image_url.url.split(',') // data:image/png;base64,<bytes>
const ext = header.split(';')[0].split('/')[1]
await writeFile(`harbour.${ext}`, Buffer.from(data, 'base64'))
console.log((completion.usage as any).cost, 'USD charged')

The frame comes back as a data URL in choices[0].message.images[0].image_url.url, and content is empty. Ask for several frames with n — one choice then carries them all in images. n is the one field read at the top level only: inside image_config it has no effect. A frame takes tens of seconds rather than milliseconds, so keep the client timeout generous.

Send each geometry field once. When one appears in both places every copy is still checked, so a typo in the copy that loses does not slip through, but which of the two wins is not worth relying on.

Edit from a reference on the image route

The image route, POST /api/v1/images, takes the same geometry fields with the prompt as a single prompt string and the frames to work from in input_references. It is the route for editing a frame you already have and for several variants of one edit; leave input_references out and it draws from the prompt alone. Call it with plain HTTP — the client library’s image helpers use other paths:

edit.py
import base64
import requests

with open("mug.jpg", "rb") as f:
    reference = "data:image/jpeg;base64," + base64.b64encode(f.read()).decode()

r = requests.post(
    "https://api.gtokens.io/api/v1/images",
    headers={"Authorization": "Bearer sk-sl-your-key"},
    json={
        "model": "openai/gpt-image-2.5-flare",
        "prompt": "Put the mug on a marble counter in soft daylight",
        "size": "1536x1024",            # exact pixels, a size this model draws as named
        "n": 2,                         # two variants of the same edit
        "input_references": [reference],
    },
    timeout=300,
)
if r.status_code != 200:
    raise SystemExit(r.json()["error"]["message"])

body = r.json()
for i, frame in enumerate(body["data"]):            # one entry per frame delivered
    ext = frame.get("media_type", "image/png").split("/")[1]
    with open(f"mug-{i}.{ext}", "wb") as f:
        f.write(base64.b64decode(frame["b64_json"]))  # raw base64, no data: prefix
print(body["usage"]["cost"], "USD charged for the whole request")
edit.ts
import { readFile, writeFile } from 'node:fs/promises'

const reference = 'data:image/jpeg;base64,' + (await readFile('mug.jpg')).toString('base64')

const r = await fetch('https://api.gtokens.io/api/v1/images', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer sk-sl-your-key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'openai/gpt-image-2.5-flare',
    prompt: 'Put the mug on a marble counter in soft daylight',
    size: '1536x1024', // exact pixels, a size this model draws as named
    n: 2, // two variants of the same edit
    input_references: [reference],
  }),
})
if (!r.ok) throw new Error((await r.json()).error.message)

const body = await r.json()
for (const [i, frame] of body.data.entries()) {
  const ext = (frame.media_type ?? 'image/png').split('/')[1]
  await writeFile(`mug-${i}.${ext}`, Buffer.from(frame.b64_json, 'base64'))
}
console.log(body.usage.cost, 'USD charged for the whole request')

The reply has one entry in data per frame delivered, with raw base64 in b64_json and no data: prefix. The size of a frame is not promised on this route: read it from the image. Take the file extension from media_type rather than from what you asked for: it is read from the bytes that came back. usage.cost covers the whole request, every frame included.

A reference is a data URL, an http(s) URL that is fetched for you, or the content-part object {"type": "image_url", "image_url": {"url": "…"}}, and a request takes up to 16 references. Each fetched URL must be an image, at most 20 MB, answer within ten seconds and not redirect; one that cannot be fetched is a 400 with image_download_failed, before the balance is touched. A request whose references are too heavy in total is refused with 413. The route serves image models only, and prompt must be a non-empty string.

What is refused with 400

Geometry is checked before anything is held on the balance, and a body the gateway cannot honour is refused rather than drawn on a guess. A 400 here costs nothing:

  • Fields that contradict each other. Exact pixels next to an aspect ratio they do not match, or next to a tier of a different class, are refused with a message naming both fields. Values that agree pass: 1080x1920 with 9:16 is the same frame said twice.
  • A value no field understands. size, resolution, image_size, aspect_ratio, quality, output_format and background take a fixed set of values, and the message names the field a bad one came from. A ratio with zero on either side is refused too. auto is legal in every image field: it is not a typo, it is declining to ask.
  • A frame past the ceilings. At most 3840 px on a side and 8.29 megapixels (8,294,400 px) in total, 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 shrunk to fit.
  • A frame count out of range. n is 1 to 10, one by default; anything outside is refused with 400.
refusals.py
import requests

def draw(**geometry):
    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"}],
            "image_config": geometry,
        },
        timeout=300,
    )
    if r.status_code == 400:
        error = r.json()["error"]
        # refused before the balance is touched; the message names the field
        print(error.get("metadata", {}).get("error_type"), "|", error["message"])
        return None
    r.raise_for_status()
    return r.json()

draw(size="1080x1920", aspect_ratio="16:9")   # pixels contradict the ratio: 400
draw(aspect_ratio="9x16")                      # not written W:H: 400
draw(size="4096x4096")                         # over 3840 px on a side: 400
draw(size="1080x1920", aspect_ratio="9:16")   # the same frame said twice: drawn

The refusal arrives as {"error": {"code", "message", "metadata": {"error_type"}}} with error_type set to invalid_request for a bad or clashing value. Branch on metadata.error_type rather than on the status alone, and read the message: it says which field to change.

What sets the price of a frame

An image model is metered in tokens, like a text model. The prompt and every attached image are input, billed at the model’s pricing.prompt; each reference is billed as input tokens, and the count is in usage. The frame is output, billed at pricing.image_output. There is no price per image and no scale by area: what sets the output count is the shape of the frame that was drawn.

Two consequences surprise people. A square takes more tokens than an oblong frame of the same size, so in the guide’s measurements 2K at 1:1 — 1440x1440 — is a smaller frame than 2K at 9:16 and still takes more tokens. And orientation makes no difference: a portrait frame and the same frame turned to landscape take the same count. The count also follows the frame that was drawn rather than the one you named: on chat completions an exact size is drawn at the nearest size the model accepts and then resampled to your pixels, so a tier and exact pixels that land on the same frame cost the same.

Reconcile against usage.cost

Before the request goes out, the balance is held at an estimate for the size and the number of frames you asked for. When the reply arrives the hold is settled against the tokens actually reported, and the difference returns to the balance. If fewer frames come back than you asked for, you pay for the ones that did; a request that produces no frame is not billed. usage.cost on the reply is the exact amount charged, and the image guide lists measured token counts by shape.

Picking a model

Every model that draws carries ["image"] in architecture.output_modalities in the catalogue — filter on that field rather than on the identifier. They all take the geometry fields above; what tells them apart is the entry itself, the rates and the description:

  • GPT-5.4 Image 2 (openai/gpt-5.4-image-2) — image generation and editing, metered in output tokens.
  • GPT Image 2.5 Flare (openai/gpt-image-2.5-flare) — the fast tier of GPT Image 2.5, at roughly a quarter of the tokens per frame of GPT-5.4 Image 2.
  • Nano Banana 2.1 (google/gemini-nano-banana-2.1) — frames at half the Nano Banana 2 output rate, with a higher input rate. References are input, so an edit from several of them leans on the input rate.

Two settings depend on the model. background: "transparent" needs png or webp and a model that supports it — only some do — and where it is not supported the request is a 400 naming the model, before anything is billed. quality takes auto, low, medium, high, xhigh or max — the two above high are levels of GPT Image 2.5 — and without quality the model decides. It is a request rather than a guarantee: a model may ignore it. The current rates of every model, these three included, are on the models page.

More in Guides

All in Guides →