What OpenAI compatibility does not change
A genuine drop-in replacement means changing two lines -- base_url to https://router.xark.io/api/v1 and the key -- with no other edit to your integration. Three behaviours still differ here and all three fail only in production: an exhausted balance returns 429 with code insufficient_credits rather than 402, closed model ids such as gpt-4o return model_not_found, and OpenAI-Organization and OpenAI-Project headers are accepted and discarded.
The change itself
Two lines. Anything built on the OpenAI SDK inherits it -- LangChain, LlamaIndex, the Vercel AI SDK, Instructor -- because they all take a base URL and pass it through.
from openai import OpenAI
client = OpenAI(
base_url="https://router.xark.io/api/v1",
api_key="sk-...",
)
response = client.chat.completions.create(
model="z-ai/glm-5.2",
messages=[{"role": "user", "content": "Hello"}],
)
Model ids: three forms, all exact
Ids are namespaced publisher/model. Two other forms resolve as well, and knowing which is canonical matters for anything you write down: the namespaced id is what responses echo back.
Matching is exact and case-sensitive everywhere. A lookup that quietly normalises is a lookup that eventually resolves two different models to the same row, which is a billing defect rather than a convenience.
| Form | Example | Status |
|---|---|---|
| Namespaced api id | z-ai/glm-5.2 | Canonical. Echoed back in every response. |
| Bare model name | glm-5.2 | Accepted, for integrations written before namespacing. |
| URL slug | glm-5-2 | Accepted, because it is what the site links to. |
Difference 1: running out of credit is a 429, not a 402
402 Payment Required is the status a billing-aware API should use, and it is the wrong answer for a drop-in replacement. The platform being replaced returns 429 for an exhausted balance, so a client that special-cases 402 would stop noticing it is out of credit -- and would read the 429 as a rate limit and back off forever, waiting for a condition only a payment can clear.
The cost of honouring that contract is that 429 no longer identifies a cause on its own. So the cause moves into error.code, and every 429 this API emits carries one of exactly two values. A 429 with a null code is a bug in the API, not a case for you to handle.
There is a second layer to this that only shows up in production. The gateway behind this API, left to itself, answers an exhausted balance with HTTP 403 and a Chinese-language message carrying the code insufficient_user_quota. That is wrong on three counts at once: it is the wrong status, it is in the wrong language for an English-language product, and it contradicts the contract published in these docs. So the relay rewrites exactly that response -- and only responses it recognises -- into the documented 429. Anything it has not seen before passes through with its status intact, because inventing a translation for an unknown error is worse than showing the original.
| error.code | Meaning | Correct client behaviour |
|---|---|---|
| rate_limit_exceeded | Too many requests in the window | Back off and retry; x-ratelimit-reset says when |
| insufficient_credits | Balance is exhausted | Do not retry. Top up. Nothing was charged for the refused request. |
try:
response = client.chat.completions.create(...)
except openai.RateLimitError as e:
# Branch on the code, never on the status alone.
if e.body["error"]["code"] == "insufficient_credits":
alert_billing() # retrying will never clear this
else:
backoff_and_retry()
Difference 2: there are no closed models here
This platform serves open-weight models only. There is no gpt-4o, no claude-*, no gemini-*. That is a licensing position rather than a catalogue gap -- third-party resale of closed-weight model access routinely violates the origin provider's terms, and we name the specific models we refuse rather than writing a generic compliance paragraph.
The practical consequence for a migration is that fallback chains break loudly. If your error handler drops to a closed model when the primary fails, that fallback now returns 404 with code model_not_found instead of silently working. Loud is the right failure here -- the alternative is a fallback path that appears healthy in staging and is never exercised until an incident.
Difference 3: organisation and project headers are discarded
OpenAI-Organization and OpenAI-Project are accepted and thrown away. There is nothing behind them here, and rejecting them would break integrations that set them by default.
Use one API key per project instead. Spend is attributed per key and shows up that way in the usage log, which gives you the same separation without a second identity concept.
A related detail if you run behind a proxy: the relay strips the inbound x-forwarded-for and re-sets it from the connection, rather than forwarding whatever the caller sent. The gateway rate-limits on that address, so passing it through verbatim would let a caller forge the origin the limiter counts against.
What is identical, plus one extra field
The error envelope is OpenAI's exactly -- message, type, param, code -- because SDK error handling reads those fields. A near-miss error shape is worse than no compatibility at all: it fails only on the unhappy path, in production, long after the migration looked fine.
The one addition is usage.cost_usd on every response, which means you never have to reconcile a bill against a rate card to find out what a request cost. On the models endpoint, the platform-specific data hangs off a namespaced pricing object for the same reason: a strict SDK validator will ignore an unknown key but will choke on a redefined known one.
Streaming: two endpoints, two different terminators
Chat completions stream as chat.completion.chunk frames terminated by data: [DONE], matching what an OpenAI client expects. The newer responses endpoint does not: it emits named SSE events and has no [DONE] sentinel at all. Its terminator is response.completed, or response.incomplete, and each payload carries a sequence_number so a client can detect a dropped frame.
With stream_options.include_usage set, one extra frame arrives after the last content frame and before [DONE]: empty choices, populated usage. While include_usage is on, every earlier frame carries usage: null rather than omitting the key, so a client can read chunk.usage unconditionally and get its answer on exactly one frame.
One more thing that trips migrations onto the responses endpoint: parse the output array. Walk output[], switch on output[].type, then on output[].content[].type. Reading a fixed path like output[0].content[0].text works today and breaks the first time a reasoning item is emitted ahead of the message.
Deliberate restrictions, and the rate limits
Four behaviours are narrower than the platform being replaced, each for a stated reason rather than as an omission. When both max_completion_tokens and max_tokens are present, the newer spelling wins -- the opposite rule would make adopting it a breaking change for anyone whose old value still sits in a shared config file. On the legacy completions endpoint, n must be 1, because generation is deterministic and additional choices would be identical copies you were billed for. Token-array prompts are rejected rather than mishandled, since there is no tokenizer here to invert one. And echo output is not billed as completion tokens: charging a caller twice for their own prompt is exactly what this platform's pricing argument is against.
| Endpoint | Limit |
|---|---|
| POST /v1/chat/completions | 60 / minute / key |
| POST /v1/responses | 60 / minute / key |
| POST /v1/completions | 60 / minute / key |
| POST /v1/embeddings | 300 / minute / key |
| POST /v1/video/generations | 10 / minute / key |
Rolling back
Change the base URL back. Nothing here writes to your side and we hold no state your application depends on -- keys and balance live with us, your data does not. Keep the old credentials until you are satisfied; there is no lock-in step to undo.
The honest way to run the cutover is to send one real request first and read the usage block, because that is where a billing surprise would show up before a traffic shift makes it expensive.
curl https://router.xark.io/api/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model":"z-ai/glm-5.2","messages":[{"role":"user","content":"Say hello"}]}' \
| jq .usage
AI Token Router is an OpenAI-compatible gateway for open-weight models. Every rate on the pricing page is printed next to the model’s official rate, so the numbers in this post are checkable rather than claimed.
Get an API keyRelated
- Rate limits, concurrency and backoff
Five independent per-endpoint buckets, a fixed window that allows 120 requests in two seconds, and the field that says whether retrying will help.
- What a leaked API key can actually do
Keys are shown once because the plaintext is fetched once. What rotation really has to do, and why a leaked key's blast radius is your balance plus auto-recharge.
- Streaming: who pays when nobody listens
The SSE frames in order, the one frame that carries usage, and what actually happens to the bill when a client hangs up mid-completion.