API Reference

Create a chat completion

Sends a conversation to a model in the OpenAI chat shape. A text model answers with text and tool calls; an image model returns the rendered frame inside the assistant message.

POST/api/v1/chat/completions

The request and the reply keep the shape of the OpenAI chat API, so code already written against that shape — the OpenAI client library included — works after changing the base URL. One route serves both kinds of model, and the catalogue decides which reply you get: a text model answers in choices[0].message.content and may call the tools you declared; an image model renders a picture and returns it in choices[0].message.images. Which kind a model is stands in the catalogue as architecture.output_modalities.

There is no substitution in either direction: the model you name is the model that answers. A model that is listed but not served yet is refused with 400, an identifier that is not in the catalogue is a 404. Image models also answer on the images route; text models other than GPT also answer on the Messages route, in its native shape — and a text request here is executed by the same core: the same accounts, the same hold on your balance, the same billing and the same statuses. Only the envelope differs.

The GPT text models (the gpt-6, gpt-6.1 and gpt-5.6 models) answer on this route only and with text only. Declaring tools or attaching an image is refused with 400 rather than answered without them; with stream: true the finished reply arrives in one piece; max_tokens does not cut the reply short, and the bill follows the tokens of the reply. The effort is set with reasoning.effort. Each model page and the catalogue route list exactly what it accepts.

Headers

Request headers
FieldTypeDescription
AuthorizationstringrequiredBearer sk-sl-… — the key that pays
Content-Typestringapplication/json

Scroll the table sideways for the description.

No other header changes how a request is executed. The key identifies the account, and the account carries the balance, the ceilings and the rate that is charged. How the key is passed

Request body

Four fields are read whatever the model. The rest depends on the kind of model named in model: text models read the fields of the next section, image models read the image options after it.

Request body — every model
FieldTypeDescription
modelstringrequiredCatalogue identifier, vendor/model — a text or an image model
messagesarrayrequiredThe conversation; read in full, not only the last turn
streambooleanServer-sent events instead of one JSON (default false)
nintegerText models: 1 only. Image models: frames to render, 1 to 10 (default 1)

Scroll the table sideways for the description.

A content is either a string or an array of parts, where a part is {"type": "text", "text": …} or {"type": "image_url", "image_url": {"url": …}}. A broken structure — a message that is not an object, a text that is not a string, a tool call without a name — is a 400 naming the position, such as messages[2].content, refused before anything is reserved; so is an empty messages array. On a text model, n is 1 or nothing: n: 2 is a 400, because two answers would be two requests and two charges under one identifier. On an image model, values above 10 are clamped to 10, and a value that is not an integer is a 400.

Text models

The conversation is carried turn by turn, in the roles the OpenAI shape defines. system and developer messages are the standing instruction, wherever they stand in the array — an image inside one is a 400. A user message carries text and image_url parts; a data URL is sent as bytes, an http(s) link is opened by the model provider, not by us. An assistant message carries earlier answers and the tool calls the model made; a tool message carries a result under its tool_call_id, and adjacent results are merged into one turn. A part of any other kind — audio, a file — is a 400: dropping it would change the question the model is asked.

A cache_control mark on a text or image_url part — {"type": "ephemeral"}, optionally with a ttl — is an explicit prompt-cache point and travels to the model; one at the top level of the body is the automatic form and marks the last block of the conversation. Cache writes and reads are billed at the rates the catalogue publishes, and what was read from the cache is reported in usage.prompt_tokens_details.cached_tokens.

Request body — text models
FieldTypeDescription
toolsarrayFunctions the model may call: type "function" with name, description and a JSON-schema parameters object
tool_choicestring | objectauto, none, required, or {type: "function", function: {name}}. Without tools it is dropped, not refused
parallel_tool_callsbooleanfalse makes the model call one tool per turn
max_tokensintegerCeiling on the reply. Not sent: 4096. Also read as max_completion_tokens
temperature, top_pnumberSampling, where the model takes it — see below
top_kintegerThe k likeliest tokens only
stopstring | string[]Strings that end the generation
stream_optionsobjectAccepted and changes nothing: usage rides on the last chunk of every stream

Scroll the table sideways for the description.

