Zyquo Router — API Reference
The exact public contract served on http://localhost:<port> (default port 8787).
This document and the implementation are maintained together; the in-app Docs screen
renders this file. OpenAI-compatible: point any OpenAI SDK at
base_url = http://localhost:8787/v1.
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8787/v1", api_key="zyquo-sk-…")
r = client.chat.completions.create(
model="anthropic/claude-sonnet-4-5", # any provider/model from GET /v1/models
messages=[{"role": "user", "content": "Hello"}],
)Authentication
- Localhost (default bind): authentication is optional. With no local API keys
configured, requests need no
Authorizationheader. - When local keys exist (created in Keys → Local API Keys), every endpoint except
GET /healthrequiresAuthorization: Bearer zyquo-sk-…. Unknown, revoked, or malformed tokens →401(authentication_error, codeinvalid_api_key). - LAN bind (
0.0.0.0) refuses to start without at least one enabled local key. - Keys may carry a model allow-list: requests for other models →
403(permission_error, codemodel_not_allowed). - Provider API keys (OpenAI, Anthropic, …) live only in the encrypted vault on the Mac running the router. No endpoint ever returns them, and they never appear in logs or error messages.
Model naming
- Canonical IDs are namespaced:
provider/model-id— e.g.openai/gpt-5.2,anthropic/claude-sonnet-4-5,deepseek/deepseek-chat,deepinfra/meta-llama/Llama-4-Maverick. The segment before the first/must be a provider (openai, anthropic, xai, mistral, gemini, qwen, deepseek, kimi, perplexity, together, deepinfra, cerebras); model IDs may themselves contain/. - Bare upstream IDs are accepted when unambiguous across providers
(
deepseek-chatworks; an ID hosted by two providers →404with the namespaced candidates listed). - Aliases (user-defined, e.g.
fast) resolve before anything else. - Disabled models 404 exactly like unknown ones.
- Responses always echo the namespaced ID in
model— including when a fallback chain routed the request to a different model than requested (honest reporting).
POST /v1/chat/completions
The full current OpenAI request schema is accepted. Highlights and router-specific behavior:
| Field | Behavior |
|---|---|
model |
Namespaced ID, unambiguous bare ID, or alias. Required. |
messages |
All roles: system, developer, user, assistant (incl. tool_calls), tool. Content may be a string or content-part array. |
| Image parts | {"type":"image_url","image_url":{"url":…}} with data-URI base64 or remote URL. Vision-capable models only (else 400). Gemini: data-URI only — the router does not fetch remote URLs for Gemini (400 with explanation). |
max_tokens / max_completion_tokens |
Both accepted; max_completion_tokens wins. Sent upstream under the name each provider documents. Anthropic requires one — when omitted the router fills the model's catalog max output (fallback 4096). |
temperature, top_p, stop, seed, frequency_penalty, presence_penalty, user, logprobs… |
Translated, clamped, renamed, or stripped per provider (see Provider notes). Unsupported params are stripped silently — never a 400 for asking. |
n |
Only n=1. n>1 → 400 (invalid_request_error, param n). |
tools, tool_choice, parallel_tool_calls |
Full function calling on tool-capable models (else 400). Translated natively for Anthropic (input_schema, tool_choice auto/any/none/tool, disable_parallel_tool_use) and Gemini (functionDeclarations, functionCallingConfig). |
response_format |
json_object / json_schema forwarded where supported (Gemini: responseMimeType/responseJsonSchema; OpenAI-compatible: pass-through). Anthropic: best-effort via system steering (documented limitation). |
reasoning_effort |
OpenAI-standard values, translated per provider (Anthropic thinking budget 1024/8192/24576; Gemini thinkingConfig; pass-through where native). Stripped on non-reasoning models. |
stream |
SSE streaming (below). |
stream_options.include_usage |
Adds the final usage chunk (empty choices). |
| Unknown keys | Passed through to OpenAI-compatible upstreams — use provider extras like Perplexity search_domain_filter, Qwen enable_thinking, Together top_k, Anthropic thinking (extra body). |
Non-streaming response
Spec-exact chat.completion:
{
"id": "chatcmpl-5f9d174703e1",
"object": "chat.completion",
"created": 1785462056,
"model": "anthropic/claude-haiku-4-5-20251001",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "OK" },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16 }
}finish_reason∈stop | length | tool_calls | content_filter(every upstream value is normalized into this set; e.g. Togethereos→stop, Anthropictool_use→tool_calls, GeminiSAFETY→content_filter).usagecomes from the upstream when reported. When an upstream reports none, the router estimates (~4 chars/token) and flags it:"usage": { …, "x_zyquo": {"usage_estimated": true} }.- Cached prompt tokens land in
usage.prompt_tokens_details.cached_tokens; reasoning tokens inusage.completion_tokens_details.reasoning_tokens.
Reasoning output
Reasoning/thinking text is normalized to reasoning_content — a sibling of
content on the message (non-streaming) and the delta (streaming) — the DeepSeek
convention that most tooling already understands. Sources: DeepSeek/Qwen/Kimi/xAI
native field, Anthropic thinking blocks, Gemini thought parts, Mistral Magistral
thinking chunks, Perplexity <think> tags (extracted).
Streaming (SSE)
Content-Type: text/event-stream; each event is data: <chat.completion.chunk JSON>,
terminated by data: [DONE]. Byte-exact chunk discipline:
- First chunk: role delta
{"delta":{"role":"assistant","content":""}}. - Content deltas
{"delta":{"content":"…"}}; reasoning deltas{"delta":{"reasoning_content":"…"}}. - Tool calls stream as OpenAI deltas: first frame carries
{"index":N,"id":"…","type":"function","function":{"name":"…","arguments":""}}, subsequent frames only{"index":N,"function":{"arguments":"<fragment>"}}. (Gemini delivers arguments whole; the router emits announce + one full fragment.) - Finish chunk: empty delta +
"finish_reason". - If
stream_options.include_usage: one usage chunk with emptychoicesarray. data: [DONE].
The id/created/model envelope is constant across a stream. Comment lines
(: keep-alive) may appear and must be ignored (all OpenAI SDKs do).
Mid-stream upstream failure: the router cannot change the HTTP status after bytes
are sent; it emits one error frame data: {"error":{"message":…,"type":…,"code":…}}
followed by data: [DONE], and never retries after the first forwarded byte.
Client disconnect cancels the upstream call immediately.
Retries & fallbacks
- Transient upstream failures (429, 5xx, network) retry with exponential backoff +
jitter (max 3 attempts), honoring
Retry-After— only before any byte has been forwarded. - User-configured fallback chains try the next model in the chain on upstream
failure (rate limit, 5xx, network, missing/invalid provider key — never on request
errors). The response
modelfield reports the model that actually answered.
Errors
Always OpenAI-shaped: {"error": {"message", "type", "param", "code"}}.
| Status | When | type / code |
|---|---|---|
| 400 | Malformed body, missing model/messages, n>1, capability mismatch (tools/vision on unsupporting model), upstream rejected request, Gemini prompt block |
invalid_request_error |
| 401 | Missing/invalid/revoked local key → invalid_api_key · provider key missing → missing_provider_key · provider key rejected upstream → invalid_provider_key |
authentication_error |
| 403 | Local key not allowed for this model | permission_error / model_not_allowed |
| 404 | Unknown/disabled/ambiguous model (model_not_found), unknown route |
invalid_request_error |
| 413 | Body over the request size limit (default 32 MB) | invalid_request_error |
| 429 | Upstream rate limit (with Retry-After when known) |
rate_limit_error / upstream_rate_limited |
| 502 | Upstream 5xx / unreachable / malformed upstream response | api_error / upstream_error |
| 504 | Upstream timeout | api_error / upstream_timeout |
Provider payload shapes and key material never leak into errors.
GET /v1/models
OpenAI list shape over the full enabled catalog (all providers, namespaced IDs), with
router metadata under the x_zyquo extension key:
{
"object": "list",
"data": [{
"id": "anthropic/claude-sonnet-4-5",
"object": "model",
"created": 1785461333,
"owned_by": "anthropic",
"x_zyquo": {
"display_name": "Claude Sonnet 4.5",
"context_window": 200000, "max_output_tokens": 64000,
"vision": true, "tools": true, "reasoning": true,
"input_per_mtok": 3.0, "output_per_mtok": 15.0
}
}]
}GET /v1/models/{id} returns a single entry (namespaced, bare, or alias id;
URL-encode if needed — IDs containing / also work raw).
GET /health
Unauthenticated readiness probe:
{ "status": "ok", "version": "1.0.0", "uptime": 42, "models": 170 }Provider notes (translation table summary)
| Provider | Upstream API | Notes |
|---|---|---|
openai |
native chat/completions | Reference; pass-through. |
anthropic |
Messages API (translated) | max_tokens synthesized when omitted; temperature clamped to ≤1; system/developer → top-level system; consecutive turns merged; tool results become tool_result blocks; stop_reason mapped; usage includes cache reads in prompt_tokens. response_format best-effort. Extra body thinking / top_k forwarded. |
gemini |
native generateContent (translated) | Roles renamed (assistant→model); tool messages → functionResponse (object-wrapped, name resolved from tool_call_id); STOP+functionCall → finish_reason:"tool_calls"; images must be data URIs; n>1 unsupported; blocked prompts → 400 naming the reason. |
xai |
compat | Reasoning models reject presence_penalty/frequency_penalty/stop — stripped. search_parameters pass-through. |
mistral |
compat | seed→random_seed; logit_bias/user/logprobs stripped. Magistral thinking arrays flattened into reasoning_content. |
qwen (DashScope intl) |
compat | enable_thinking/thinking_budget pass-through; streaming-only models transparently aggregated for non-streaming clients. |
deepseek |
compat | reasoning_content passed through natively; cache-hit tokens → cached_tokens; assistant reasoning_content echoed back only in tool loops (stripped otherwise). |
kimi (Moonshot) |
compat | temperature clamped to [0,1]. |
perplexity |
compat | citations/search_results pass through verbatim; <think> extracted to reasoning_content; search params (search_domain_filter, web_search_options, …) pass-through. No function calling. |
together |
compat | finish_reason:"eos"→stop; top_k/min_p/repetition_penalty pass-through. |
deepinfra |
compat | logit_bias stripped; usage estimated_cost used for cost metering. |
cerebras |
compat | max_completion_tokens naming; base64-only images. |
Copy-paste snippets
curl http://localhost:8787/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer zyquo-sk-…" \
-d '{"model":"deepseek/deepseek-chat","messages":[{"role":"user","content":"Hi"}],"stream":true}'import OpenAI from "openai";
const client = new OpenAI({ baseURL: "http://localhost:8787/v1", apiKey: "zyquo-sk-…" });
const stream = await client.chat.completions.create({
model: "gemini/gemini-2.5-flash",
messages: [{ role: "user", content: "Hi" }],
stream: true,
});
for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? "");# LangChain
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(base_url="http://localhost:8787/v1", api_key="zyquo-sk-…",
model="anthropic/claude-sonnet-4-5")