SPB Git

spb/zyquo-router Public MIT

One local endpoint, every AI provider — a private OpenAI-compatible LLM gateway for your Mac (170 models, 12 providers).

Swift 95.7% Python 2.3% Shell 1.2% Makefile 0.9%
12.4 KB

# 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.

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>1400 (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_reasonstop | length | tool_calls | content_filter (every upstream value is normalized into this set; e.g. Together eosstop, Anthropic tool_usetool_calls, Gemini SAFETYcontent_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 <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:

  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":"<fragment>"}}. (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 (assistantmodel); 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 seedrandom_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

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")