# Zyquo Router — API Reference The exact public contract served on `http://localhost:` (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`. ```python 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 `Authorization` header. - **When local keys exist** (created in *Keys → Local API Keys*), every endpoint except `GET /health` requires `Authorization: Bearer zyquo-sk-…`. Unknown, revoked, or malformed tokens → `401` (`authentication_error`, code `invalid_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`, code `model_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-chat` works; an ID hosted by two providers → `404` with 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`: ```json { "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. Together `eos`→`stop`, Anthropic `tool_use`→ `tool_calls`, Gemini `SAFETY`→`content_filter`). - `usage` comes 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 in `usage.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 `` tags (extracted). ### Streaming (SSE) `Content-Type: text/event-stream`; each event is `data: `, terminated by `data: [DONE]`. Byte-exact chunk discipline: 1. First chunk: role delta `{"delta":{"role":"assistant","content":""}}`. 2. Content deltas `{"delta":{"content":"…"}}`; reasoning deltas `{"delta":{"reasoning_content":"…"}}`. 3. Tool calls stream as OpenAI deltas: first frame carries `{"index":N,"id":"…","type":"function","function":{"name":"…","arguments":""}}`, subsequent frames only `{"index":N,"function":{"arguments":""}}`. (Gemini delivers arguments whole; the router emits announce + one full fragment.) 4. Finish chunk: empty delta + `"finish_reason"`. 5. If `stream_options.include_usage`: one usage chunk with **empty `choices` array**. 6. `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 `model` field 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: ```json { "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: ```json { "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; `` 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 ```bash 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}' ``` ```javascript 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 ?? ""); ``` ```python # LangChain from langchain_openai import ChatOpenAI llm = ChatOpenAI(base_url="http://localhost:8787/v1", api_key="zyquo-sk-…", model="anthropic/claude-sonnet-4-5") ```