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
| Field | Type | Description |
|---|---|---|
| Authorization | stringrequired | Bearer sk-sl-… — the key that pays |
| Content-Type | string | application/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.
| Field | Type | Description |
|---|---|---|
| model | stringrequired | Catalogue identifier, vendor/model — a text or an image model |
| messages | arrayrequired | The conversation; read in full, not only the last turn |
| stream | boolean | Server-sent events instead of one JSON (default false) |
| n | integer | Text 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.
| Field | Type | Description |
|---|---|---|
| tools | array | Functions the model may call: type "function" with name, description and a JSON-schema parameters object |
| tool_choice | string | object | auto, none, required, or {type: "function", function: {name}}. Without tools it is dropped, not refused |
| parallel_tool_calls | boolean | false makes the model call one tool per turn |
| max_tokens | integer | Ceiling on the reply. Not sent: 4096. Also read as max_completion_tokens |
| temperature, top_p | number | Sampling, where the model takes it — see below |
| top_k | integer | The k likeliest tokens only |
| stop | string | string[] | Strings that end the generation |
| stream_options | object | Accepted 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.
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.
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
}'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)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:
{
"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.
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.
| Field | Type | Description |
|---|---|---|
| size | string | Exact pixels ("1080x1920") or a tier ("512", "1K", "2K", "4K") |
| image_size, resolution | string | The same values as size, under the names other clients use |
| aspect_ratio, aspect | string | Ratio such as "9:16" or "19.5:9"; "auto" leaves it open |
| quality | string | auto, low, medium or high (default high) |
| output_format | string | png, jpeg or webp |
| output_compression | integer | 0 to 100, applied to jpeg and webp |
| background | string | auto, transparent or opaque |
| seed, user | integer, string | Accepted, 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.
Response
A completed request answers 200 with one JSON object. For a text model:
{
"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
}
}| Field | Type | Description |
|---|---|---|
| id | string | gen-…, the identifier in your request log |
| object, created, model | string, integer, string | chat.completion (chat.completion.chunk when streaming), unix seconds, the catalogue identifier you asked for |
| choices | array | Always one element, index 0 |
| choices[]. | string | The answer of a text model; empty for an image model |
| choices[]. | array | Text models, when the model calls a tool: id, type "function", function.name, function.arguments as a JSON string |
| choices[]. | array | Image models: one entry per frame with a data URL in image_url.url; n: 4 gives one choice with four entries |
| choices[]. | string | stop (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[]. | string | Text models: the executor’s own word, such as end_turn or max_tokens |
| usage | object | Tokens 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'spricing.imageis 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:
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 indeltarather thanmessage.delta.roleis named once, in the first chunk; text arrives indelta.content, piece by piece. finish_reasonisnulluntil the last chunk, which carries it together withnative_finish_reasonand theusageof the whole request, includingcost— whether or notstream_optionsasked for it.- A tool call opens with a chunk that names it in
delta.tool_calls—index,id,function.nameand empty arguments — and continues in chunks that carry onlyfunction.argumentsunder 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 usualerrorbody besidechoices, then[DONE]. What was produced before it is billed:
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.imagesand the sentinel. The first byte leaves when the picture is already drawn, and anything that can fail fails before it.
Errors
Every refusal arrives in one shape, whatever went wrong:
{
"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 | error_type | When |
|---|---|---|
| 400 | invalid_request | Malformed body, n above 1 on a text model, impossible frame size, model listed but not served, or the model itself refused the request |
| 401 | authentication | Missing or unknown key |
| 402 | payment_required | Balance below the minimum, or a ceiling reached |
| 403 | permission_denied | The account is suspended |
| 404 | not_found | No such model in the catalogue |
| 429 | rate_limit_exceeded | The 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 |
| 500 | server | Our fault, and nothing to fix on your side |
| 502 | provider_unavailable | The executor answered with something unusable, or turned the request away before it reached the model; no Retry-After |
| 503 | provider_overloaded | Every account for this model is busy or paused after a limit, or the renderer refused; Retry-After names when the nearest one comes back |
| 504 | timeout | The 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
