A1 API Gateway — Integration Guide
tests/manual/anthropic-scenarios.md). The OpenAI / xAI path has so far been verified against a mock vendor only; treat it as designed behaviour until a real key has been run through it. The same goes for embeddings, speech and transcription (added 19 September 2026): tested end to end against a mock of each vendor, not yet with real keys.- What the gateway is
- Setup in five minutes
- Endpoints
- Model names and aliases
- Which endpoint to use
- Features
- What comes back in the headers
- Errors and what to do about them
- Limits, budgets and money
- What is not supported
- Getting and checking a key
- Telling your AI assistant
1. What the gateway is
A1 API Gateway is the company's single door to the AI vendors — Anthropic, OpenAI and xAI for chat, OpenAI for pictures and embeddings, ElevenLabs, AssemblyAI and OpenAI for speech and transcription. Your project never holds a vendor key. It holds a gateway key that starts with sk-gw-, sends requests to the gateway in the vendor's own format, and the gateway does the rest: checks the key and the caller's IP, applies rate limits and spending caps, forwards the request with the real vendor key, measures the exact cost, and returns the vendor's answer unchanged.
sk-gw- keys. Read the gateway URL and the key from environment variables, never from source code.2. Setup in five minutes
2.1 Two environment variables
AI_GATEWAY_URL=https://api.a1apigateway.com # local development: http://127.0.0.1:8080 AI_GATEWAY_KEY=sk-gw-xxxxxx-xxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The production address is https://api.a1apigateway.com. Keep it in an environment variable so a local or staging gateway can be swapped in without code changes.
2.2 Change two lines in your SDK setup
Keep using the SDK you already use. Point it at the gateway and hand it the gateway key.
// TypeScript # Python
import OpenAI from "openai"; from openai import OpenAI
const ai = new OpenAI({ ai = OpenAI(
apiKey: process.env.AI_GATEWAY_KEY, api_key=os.environ["AI_GATEWAY_KEY"],
baseURL: `${process.env.AI_GATEWAY_URL}/v1`, base_url=f"{os.environ['AI_GATEWAY_URL']}/v1",
}); )
const res = await ai.chat.completions.create({ res = ai.chat.completions.create(
model: "anthropic/claude-opus-5", model="anthropic/claude-opus-5",
max_tokens: 1024, max_tokens=1024,
messages: [{ role: "user", content: "…" }], messages=[{"role": "user", "content": "…"}],
}); )
/chat/completions to the base URL itself, so the base URL must end in /v1.// TypeScript # Python
import Anthropic from "@anthropic-ai/sdk"; import anthropic
const ai = new Anthropic({ ai = anthropic.Anthropic(
apiKey: process.env.AI_GATEWAY_KEY, api_key=os.environ["AI_GATEWAY_KEY"],
baseURL: process.env.AI_GATEWAY_URL, base_url=os.environ["AI_GATEWAY_URL"],
}); )
const res = await ai.messages.create({ res = ai.messages.create(
model: "anthropic/claude-opus-5", model="anthropic/claude-opus-5",
max_tokens: 1024, max_tokens=1024,
messages: [{ role: "user", content: "…" }], messages=[{"role": "user", "content": "…"}],
}); )
/v1/messages itself, so its base URL is the bare gateway URL — no /v1.curl "$AI_GATEWAY_URL/v1/chat/completions" \
-H "Authorization: Bearer $AI_GATEWAY_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"anthropic/claude-opus-5","max_tokens":256,"messages":[{"role":"user","content":"ping"}]}'
That is the whole integration. Everything below is detail you will want when something surprises you.
3. Endpoints
| Method | Path | Purpose | Format |
|---|---|---|---|
| POST | /v1/chat/completions | Chat with any provider | OpenAI Chat Completions |
| POST | /v1/messages | Chat with Claude, native | Anthropic Messages, forwarded untouched |
| POST | /v1/messages/count_tokens | Count input tokens before sending (Claude), free | Anthropic count_tokens, forwarded untouched |
| POST | /v1/images/generations | Generate pictures (OpenAI GPT-Image) — your key must allow images | OpenAI Images, forwarded untouched |
| POST | /v1/embeddings | Turn text into vectors (OpenAI embedding models) | OpenAI Embeddings, forwarded untouched |
| POST | /v1/audio/speech | Text to speech (ElevenLabs, OpenAI tts-1) | OpenAI Speech; the reply is raw audio |
| POST | /v1/audio/transcriptions | Speech to text (AssemblyAI, ElevenLabs Scribe, OpenAI whisper-1) | OpenAI Transcriptions, multipart upload |
| GET | /v1/models | Models this key may use, each with its capabilities | OpenAI list + capabilities |
| GET | /v1/key | This key's limits, budgets and routing | Gateway JSON |
Authentication is either Authorization: Bearer sk-gw-… or x-api-key: sk-gw-… — both SDKs already send one of them.
4. Model names and aliases
Names are provider/model: anthropic/claude-opus-5, openai/gpt-5.6, xai/grok-4.3, openai/text-embedding-3-small, elevenlabs/eleven_flash_v2_5, assemblyai/universal-3-5-pro. The prefix can be left off when the name is unambiguous (claude-…, gpt-…, o…, grok-…, text-embedding-…, tts-…, whisper-…). ElevenLabs and AssemblyAI models always need their prefix: a bare eleven_flash_v2_5 or universal-2 is 404 model_not_found.
Your gateway admin can also define aliases for your organisation — for example settlement-default pointing at whatever model the team has standardised on. Use the alias in production code: when the team moves to a newer model the admin changes the alias and nothing in your project needs a deploy. The x-gw-model response header always tells you which real model answered.
GET /v1/models lists exactly what your key is permitted to use. A name that does not exist gives 404 model_not_found; one that exists but is not allowed for your key gives 403 model_not_allowed.
4.1 Capabilities — check, don't guess
Every model in the list carries a capabilities block saying what you may send to it, so a client can check up front instead of discovering a 400 in production:
{"id":"anthropic/claude-opus-5","object":"model","owned_by":"anthropic",
"capabilities":{"streaming":true,"images":true,"documents":true,"tools":true,"structured_output":true,
"reasoning":true,"prompt_caching":true,"sampling_params":false,"forced_tool_choice":true,
"endpoints":["/v1/chat/completions","/v1/messages","/v1/messages/count_tokens"]}}
true and false are asserted by the gateway; null means the gateway does not know and the vendor decides. documents means PDFs and text documents as file content parts (§6.3b); prompt_caching means repeated context is billed at the cached rate (§6.3c); endpoints lists which consumer endpoints accept the model — exactly one for image, embedding, speech and transcription models. Those models report every other flag as false (nothing streams on their endpoints), except sampling_params, which is true for transcription models (temperature). The block is an extra field on the standard OpenAI list entry, so OpenAI-shaped clients simply ignore it.
{"id":"openai/text-embedding-3-small","object":"model","owned_by":"openai",
"capabilities":{"streaming":false,"images":false,"documents":false,"tools":false,"structured_output":false,
"reasoning":false,"prompt_caching":false,"sampling_params":false,"forced_tool_choice":false,
"endpoints":["/v1/embeddings"]}}
4.2 One model, one door
Every model in the catalogue does one kind of thing and answers on the endpoint for that thing only:
| Kind | Endpoint | Models |
|---|---|---|
| chat | POST /v1/chat/completions (Claude also on /v1/messages) | Claude, GPT, o-series, Grok |
| image | POST /v1/images/generations | gpt-image-* |
| embedding | POST /v1/embeddings | text-embedding-* |
| speech | POST /v1/audio/speech | elevenlabs/eleven_*, tts-1, tts-1-hd |
| transcription | POST /v1/audio/transcriptions | assemblyai/*, elevenlabs/scribe_v2, whisper-1 |
A model sent to the wrong endpoint is refused before any vendor call with 400 invalid_request, and the message names the right one:
Model 'text-embedding-3-small' is an embedding model and answers on POST /v1/embeddings, not /v1/chat/completions.
404 model_not_found. Code that branched on that 404 should branch on the 400 instead. capabilities.endpoints in GET /v1/models tells you which endpoint each model answers on.4.3 Prices worth knowing
The catalogue was re-read from the vendors' own price pages on 19 September 2026.
- Long context is billed at the higher rate for the whole request. xAI charges every text model double (input and output) once the prompt is over 200K tokens; OpenAI charges 2× input and 1.5× output over 272K prompt tokens on its newer models (
gpt-6-astra,gpt-5.6,gpt-5.6-sol/-terra/-luna,gpt-5.5,gpt-5.4). The gateway picks the rate from the vendor's own prompt count (input + cached + cache-write tokens), and reserves at the higher rate when the estimated prompt is already over the line — so a large prompt can meet402 request_too_expensivesooner than its short-context price suggests. gpt-5.6is $4 input / $0.40 cached / $20 output per 1M tokens (it was priced at $5 / $0.50 / $30). It is OpenAI's alias for GPT-5.6 Sol, at a promotional price OpenAI states runs at least until 21 November 2026.- Retired xAI names bill as
grok-4.3. xAI retiredgrok-4,grok-4-fastandgrok-3on 15 May 2026 and now serves and bills them asgrok-4.3($1.25 / $0.20 / $2.50 per 1M tokens), so that is what they cost here. Move toxai/grok-4.3or a newer model by name. grok-3-miniis switched off — it is on no current xAI price list. Requests for it are404 model_not_found.
Corrections apply from 19 September 2026; requests already made keep the price they were charged. x-gw-cost-usd is always what you were actually charged.
5. Which endpoint to use
/v1/chat/completions. It is the standard door for every project: one format for every provider, PDFs and documents, automatic prompt caching, tools, structured output, reasoning, images, streaming — and every request is validated and priced by the gateway before a vendor is called, so spending caps are exact./v1/messages exists for the rare Claude-only feature the OpenAI format cannot express — citations, anthropic-beta features, vendor server tools. It forwards the body untouched, which also means price-changing fields reach the vendor unpriced. Ask the gateway admin before using it.
For Claude models, /v1/chat/completions is translated; the few OpenAI-only parameters that have no equivalent are listed in §10, and every ignored field is reported in x-gw-warnings — nothing is dropped silently.
Pictures, embeddings, speech and transcription each have a door of their own. A model sent to the wrong one is a 400 invalid_request that names the right one (§4.2).
| You want | Endpoint | Section |
|---|---|---|
| a picture | POST /v1/images/generations | §6.9 |
| vectors for search or clustering | POST /v1/embeddings | §6.10 |
| audio from text | POST /v1/audio/speech | §6.11 |
| text from audio | POST /v1/audio/transcriptions | §6.12 |
6. Features
6.1 Conversations
Both endpoints are stateless. Send the whole history on every call — system prompt, earlier user and assistant turns, then the new message. On /v1/chat/completions the system prompt is a message with role: "system"; on /v1/messages it is the top-level system field.
6.2 Streaming
Add "stream": true (or use the SDK's streaming helper). Text arrives as it is generated, exactly as from the vendor. At the end of the stream the gateway adds one extra line before [DONE]:
: x-gw-cost-usd=0.000146 x-gw-billing=byok
It starts with a colon, which makes it an SSE comment: every SDK ignores it, so it never breaks parsing. Read it only if you want the cost of a streamed call.
6.3 Images
JPEG, PNG, GIF and WebP, either inline as base64 or as an https:// URL, in the vendor's usual content-part shape (image_url on the OpenAI door, image + source on the Anthropic door).
400 invalid_request and the vendor's message "Unable to download the file" — and so does any host whose robots.txt disallows crawlers, which Anthropic honours (picsum.photos and Wikimedia, for example: "disallowed by the website's robots.txt"). Send anything you do not control as base64.6.3b PDFs and documents
Send the document inline as a file content part with a base64 data URL. PDFs and text types (plain text, Markdown, JSON, CSV, YAML, XML) are accepted; the gateway converts them to Claude's document block, with the filename as the title, and passes them through for OpenAI models. Other types — Word, Excel — are refused with 400 naming the media type, and file_id references are refused because there is no Files API through the gateway.
{"model":"anthropic/claude-opus-5","max_tokens":1024,"messages":[{"role":"user","content":[
{"type":"file","file":{"filename":"contract.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK…"}},
{"type":"text","text":"What is the termination notice period?"}]}]}
Cost is reserved per page (worst case 4,600 tokens a page — the text plus the page as an image) and settled to the real usage afterwards. Anthropic accepts up to 32 MB and 100 pages per PDF on 200K-context models; the gateway accepts 20 MB bodies.
6.3c Prompt caching — automatic
Repeated context — a long system prompt, a big tool list, the earlier turns of a conversation — is cached by Anthropic and billed at 10 % of the input price on the next call within five minutes (refreshed on each use). The gateway turns this on for every Claude request; you do nothing. Writing the cache costs 1.25× on the cached prefix once, so it pays for itself on the second call.
- Only prefixes above the model's minimum (1,024–4,096 tokens, depending on the model) are cached. Shorter ones are simply not cached — nothing fails.
- Order matters: stable content first (system prompt, tools), then history, then the new message. Everything before the first changed byte is cached; everything after it is not.
- Check it works:
usage.prompt_tokens_details.cached_tokensin the response is non-zero on a cache hit. - Opt out per request with
"cache_control": false; pass{"type":"ephemeral","ttl":"1h"}for the one-hour tier.
6.4 Tool calling
OpenAI-style tools and tool_choice are translated for Claude; the reply comes back as tool_calls with finish_reason: "tool_calls", and you continue with role: "tool" messages as usual. strict: true on a function is honoured — the vendor then guarantees the arguments match the schema. Only tools[].type: "function" is supported; the legacy functions / function_call API is rejected.
6.5 Structured JSON output
response_format: {"type": "json_schema", …} is mapped to Claude's structured output and the answer is guaranteed to match the schema. {"type": "json_object"} has no Claude equivalent, so the gateway appends a system instruction asking for a single JSON object and sets a warning — a request, not a guarantee. Use a schema when the shape matters.
6.6 Reasoning
reasoning_effort on /v1/chat/completions maps to Claude's adaptive thinking: none disables it; low, medium, high, xhigh, max set the effort; minimal becomes low. Omitted means the model's default. On models without adaptive thinking (Haiku 4.5, Sonnet 4.5 and older) the level becomes a fixed thinking budget instead (1,024 to 32,000 tokens, kept below max_tokens) and temperature / top_p are dropped — both reported in x-gw-warnings; if max_tokens leaves no room, thinking is skipped with a warning. On /v1/messages, send thinking / output_config as Anthropic documents them.
6.7 Counting tokens before you send
POST /v1/messages/count_tokens takes the same model, system, messages and tools you are about to send and returns {"input_tokens": 21}. It is free at the vendor, so the gateway charges nothing and holds no budget for it — it only counts against your requests-per-minute and shows in the request log at cost 0. Claude models only. Both SDKs' helpers work unchanged: ai.messages.countTokens(…) in TypeScript, ai.messages.count_tokens(…) in Python. Use it to estimate a call's cost — input tokens times the input price, plus max_tokens times the output price — before deciding to make it.
6.8 Sampling and max_tokens
temperature, top_p and stop pass through (Anthropic's temperature range is 0–1; higher values are clamped with a warning; the newest Claude models accept none of these and they are dropped with a warning). max_tokens falls back to your key's default when omitted and is refused when above your key's maximum.
6.9 Generating images
POST /v1/images/generations — OpenAI's own path and body, forwarded as sent. The reply is the vendor's, unchanged: each picture is in data[].b64_json. The OpenAI SDK works without modification (client.images.generate({…}) against the gateway's base URL).
curl "$AI_GATEWAY_URL/v1/images/generations" \
-H "Authorization: Bearer $AI_GATEWAY_KEY" \
-H "content-type: application/json" \
-d '{
"model": "openai/gpt-image-2.5-flare",
"prompt": "a single ripe lemon on a white marble counter, morning light",
"n": 1,
"size": "1024x1024",
"quality": "low"
}'
Models. gpt-image-2.5-flare (fast, the sensible default) and gpt-image-2.5-sunburst (highest quality); gpt-image-2 is still priced. Prefixed or bare model ids both work. DALL·E is not available — OpenAI removed dall-e-2 and dall-e-3 from the API on 12 May 2026.
| Field | Values | Notes |
|---|---|---|
prompt | text, required | |
n | 1–10 | bounded by your key; every picture is billed |
size | 1024x1024, 1536x1024, 1024x1536, auto, or any WIDTHxHEIGHT | bounded by your key, compared by area |
quality | low, medium, high, xhigh, max, auto | bounded by your key |
output_format | png, jpeg, webp | default png |
background | transparent, opaque, auto | transparent needs png or webp |
stream | — | refused, 400 unsupported_parameter |
Your key's image settings
Image generation is off by default on every key. An admin switches it on per key, together with ceilings on how many pictures per request, how large, at what quality, which image models, and an optional cost cap. Read your own from GET /v1/key and check at startup rather than discovering a limit in production:
"images": {
"allowed": true,
"max_images_per_request": 2,
"max_size": "1536x1024",
"max_quality": "high",
"max_cost_per_request_usd": null,
"allowed_models": []
}
allowed_models: [] means every image model the key may already use. A request over any ceiling is refused before a vendor is called, so it costs nothing.
What comes back when it is refused
| Situation | Status and code |
|---|---|
| Images not switched on for this key | 403 service_not_allowed |
| A chat model sent here | 400 invalid_request — names the endpoint to use instead (was 404 model_not_found before 19 September 2026) |
An image model sent to /v1/chat/completions | 400 invalid_request — points back here (was 404 model_not_found) |
n, size or quality above your key's ceiling | 400 invalid_request, naming the ceiling |
| Worst case above a per-request cost cap | 402 request_too_expensive, naming estimate and cap |
stream: true | 400 unsupported_parameter |
| Budget or credit exhausted | 402, as anywhere else |
| The prompt itself refused by OpenAI | 400 invalid_request, prefixed The provider rejected the request: |
Cost and timing
Billed in tokens like everything else: the prompt at the model's input rate, the pictures at its output rate, so x-gw-cost-usd is exact. Unlike chat, the worst case is known before the call — how many pictures, how large and at what quality are all in the body — so the reservation is tight rather than a guess.
low picture is about $0.006 and max about $0.21 — roughly forty times. Choose quality deliberately; auto is reserved at the most your key allows, because you have not said. Expect 10–60 seconds, with no streaming, so set your client timeout accordingly./v1/images/edits, /v1/images/variations) are not proxied. Generation only.6.10 Embeddings
POST /v1/embeddings — OpenAI's own path and body. The body goes to OpenAI exactly as you sent it, with only model rewritten to the vendor's id, and OpenAI's reply comes back unchanged.
curl "$AI_GATEWAY_URL/v1/embeddings" \
-H "Authorization: Bearer $AI_GATEWAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "openai/text-embedding-3-small",
"input": ["Clause 4 limits liability to the fees paid.", "Payment is due within 30 days."]
}'
{"object":"list",
"data":[{"object":"embedding","index":0,"embedding":[-0.0061,0.0142,…]},
{"object":"embedding","index":1,"embedding":[0.0217,-0.0093,…]}],
"model":"text-embedding-3-small",
"usage":{"prompt_tokens":19,"total_tokens":19}}
ai is the client from §2.2// TypeScript # Python
const res = await ai.embeddings.create({ res = ai.embeddings.create(
model: "openai/text-embedding-3-small", model="openai/text-embedding-3-small",
input: ["Clause 4 …", "Payment is due …"], input=["Clause 4 …", "Payment is due …"],
}); )
const vectors = res.data.map((d) => d.embedding); vectors = [d.embedding for d in res.data]
// number[] each, 1536 long # list[float] each, 1536 long
Models and prices — per 1M input tokens. Nothing is generated, so there is no output price. The prefix is optional.
| Model | Price | Vector length (default) |
|---|---|---|
openai/text-embedding-3-small | $0.02 | 1536 |
openai/text-embedding-3-large | $0.13 | 3072 |
openai/text-embedding-ada-002 | $0.10 | 1536 |
| Field | Values | Notes |
|---|---|---|
input | a string, an array of strings, an array of token ids, or an array of token-id arrays | required; an empty string or empty array is 400 invalid_request before OpenAI is called |
encoding_format | float (OpenAI's default), base64 | passed through as sent — see below |
dimensions | integer | text-embedding-3-* only; shortens the vector. OpenAI decides |
user | string | passed through |
Floats or base64. The gateway sends encoding_format only if you did: ask for nothing and you get arrays of floats, ask for base64 and you get base64 strings, untouched. The gateway never switches to base64 behind your back. (The OpenAI SDKs do so themselves: when you leave encoding_format out, embeddings.create asks for base64 on the wire and decodes it back into floats for you. That works unchanged through the gateway. With curl or a plain HTTP client you get floats unless you ask.)
What is billed. usage.prompt_tokens from OpenAI's reply, at the model's input rate, and nothing else. Before the call the gateway reserves an over-estimate — text at one token per 3 bytes, token-id arrays at their exact length — which counts against your per-request cap, then settles to the real count. x-gw-cost-usd is exact.
Limits OpenAI enforces: 8,192 tokens per input, 2,048 inputs per request, 300,000 tokens across all inputs in one request. The gateway does not pre-check these; OpenAI's refusal comes back as 400 invalid_request prefixed The provider rejected the request:. The gateway's own body limit is 20 MB (413 request_too_large). Split large batches into several requests.
Retries and logging. The gateway retries once itself on a transient vendor failure (a 5xx or a dropped connection) — safe, because an embedding is idempotent. On a key that logs at full, the input is kept but the vectors are not.
What comes back when it is refused
| Situation | Status and code |
|---|---|
| A chat, image, speech or transcription model sent here | 400 invalid_request — names the endpoint to use |
An embedding model sent to /v1/chat/completions | 400 invalid_request — points back here |
input missing, empty, or not one of the four shapes | 400 invalid_request, before any vendor call |
| Over one of OpenAI's limits above | 400 invalid_request, prefixed The provider rejected the request: |
| Estimate above your key's per-request cost cap | 402 request_too_expensive, naming estimate and cap |
| Budget or credit exhausted | 402, as anywhere else |
| Model not permitted for this key or organisation | 403 model_not_allowed |
A mail key ("service": "mail" in GET /v1/key) | 403 service_not_allowed |
| OpenAI rejected the vendor key | 503 credential_unavailable — the vendor key is marked unhealthy and nothing is charged; tell the admin |
6.11 Speech
POST /v1/audio/speech — OpenAI's speech request, answered by ElevenLabs or by OpenAI's tts-1 / tts-1-hd. The reply is the audio itself: raw bytes with the vendor's Content-Type (audio/mpeg for mp3), not JSON. Errors are JSON as usual, so check the status before writing the body to a file.
curl "$AI_GATEWAY_URL/v1/audio/speech" \
-H "Authorization: Bearer $AI_GATEWAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "elevenlabs/eleven_flash_v2_5",
"voice": "narrator",
"input": "Your settlement has been approved and will be paid on Friday.",
"response_format": "mp3"
}' \
--fail-with-body --output reply.mp3
// TypeScript # Python
import { writeFile } from "node:fs/promises";
const res = await ai.audio.speech.create({ res = ai.audio.speech.create(
model: "elevenlabs/eleven_flash_v2_5", model="elevenlabs/eleven_flash_v2_5",
voice: "narrator", voice="narrator",
input: "Your settlement has been approved …", input="Your settlement has been approved …",
response_format: "mp3", response_format="mp3",
}); )
await writeFile("reply.mp3", res.write_to_file("reply.mp3")
Buffer.from(await res.arrayBuffer()));
Models and prices — per character of input. ElevenLabs models need the elevenlabs/ prefix.
| Model | Price | Notes |
|---|---|---|
elevenlabs/eleven_flash_v2_5 | $0.05 per 1K characters | fastest and cheapest, multilingual |
elevenlabs/eleven_flash_v2 | $0.05 per 1K characters | English only |
elevenlabs/eleven_multilingual_v2 | $0.10 per 1K characters | |
elevenlabs/eleven_v3 | $0.10 per 1K characters | |
elevenlabs/eleven_turbo_v2_5, elevenlabs/eleven_turbo_v2 | $0.05 per 1K characters | deprecated by ElevenLabs — use Flash |
tts-1 (or openai/tts-1) | $15 per 1M characters ($0.015 per 1K) | |
tts-1-hd | $30 per 1M characters ($0.03 per 1K) |
The voice
- OpenAI models:
voicegoes to OpenAI unchanged —alloy,echo,novaand the rest of OpenAI's names. - ElevenLabs models:
voiceis either a name your organisation has set up in the console under Providers → ElevenLabs → Voices (saynarrator, pointing at an ElevenLabs voice; matched exactly, case included — an admin can point it at a different voice later with no change to your code), or a raw ElevenLabs voice id — exactly 20 letters and digits, e.g.21m00Tcm4TlvDq8ikWAM. Anything else is400 invalid_request, and the message lists your organisation's voice names. - OpenAI's names do not carry over:
"voice": "alloy"with anelevenlabs/…model is refused, unless your organisation has named one of its voicesalloy. voicemust be a string. OpenAI's custom-voice object ({"id": "voice_…"}) is refused with400.- A voice cloned in an ElevenLabs account can only be used through that account's key. If your organisation cloned it, requests must route to the organisation's own ElevenLabs key —
GET /v1/key→routing.elevenlabsshows which key is used.
| Field | OpenAI models | ElevenLabs models |
|---|---|---|
input | required | required. Each vendor caps the length (OpenAI: 4,096 characters); its refusal comes back as 400 invalid_request |
response_format | mp3 (default), opus, aac, flac, wav, pcm | mp3 (default; 44.1 kHz, 128 kbit/s), opus (48 kHz, 128 kbit/s), wav (24 kHz), pcm (raw 24 kHz 16-bit mono, the same as OpenAI's). aac and flac are 400 invalid_request, before the call |
speed | 0.25–4.0 (OpenAI decides) | 0.7–1.2; outside that, 400 invalid_request before the call |
instructions | passed to OpenAI | ignored, and named in x-gw-warnings |
stream, stream_format: "sse" | refused, 400 unsupported_parameter | |
For OpenAI models the body is forwarded as sent (only model rewritten). For ElevenLabs models only the fields above are used.
What is billed. The characters of input at the model's rate, counted as Unicode code points (an emoji is one character). The count is known before the call, so the reservation is exact. For ElevenLabs, the vendor's own figure wins when it reports one (the character-cost response header): a Voice Library voice with a custom rate costs more characters than the text has. x-gw-cost-usd is exact. As a guide, 1,000 characters cost $0.05 on Flash, $0.10 on v3 or Multilingual v2 and $0.015 on tts-1.
Access and limits. Any AI key whose model allowlist includes the model may use it; unlike images, audio has no separate switch on the key. Tokens per minute does not apply — there are no x-ratelimit-*-tokens headers — but requests per minute, concurrency, budgets and credit do. On a key that logs at full, the text is kept, never the audio.
What comes back when it is refused
| Situation | Status and code |
|---|---|
input or voice missing | 400 invalid_request |
ElevenLabs model with a voice that is neither an organisation name nor a voice id | 400 invalid_request, listing your organisation's voice names |
ElevenLabs model with aac / flac, or speed outside 0.7–1.2 | 400 invalid_request, before the call |
stream: true or stream_format: "sse" | 400 unsupported_parameter |
| A model that is not a speech model | 400 invalid_request, naming its endpoint |
| Estimate above your key's per-request cost cap | 402 request_too_expensive |
| Budget or credit exhausted | 402, as anywhere else |
| Model not permitted / a mail key | 403 model_not_allowed / 403 service_not_allowed |
| The vendor rejected its key | 503 credential_unavailable — nothing charged; tell the admin |
6.12 Transcription
POST /v1/audio/transcriptions — OpenAI's transcription upload, answered by AssemblyAI, ElevenLabs Scribe or OpenAI's whisper-1. Send multipart/form-data with the audio in file, exactly as for OpenAI.
curl "$AI_GATEWAY_URL/v1/audio/transcriptions" \ -H "Authorization: Bearer $AI_GATEWAY_KEY" \ -F model=assemblyai/universal-3-5-pro \ -F file=@call-recording.mp3 \ -F response_format=json \ --max-time 900
{"text":"Hello, I'm calling about settlement S-1042…","usage":{"type":"duration","seconds":184}}
// TypeScript
import { createReadStream } from "node:fs";
const t = await ai.audio.transcriptions.create(
{ model: "assemblyai/universal-3-5-pro", file: createReadStream("call-recording.mp3"), language: "en" },
{ timeout: 15 * 60 * 1000 }, // AssemblyAI can take up to 10 minutes: wait longer than the gateway does
);
console.log(t.text);
# Python
with open("call-recording.mp3", "rb") as f:
t = ai.with_options(timeout=900).audio.transcriptions.create( # wait longer than the gateway's 10 minutes
model="assemblyai/universal-3-5-pro",
file=f,
language="en",
)
print(t.text)
Models and prices — per second of audio, rounded up to a whole second. AssemblyAI and ElevenLabs models need their prefix.
| Model | Price | Notes |
|---|---|---|
assemblyai/universal-3-5-pro | $0.21 per hour | the only one that takes prompt at AssemblyAI |
assemblyai/universal-2 | $0.15 per hour | |
elevenlabs/scribe_v2 | $0.22 per hour | |
whisper-1 (or openai/whisper-1) | $0.006 per minute ($0.36 per hour) | OpenAI shuts it down on 26 February 2027 |
| Field | Notes |
|---|---|
file | required. Up to 25 MB; larger is 413 request_too_large (Audio upload exceeds 25 MB (26214400 bytes).). Common formats (mp3, m4a, wav, ogg, webm, flac) work with all three vendors; the vendor decides |
model | required |
language | optional ISO-639-1 code (en, de, …). Without it each vendor detects the language |
prompt | sent to whisper-1 and AssemblyAI; ignored for ElevenLabs Scribe, with an x-gw-warnings note |
response_format | json (default), text, verbose_json, srt, vtt — for every vendor. Anything else (such as diarized_json) is 400 invalid_request |
temperature | sent to every vendor |
timestamp_granularities[] | ignored, with an x-gw-warnings note: the gateway always asks for word timings, and verbose_json carries word and segment timings whenever the vendor provides them |
stream | true is refused, 400 unsupported_parameter |
Every format works with every vendor
The gateway renders the five formats itself from the vendor's answer, so subtitles from AssemblyAI or ElevenLabs have the same shape as from whisper-1.
response_format | Content-Type | Body |
|---|---|---|
json | application/json | {"text": "…", "usage": {"type": "duration", "seconds": 184}} — seconds is what you are billed for |
text | text/plain | the transcript |
verbose_json | application/json | task, language, duration, text, words: [{word, start, end}], segments: [{id, start, end, text}], times in seconds |
srt | text/plain | numbered SubRip cues |
vtt | text/vtt | WebVTT cues |
For AssemblyAI and ElevenLabs, segments and subtitle cues are built from the word timings: a cue ends at the end of a sentence, at a pause of more than a second, or at 7 seconds or 16 words. whisper-1's own segments are used as they are. language is whatever the vendor reports, so its form differs between vendors.
Time, money, and the one charged error
whisper-1 and Scribe answer in one call. AssemblyAI works asynchronously: the gateway uploads the file, submits the job and polls until it finishes, all inside your one request, which for a long recording can take minutes. The gateway waits up to 10 minutes, upload included. Set your client's timeout above that, as in the examples — the OpenAI SDKs default to exactly 10 minutes, and curl to none. If your client gives up first, the SDK counts it as a timeout and retries: a second job, charged again, while the first one still finishes and is charged too.
What is billed. Seconds of audio as the vendor reports them, rounded up (at least 1). The length is not known before the call, so the gateway reserves as if the file were compressed speech at 32 kbit/s (file bytes ÷ 4,000 = seconds), then settles to the real duration. That over-counts almost any real file — uncompressed 16 kHz WAV by 16× — so a 25 MB WAV holds about 6,550 seconds (about $0.38 at Universal-3.5 Pro) until it settles. With a tight per-request cap or a nearly spent budget, send compressed audio (mp3, opus) so the reservation fits.
504 upstream_timeout — and the request is charged at the reservation estimate, because AssemblyAI finishes the job and bills for it regardless. The 504 carries the charge like any billed response — x-gw-cost-usd, x-gw-billing, x-gw-usage-estimated: 1 — and x-should-retry: false, and its message says that retrying will transcribe and charge again. The OpenAI SDKs obey x-should-retry, so they do not retry it; if you have retry logic of your own, make it honour that header too. The transcript cannot be fetched through the gateway afterwards. Disconnecting does not cancel a job either — it is still charged.Access and limits. As for speech: any AI key whose model allowlist includes the model, with requests per minute, concurrency, budgets and credit as usual. On a key that logs at full, the transcript and the form fields are kept, never the audio.
What comes back when it is refused
| Situation | Status and code |
|---|---|
Not multipart/form-data, or no file | 400 invalid_request, before any vendor call |
response_format not one of the five | 400 invalid_request |
stream=true | 400 unsupported_parameter |
| A model that is not a transcription model | 400 invalid_request, naming its endpoint |
| AssemblyAI could not transcribe the file (too short, unreadable) | 400 invalid_request, prefixed The provider rejected the request: — nothing charged |
| Size-based estimate above your key's per-request cost cap | 402 request_too_expensive |
| Budget or credit exhausted | 402, as anywhere else |
| Model not permitted / a mail key | 403 model_not_allowed / 403 service_not_allowed |
| File over 25 MB | 413 request_too_large. The gateway refuses on the Content-Length header, so a client that streams a larger body may see the connection closed instead of the 413 — check the size before uploading |
| AssemblyAI rate-limiting the gateway's key (it signals this with a 403) | 429 upstream_rate_limited, with retry-after |
| The vendor rejected its key | 503 credential_unavailable — nothing charged; tell the admin |
| AssemblyAI still working after 10 minutes | 504 upstream_timeout with x-should-retry: false — charged at the estimate; do not retry |
6.13 Sending mail — POST /v3/mail/send
The gateway speaks SendGrid's own path and wire format, so an existing project moves onto it by changing two lines and nothing else:
// TypeScript # Python
sgMail.setApiKey(process.env.AI_GATEWAY_KEY); sg = SendGridAPIClient(os.environ["AI_GATEWAY_KEY"])
sgMail.client.setDefaultRequest( sg.client.host = os.environ["AI_GATEWAY_URL"]
"baseUrl", process.env.AI_GATEWAY_URL);
The body is forwarded to SendGrid verbatim. The gateway authenticates the key, applies its address rules, counts the recipients and attributes the send; it never rewrites your mail.
curl -sS $AI_GATEWAY_URL/v3/mail/send \
-H "Authorization: Bearer $AI_GATEWAY_KEY" -H "Content-Type: application/json" \
-d '{"personalizations":[{"to":[{"email":"someone@example.com"}]}],
"from":{"email":"noreply@yourdomain.com"},
"subject":"Hello","content":[{"type":"text/plain","value":"Hi there"}]}'
# 202 Accepted, empty body — exactly as SendGrid answers
A mail key is a different kind of key. It is scoped to this endpoint and cannot call a model at all, and an AI key cannot send mail: either mistake is 403 service_not_allowed. Ask your admin for the kind you need.
Limits count recipients, not requests. One call can address up to 1,000 people, so a request-per-minute limit would not stop a blast. Your key carries ceilings per minute, per hour and per day, and a maximum per send; over any of them the send is refused with 429 recipient_limit_exceeded and nothing reaches SendGrid, so nothing counts against your account's reputation. The answer carries x-gw-recipients and x-ratelimit-remaining-recipients-minute / -hour / -day.
| Situation | Status and code |
|---|---|
| An AI key at this endpoint, or a mail key at a model endpoint | 403 service_not_allowed |
| A body whose recipients cannot be counted | 400 invalid_request — it is never forwarded uncounted |
| Over a recipient ceiling | 429 recipient_limit_exceeded, with retry-after |
| SendGrid rejected the message | Its own status and body, relayed; the recipients are given back |
| No SendGrid key on the organisation | 503 credential_unavailable |
Every send is recorded in the key's send log with the sender, the recipients, the subject and the outcome — and, on a key that logs at full, the message itself. Refused sends are recorded too, with the recipients they would have reached.
7. What comes back in the headers
| Header | Meaning |
|---|---|
x-gw-request-id | Quote this in any question to the gateway admin — it finds the request in the log instantly. |
x-gw-model | The real model that answered, after alias resolution. |
x-gw-cost-usd | Exact cost of this request. On streams it is in the trailer comment instead. |
x-gw-billing | byok — billed to the organisation's own vendor account; prepaid — deducted from prepaid credit. |
x-gw-budget-remaining-usd | What is left in the tightest budget that applies to your key. |
x-gw-warnings | Parameters that were changed or dropped in translation, separated by semicolons. Worth logging in development. |
x-gw-usage-estimated | Present only when the cost is a worst-case estimate because the vendor's usage figures never arrived, or a transcription was charged its estimate on a 504. |
x-should-retry | Only on a charged transcription 504, set to false: do not retry. The OpenAI SDKs obey it. |
x-ratelimit-limit-requests, …-remaining-requests, …-reset-requests | Your requests-per-minute limit, what is left, when it resets. |
retry-after | On every 429: seconds to wait. |
8. Errors and what to do about them
Errors come in the format of the endpoint you called — the OpenAI envelope on /v1/chat/completions, /v1/images/generations, /v1/embeddings and /v1/audio/*, the Anthropic envelope on /v1/messages — so the SDKs raise their normal typed exceptions. Every error carries a stable code; branch on that, not on the message.
{"error":{"message":"Monthly budget of $10.000000 for this key would be exceeded (remaining $0.002000, estimate $0.002014). Resets 2026-10-01T00:00:00.000Z.","type":"budget_error","code":"budget_exceeded",
"request_id":"req_01…","budget":{"scope":"key","period":"monthly","cap_usd":10,"remaining_usd":0.002,"resets_at":"2026-10-01T00:00:00.000Z"}}}
| HTTP | Code | Meaning | Do |
|---|---|---|---|
| 400 | invalid_request | Malformed body, the model answers on a different endpoint (the message names it), or the vendor rejected it (its message is relayed) | Fix the request |
| 400 | unsupported_parameter | Parameter has no equivalent for this provider | Remove it (see §10) |
| 401 | missing_api_key / invalid_api_key | No key, or wrong key | Check AI_GATEWAY_KEY |
| 402 | budget_exceeded | Your key's daily / monthly / lifetime cap is reached; budget.resets_at says when | Wait for the reset or ask the admin to raise the cap |
| 402 | tenant_ceiling_exceeded | The whole organisation's monthly ceiling is reached | Talk to the admin |
| 402 | credit_exhausted | No prepaid credit and no vendor key for this provider | Talk to the admin |
| 402 | request_too_expensive | The worst case for this one request is over the per-request cap | Lower max_tokens or shorten the input |
| 402 | vendor_key_cap_reached | The vendor key your request routes through has reached its monthly cap | Talk to the admin: the cap can be raised, or the key can fall back to the organisation default |
| 403 | ip_not_allowed | Your address is not on the key's list | Run from an allowed network, or ask the admin to add the address — the console shows every refused address with one button to allow it |
| 403 | key_suspended / key_revoked / key_expired | The admin disabled the key (suspended is temporary; revoked and expired are final) | Ask the admin; revoked/expired need a new key |
| 403 | model_not_allowed | Your key may not use that model, or the vendor key it routes through does not allow it — the message names which | Pick one from GET /v1/models, which lists exactly what this key can reach today |
| 403 | service_not_allowed | Your key may not use this endpoint — a mail key calling images, embeddings or audio, or images not switched on for the key | Use an AI key; ask the admin to allow images |
| 404 | model_not_found | Unknown model name | Check spelling and prefix |
| 413 | request_too_large | Body too big (20 MB; 25 MB for a transcription upload) | Shrink it |
| 429 | rate_limit_exceeded / concurrency_limit_exceeded / tenant_rate_limit_exceeded / upstream_rate_limited | Too fast, too many in flight, organisation-wide limit, or the vendor is throttling | Wait retry-after seconds and retry with backoff |
| 502 / 504 | upstream_error / upstream_timeout | The vendor failed or timed out | Retry once with backoff, then surface the error — but not a transcription 504 carrying x-should-retry: false, which is charged (§6.12) |
| 503 | provider_unavailable, credential_unavailable, platform_cap_reached, gateway_unavailable, tenant_storage_unavailable | No usable vendor key right now, or a gateway dependency is down (it fails closed on purpose) | Retry with backoff; alert the admin if it persists |
User-Agent, the reason and the numbers behind it — and an admin sees it under Refusals in the console. So a key used from a new address will be noticed: five distinct new addresses within an hour suspend it automatically. Wrong secrets never suspend a key, however many are tried, but every attempt is recorded. The secret itself is never stored — only the public key id, the part before the last dash.retry-after, exponential backoff, at most three attempts. Never retry the other 4xx codes — they will fail the same way. The one exception to "retry a 504": on /v1/audio/transcriptions, a 504 after AssemblyAI accepted the job is charged and carries x-should-retry: false, so a retry pays twice — surface it instead. Any retry logic of your own should honour x-should-retry.9. Limits, budgets and money
Every gateway key carries limits set by the admin when it was created: requests per minute and per day, concurrent requests, daily / monthly / lifetime spending caps, a per-request cost cap, an allowed-model list, and address rules. They exist so that a leaked key or a runaway loop has a bounded blast radius. There is no tokens-per-minute limit: a cheap model returns a great many tokens for very little money, so the figure never bounded anything anyone cared about, and the x-ratelimit-*-tokens headers are no longer sent. You can read your own limits any time with GET /v1/key.
The gateway checks money before the vendor is called. It reserves the worst case — max_tokens multiplied by the output price plus the input — then settles to the real cost afterwards. This means a needlessly large max_tokens can be refused with 402 even though the actual answer would have been short. Ask for what you need.
The other endpoints reserve their own way: embeddings on the input alone, speech on the exact character count, transcription on the file's size (§6.10–6.12). A prompt past a model's long-context line is reserved and billed at the higher rate for the whole request (§4.3).
x-gw-billing tells you who pays: byok means the organisation's own vendor account is billed directly and the gateway only meters; prepaid means the cost is deducted from credit held with the gateway.
10. What is not supported
On /v1/chat/completions with Claude models
Rejected with 400 unsupported_parameter | Altered — always reported in x-gw-warnings |
|---|---|
n > 1 · logprobs · top_logprobs · seed · audio in or out · file parts with a file_id or a non-PDF/non-text media type · legacy functions / function_call / role: "function" · non-function tool types |
frequency_penalty, presence_penalty, logit_bias (dropped) · response_format: json_object (becomes a system instruction) · temperature > 1 (clamped) · sampling parameters on models that do not accept them (dropped) · forced tool_choice on models that only accept auto (downgraded) · any other field the translator does not know (store, metadata, prompt_cache_key, …: ignored and named) |
On /v1/chat/completions with OpenAI models
Refused with 400 unsupported_parameter because they change what OpenAI charges in ways the gateway cannot price yet — refusing is better than under-billing: web_search_options, prediction, audio / modalities: ["audio"], and service_tier other than auto / default.
On every endpoint
| Capability | Status |
|---|---|
| Files API | Not yet. Send documents inline as base64 on /v1/messages. |
| Message Batches | Not yet. |
| Token counting | Yes — POST /v1/messages/count_tokens, Claude models, free. |
| Image generation | Yes — POST /v1/images/generations, OpenAI GPT-Image models, opt-in per key (§6.9). |
| Image editing and variations, partial-image streaming | Not yet. Generation only; stream is refused with 400 unsupported_parameter. |
| Embeddings | Yes — POST /v1/embeddings, OpenAI embedding models (§6.10). |
| Speech and transcription | Yes — POST /v1/audio/speech (ElevenLabs, tts-1) and POST /v1/audio/transcriptions (AssemblyAI, ElevenLabs Scribe, whisper-1) (§6.11, §6.12). |
| Streaming speech or transcription | Refused with 400 unsupported_parameter; the whole audio or transcript comes back in one response. |
OpenAI's token-billed audio models (gpt-4o-mini-tts, gpt-4o-transcribe, gpt-4o-mini-transcribe, gpt-transcribe) | Not yet. The gateway cannot meter them reliably; use tts-1 / whisper-1, ElevenLabs or AssemblyAI. |
Vendor features beyond OpenAI's audio shape (speaker labels, diarized_json, summaries, redaction, voice settings other than speed), audio translation | Not available. |
Vendor-native pass-through (/v1/vendors/…), OpenAI Responses API | Not yet. |
| PDFs and documents | Yes — file parts on /v1/chat/completions (§6.3b). |
| Prompt caching | Yes — automatic for Claude (§6.3c). |
| Vendor server tools (web search, code execution) | Refused on /v1/chat/completions; pass through on /v1/messages but their per-use surcharge is not included in x-gw-cost-usd. |
| Claude's thinking text in the reply | Not returned on /v1/chat/completions — the OpenAI shape has no field for it. |
11. Getting and checking a key
Keys are created by your organisation's admin in the gateway console (Keys → New key). Tell them the project name, the models you need, a sensible budget and rate limit, and the addresses the project runs from. If your address changes, or you do not know it, ask them to tick "add the next new address automatically" and make one request within ten minutes: that address is added to the key's list, once, and the window closes. The secret is shown once — put it straight into your project's secret store as AI_GATEWAY_KEY.
Call GET /v1/key at startup: it fails fast if the key or URL is wrong, and it shows your limits, budgets, remaining spend and which vendor key your requests will route to. It never reveals which addresses are allowed, only how many there are.
{"key_id":"muhr4dre","name":"settlement-worker","status":"active","environment":"production",
"routing":{"anthropic":{"vendor_key":"own:anthropic-production","billing":"byok",
"environment":"production","models":["anthropic/claude-opus-5"]}},
"allowed_models":[],
"rate_limits":{"rpm":120,"max_concurrency":8,"rpd":null,"rpm_per_ip":null},
"budgets":[{"period":"monthly","cap_usd":10,"spent_usd":0.000411,"resets_at":"2026-10-01T00:00:00.000Z"}],
"ip_mode":"allowlist","ip_allowlist_count":2,"ip_capture_open":false,"expires_at":null}
routing has an entry for each provider — anthropic, openai, xai, elevenlabs, assemblyai — saying which vendor key a request would use, who pays, which environment that key belongs to, and models: what that key allows, or "all". That list is the authoritative answer to "why was my model refused?" — a model outside it is 403 model_not_allowed naming the vendor key. A provider with no usable key shows "vendor_key": null and an error code: expect the same refusal from the endpoint.
In allowed_models, an entry ending /* — "openai/*" — means every model that provider's vendor key allows, whatever that becomes.
12. Telling your AI assistant
Most projects are written with an AI coding assistant. Two things make it integrate correctly the first time:
- Give it
docs/INTEGRATION.md— the machine-readable version of this guide, with exact request and response shapes. - Paste the block below into your project's
CLAUDE.md(orAGENTS.md), so the rules apply to every future change without anyone remembering to mention them.
## AI calls go through A1 API Gateway - All LLM calls use A1 API Gateway, never a vendor API directly. Never add a vendor API key. - Base URL: `AI_GATEWAY_URL` env var. Key: `AI_GATEWAY_KEY` env var (`sk-gw-…`). Read both from the environment. - OpenAI SDK: `baseURL = AI_GATEWAY_URL + "/v1"`. Anthropic SDK: `baseURL = AI_GATEWAY_URL` (no `/v1`). - Model names are `provider/model`, e.g. `anthropic/claude-opus-5`. Prefer the alias the admin gave us if one exists. - Always use `/v1/chat/completions` (OpenAI format). It supports PDFs/documents (`file` parts as base64 data URLs), images, tools, JSON output, reasoning, streaming, and prompt caching is automatic. `/v1/messages` only with admin approval. - `GET /v1/models` lists the models we may use, each with a `capabilities` block (images, documents, tools, …) — check it rather than guessing. `POST /v1/messages/count_tokens` counts tokens for free before an expensive call. - Put stable content first (system prompt, tools), the changing message last — that is what gets cached. - Each model answers on one endpoint; the wrong one is `400 invalid_request` naming the right one. - Image generation: `POST /v1/images/generations` with `gpt-image-2.5-flare` (or `-sunburst`) — only if our key allows it. No streaming, generation only, and quality drives the cost steeply (`low` → `max` is about 40x). - Embeddings: `POST /v1/embeddings` with `openai/text-embedding-3-small` (or `-3-large`), OpenAI's body and reply unchanged. - Speech: `POST /v1/audio/speech` returns raw audio bytes. ElevenLabs models need the `elevenlabs/` prefix and a voice name our organisation set up (or a 20-character ElevenLabs voice id); OpenAI names like `alloy` only work on `tts-1`. - Transcription: `POST /v1/audio/transcriptions` (multipart, ≤ 25 MB) with `assemblyai/universal-3-5-pro`, `elevenlabs/scribe_v2` or `whisper-1`; all five response formats work. AssemblyAI can take up to 10 minutes: set the client timeout above that. A transcription 504 is charged and says `x-should-retry: false` — never retry it. - Not available: Files API (`file_id`), batches, image editing, streaming audio, Responses API, web search. - Retry only 429/502/503/504 with `retry-after` and backoff (never a transcription 504). 4xx other than 429 means fix the request or config. - Every response has `x-gw-cost-usd` (exact cost) and `x-gw-request-id` (quote it in bug reports). - Full reference: `docs/INTEGRATION.md` in the AIGateway repository.