max_tokens is the one field with a value of ours behind it: the executor requires a ceiling, the OpenAI shape does not, and a client written for that shape rarely sends one. The same number sizes the amount held on your balance while the request runs; the hold is settled against the tokens actually used. temperature and top_p reach the model where the model takes them; some models refuse them outright, and for those the two are removed before the body goes upstream rather than answered with a 400 — the catalogue leaves them out of that model's supported_parameters.

Accepted, removed, noted in our log

response_format, logit_bias, logprobs, seed, presence_penalty, frequency_penalty and user are accepted and dropped before the request goes upstream, with a line in our log: the executor has no equivalent for them, and refusing a body over a field the OpenAI shape allows would break clients that send it on every call. There is no JSON mode and no reproducible seed on this route.

shell
curl https://api.gtokens.io/api/v1/chat/completions \
  -H "Authorization: Bearer sk-sl-your-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "anthropic/claude-sonnet-5",
    "messages": [
      {"role": "system", "content": "Answer in two sentences."},
      {"role": "user", "content": "Describe a lighthouse at dawn"}
    ],
    "max_tokens": 256
  }'
main.py
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="anthropic/claude-sonnet-5",
    messages=[
        {"role": "system", "content": "Answer in two sentences."},
        {"role": "user", "content": "Describe a lighthouse at dawn"},
    ],
    max_tokens=256,
)
print(completion.choices[0].message.content)
main.ts
import OpenAI from 'openai'

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

const completion = await client.chat.completions.create({
  model: 'anthropic/claude-sonnet-5',
  messages: [
    { role: 'system', content: 'Answer in two sentences.' },
    { role: 'user', content: 'Describe a lighthouse at dawn' },
  ],
  max_tokens: 256,
})
console.log(completion.choices[0].message.content)

Calling a tool

Declare the tools, send the conversation, read tool_calls from the reply, run the function yourself and post the result back as a tool message with the same id. One whole round, as the body looks on the second call:

application/json
{
  "model": "anthropic/claude-sonnet-5",
  "messages": [
    {"role": "user", "content": "What is the weather in Lisbon?"},
    {
      "role": "assistant",
      "content": "",
      "tool_calls": [
        {
          "id": "toolu_01Vx3kL9pQ2mR8sT4uW6yZ0a",
          "type": "function",
          "function": {"name": "get_weather", "arguments": "{\"city\": \"Lisbon\"}"}
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "toolu_01Vx3kL9pQ2mR8sT4uW6yZ0a",
      "content": "{\"temperature_c\": 21, \"sky\": \"clear\"}"
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "Current weather in a city",
        "parameters": {
          "type": "object",
          "properties": {"city": {"type": "string"}},
          "required": ["city"]
        }
      }
    }
  ],
  "tool_choice": "auto"
}

arguments is a JSON string both ways — the model produces one, and you send one back in assistant.tool_calls. A tool without parameters is declared with an empty object schema. The reply that asks for a tool carries the same tool_calls array in its message and ends with finish_reason: "tool_calls"; the reply after the result is an ordinary answer.

Image options

For an image model the prompt is assembled from every message: system and developer messages go first, then the rest in the order sent. The text parts are joined into the prompt and image_url parts become reference images — up to 16 per request, each a data URL or an http(s) link that we fetch for you; parts of any other type are skipped rather than refused. A link that cannot be fetched is a 400 and nothing is charged. Messages that carry no text at all are a 400 before anything is reserved.

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": "system", "content": "Flat vector, muted palette."},
      {"role": "user", "content": "A lighthouse at dawn"}
    ],
    "image_config": {"aspect_ratio": "9:16", "image_size": "2K"}
  }'

Every field below is read both at the top level of the body and inside image_config; the two places mean the same thing. An unparseable value is a 400 before anything is charged, never a silent fallback. Any other field — temperature included — is ignored, so a payload copied from a text client still renders.

Image options
FieldTypeDescription
sizestringExact pixels ("1080x1920") or a tier ("512", "1K", "2K", "4K")
image_size, resolutionstringThe same values as size, under the names other clients use
aspect_ratio, aspectstringRatio such as "9:16" or "19.5:9"; "auto" leaves it open
qualitystringauto, low, medium or high (default high)
output_formatstringpng, jpeg or webp
output_compressioninteger0 to 100, applied to jpeg and webp
backgroundstringauto, transparent or opaque
seed, userinteger, stringAccepted, not acted on: nothing makes a frame reproducible, and a seed that looked accepted would promise otherwise

Scroll the table sideways for the description.

Exact pixels are a promise and a tier is a level: size: "1080x1920" comes back at exactly that size, while image_size: "2K" sets the short edge of the delivered frame and the aspect ratio decides the rest. Pixels that contradict a tier or a ratio sent in the same request are a 400 rather than a silent choice between the two wishes. The ceiling is 3840 px on a side and 8.29 megapixels (8,294,400 px) in total, refused before the balance is touched. background: "transparent" needs an output format that carries alpha — png or webp — and only some models support it; for a few, the requested frame matters too. Where it is not supported the request is a 400 up front that names the model, not the frame size, and nothing is billed — transparency silently flattened into white is worse than a refusal.

Geometry, tiers, what a frame costs, and the same call in three languages

Response

A completed request answers 200 with one JSON object. For a text model:

200 application/json
{
  "id": "gen-3b7e9c1d5a2f4086",
  "object": "chat.completion",
  "created": 1772668800,
  "model": "anthropic/claude-sonnet-5",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "A lighthouse at dawn stands pale against a sky just turning gold. Its beam, still lit, fades as the sun takes over."
      },
      "finish_reason": "stop",
      "native_finish_reason": "end_turn"
    }
  ],
  "usage": {
    "prompt_tokens": 26,
    "completion_tokens": 58,
    "total_tokens": 84,
    "cost": 0.000316
  }
}
Response fields
FieldTypeDescription
idstringgen-…, the identifier in your request log
object, created, modelstring, integer, stringchat.completion (chat.completion.chunk when streaming), unix seconds, the catalogue identifier you asked for
choicesarrayAlways one element, index 0
choices[].message.contentstringThe answer of a text model; empty for an image model
choices[].message.tool_calls[]arrayText models, when the model calls a tool: id, type "function", function.name, function.arguments as a JSON string
choices[].message.images[]arrayImage models: one entry per frame with a data URL in image_url.url; n: 4 gives one choice with four entries
choices[].finish_reasonstringstop (finished, or a stop string appeared), length (max_tokens reached), tool_calls (run the tools and continue), content_filter (the model declined); always stop on an image model
choices[].native_finish_reasonstringText models: the executor’s own word, such as end_turn or max_tokens
usageobjectTokens and the amount charged

Scroll the table sideways for the description.

A reply cut off by length is billed for what it produced, so a retry with a higher ceiling costs the truncated attempt as well. The image reply — same envelope, frame in images, empty content — is shown in full in the image generation guide.

usage.prompt_tokens
Input counted for this request. On a text model it includes what was written to and read from the prompt cache — the read part is repeated in prompt_tokens_details.cached_tokens, present only when a cache was involved. On an image model it is the prompt text plus a fixed count of about 2,900 tokens for each attached image — the catalogue's pricing.image is that count at the input rate — and that count is part of the charge, which is why a request with six references costs more than the same prompt alone.
usage.completion_tokens
Output tokens of the answer or of the frames. Where the executor reports them, the number is its own; where it does not, it is estimated from the size and shape of the delivered frame. Never zero on a request that produced something.
usage.total_tokens
The two above added together.
usage.cost
US dollars charged for this request — the amount taken from the balance, not an estimate. Always present, on text and image models alike, and the same figure the request log in your account shows under this id.

Streaming

With stream: true the same result is delivered as server-sent events under text/event-stream, each a single data: line carrying a chat.completion.chunk, closed by data: [DONE]. This is the second form of the same 200, not a different operation. A text model writes incrementally:

200 text/event-stream
data: {"id":"gen-3b7e9c1d5a2f4086","object":"chat.completion.chunk","created":1772668800,
"model":"anthropic/claude-sonnet-5","choices":[{"index":0,"delta":{"role":"assistant","content":""},
"finish_reason":null}]}

data: {"id":"gen-3b7e9c1d5a2f4086","object":"chat.completion.chunk","created":1772668800,
"model":"anthropic/claude-sonnet-5","choices":[{"index":0,"delta":{"content":"A lighthouse"},
"finish_reason":null}]}

data: {"id":"gen-3b7e9c1d5a2f4086","object":"chat.completion.chunk","created":1772668800,
"model":"anthropic/claude-sonnet-5","choices":[{"index":0,"delta":{"content":" at dawn."},
"finish_reason":null}]}

data: {"id":"gen-3b7e9c1d5a2f4086","object":"chat.completion.chunk","created":1772668800,
"model":"anthropic/claude-sonnet-5","choices":[{"index":0,"delta":{},"finish_reason":"stop",
"native_finish_reason":"end_turn"}],"usage":{"prompt_tokens":26,"completion_tokens":58,
"total_tokens":84,"cost":0.000316}}

data: [DONE]
  • On the wire each event is one data: line followed by a blank line; the events above are wrapped to fit the page. The payload sits in delta rather than message. delta.role is named once, in the first chunk; text arrives in delta.content, piece by piece.
  • finish_reason is null until the last chunk, which carries it together with native_finish_reason and the usage of the whole request, including cost — whether or not stream_options asked for it.
  • A tool call opens with a chunk that names it in delta.tool_calls — index, id, function.name and empty arguments — and continues in chunks that carry only function.arguments under the same index; joined in order, the pieces are the JSON string of the non-streaming reply.
  • Anything that can be judged before the model starts writing fails before the first byte and arrives as a real HTTP status. After the first byte the status is already spent, so a failure mid-answer arrives as a chunk with finish_reason: "error" and the usual error body beside choices, then [DONE]. What was produced before it is billed:
200 text/event-stream, failure mid-answer
data: {"id":"gen-3b7e9c1d5a2f4086","object":"chat.completion.chunk","created":1772668800,
"model":"anthropic/claude-sonnet-5","choices":[{"index":0,"delta":{},"finish_reason":"error"}],
"error":{"code":503,"message":"provider is temporarily unavailable, retry later",
"metadata":{"error_type":"provider_overloaded","reason":"no_provider_available"}}}

data: [DONE]
  • Nothing follows [DONE]. A client that keeps reading after it waits for its own timeout.
  • An image model does not stream progress: a frame is rendered whole, so the stream is one chunk with every frame in delta.images and the sentinel. The first byte leaves when the picture is already drawn, and anything that can fail fails before it.
Reading the event stream

Errors

Every refusal arrives in one shape, whatever went wrong:

error body
{
  "error": {
    "code": 402,
    "message": "insufficient balance",
    "metadata": {
      "error_type": "payment_required"
    }
  }
}

Branch on metadata.error_type rather than on the status alone — one status covers several causes. Where our own reason is narrower, it rides alongside in metadata.reason: a model listed but not served yet is invalid_request with reason: "model_not_available".

Status codes this route returns
Statuserror_typeWhen
400invalid_requestMalformed body, n above 1 on a text model, impossible frame size, model listed but not served, or the model itself refused the request
401authenticationMissing or unknown key
402payment_requiredBalance below the minimum, or a ceiling reached
403permission_deniedThe account is suspended
404not_foundNo such model in the catalogue
429rate_limit_exceededThe provider refused without naming when to come back, or asked for extra usage; carries Retry-After, but a repeat on a schedule will not change the answer
500serverOur fault, and nothing to fix on your side
502provider_unavailableThe executor answered with something unusable, or turned the request away before it reached the model; no Retry-After
503provider_overloadedEvery account for this model is busy or paused after a limit, or the renderer refused; Retry-After names when the nearest one comes back
504timeoutThe model did not answer in time

Scroll the table horizontally for the last column.

On a text model a limit hit upstream does not reach you while another account for the model is free: the request moves to it and the account that hit the limit is taken out of rotation until the time the provider named. Only when none is left does the 503 above arrive, with Retry-After set to that time.

A refused request is not charged. Everything that can be judged from the request alone is checked before the balance is touched; what needs the network happens after the hold is taken, and the hold is released in full on any failure. The one exception is a stream that broke after its first byte: what the model produced before the break is billed. Typed codes and what to retry