SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
53.2 KB

# OpenRouter — provider research for PolyLLM

Last documentation audit: 2026-09-08 Probes executed 2026-09-08 with a real paid key against https://openrouter.ai/api/v1 (scripts in research/openrouter/, raw outputs in research/openrouter/out/, full model dump in research/openrouter/out/models.json, 428 models). Everything marked (probed) was observed live; everything marked (docs) comes from the pages listed at the end. Total probe spend ≈ $0.07.

Heads-up: several openrouter.ai/docs/... URLs given in the brief are 404 now (/docs/api-reference/list-available-models, /docs/api-reference/get-current-api-key, /docs/features/tool-calling, /docs/api-reference/responses/*, /docs/sdks/typescript). The documentation was reorganised under /docs/api/api-reference/..., /docs/guides/..., /docs/api_reference/... and /docs/client-sdks/...; the index is https://openrouter.ai/docs/llms.txt (every page also exists as .md). See "Documentation pages used".


# 1. Base URL, auth, headers

Item Value
REST base URL https://openrouter.ai/api/v1
Auth Authorization: Bearer <OPENROUTER_API_KEY> (keys look like sk-or-v1-…)
Attribution headers (optional, docs) HTTP-Referer: <site url> (leaderboard/rankings) and X-OpenRouter-Title: <app name>. The older X-Title is still shown on the OpenAI-SDK page and both are accepted (probed: 200 with either). The /generation record stored origin: "https://www.polyllm.io/" from our HTTP-Referer (probed). Also X-OpenRouter-Categories (docs).
Response headers (probed) Only Cloudflare basics (cf-ray, server: cloudflare, content-type, x-content-type-options). No x-ratelimit-* headers on 200s; docs say X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After appear on 429/503. No request-id header — use the body id (gen-…).
Key introspection GET /api/v1/key → data.{label, usage, usage_daily, usage_weekly, usage_monthly, limit, limit_remaining, limit_reset, is_free_tier, is_management_key, is_provisioning_key(deprecated), byok_usage*, include_byok_in_limit, expires_at, creator_user_id, rate_limit(deprecated: {requests:-1, interval:"10s", note})} (probed, 200 with a normal inference key). Ideal "validate key" call (no tokens). Invalid key → 401 {"error":{"message":"User not found.","code":401}}.
Credits GET /api/v1/credits → {"data":{"total_credits":1050,"total_usage":778.849026603}} (probed 200 with our normal key) although the docs say "Management key required" — don't depend on it for user keys; treat 403 as "not available".
Session stickiness body session_id (≤256 chars) or header x-session-id (docs) — groups multi-turn requests on the same provider and is used by openrouter/auto to keep a conversation on the same model.

# 2. SDK recommendation (TypeScript / Node)

  • OpenRouter now ships its own SDKs: @openrouter/sdk (TypeScript, openRouter.chat.send({model, messages, stream})) and @openrouter/agent; the quickstart says "For all code samples and guidance use OpenRouter SDKs by default (@openrouter/sdk for TypeScript…)". The OpenAI SDK with baseURL: "https://openrouter.ai/api/v1" + defaultHeaders is still a documented, fully supported path ("drop-in replacement", community page guides/community/openai-sdk), with OpenRouter-only fields passed through extra_body/untyped properties. @openrouter/ai-sdk-provider exists for Vercel AI SDK users (exposes generation ids and cost via provider metadata).
  • Recommendation for PolyLLM: keep the OpenAI SDK openai@7.10.0 (probed) or a raw fetch SSE parser — PolyLLM already has both for xAI/OpenAI and the wire format is identical. Everything OpenRouter-specific (reasoning, provider, plugins, usage.cost, reasoning_details, annotations, provider field) is untyped in the OpenAI SDK, so cast (as any) or use raw fetch. The OpenAI SDK's SSE parser already skips the : OPENROUTER PROCESSING comment lines. Only adopt @openrouter/sdk if we want typed access to those extras.
  • The models array / route fallback fields of the old docs are now "deprecated — use provider.sort.partition" (API reference).

# 3. Endpoints

Endpoint Status Notes
POST /api/v1/chat/completions primary OpenAI-compatible + OpenRouter extras. (probed)
POST /api/v1/responses active, stateless Drop-in for OpenAI Responses API but store: true and previous_response_id are rejected with 400 ("Invalid input: expected false" / "expected null, received string") (probed). Response echoes store: false. Streaming event names: response.created, response.in_progress, response.output_item.added, response.content_part.added, response.output_text.delta, response.output_text.done, response.content_part.done, response.output_item.done, response.completed, [DONE] (probed). Usage: input_tokens, output_tokens, *_details, cost, cost_details.upstream_inference_input_cost/output_cost. Not needed for PolyLLM — chat completions covers everything, and reasoning/tools work there.
GET /api/v1/models active Full catalogue with pricing/params (§12). Paginated (limit default 500, max 1000, links.next, total_count); with no params the full list is returned (data: 428, links.next: null) (probed). Many filters: supported_parameters=tools, output_modalities, input_modalities, category, sort, q, zdr=true, min_price… (docs). Works without auth (200 with a bad key) (probed).
GET /api/v1/models/user active Same shape filtered by the account's provider/model preferences (425 vs 428: hides meta/muse-spark-*-contributor, sakana/sakana-namazu) (probed). Use this one when the user's key is set so ignored providers disappear.
GET /api/v1/models/count active {"data":{"count":428}} (probed)
GET /api/v1/models/{author}/{slug}/endpoints active Per-provider endpoints: name, provider_name, tag, context_length, max_completion_tokens, max_prompt_tokens, pricing (incl. discount), supported_parameters, supports_tool_choice, status, quantization, uptime_last_30m/5m/1d, supports_implicit_caching, latency_last_30m, throughput_last_30m, perf_last_30m_by_workload (probed). links.details in the listing points here.
GET /api/v1/endpoints/zdr active 845 zero-data-retention endpoints (probed)
GET /api/v1/providers active 106 providers with slug, privacy_policy_url, terms_of_service_url, status_page_url, headquarters (probed)
GET /api/v1/generation?id=gen-… active Async stats for one generation: native_tokens_prompt/completion/reasoning/cached, usage (USD), latency, generation_time, streamed, cancelled, finish_reason, origin, provider… (probed: 404 "Generation … not found" 1.5 s after the call, 200 after ~4 min — poll with backoff or just use usage.cost from the response).
GET /api/v1/key, GET /api/v1/credits active §1
POST /api/beta/batches beta :batch model variants are only usable here: chat completions → 404 "This model is only available through the Batch API. Use the /api/beta/batches endpoint instead." (probed).
POST /api/v1/images/generations, /api/v1/images/models, audio/TTS/STT, embeddings active Out of scope; image generation also works through chat completions with modalities: ["image","text"] (docs).

# 4. Chat Completions request/response (probed shapes)

Request body (docs API ref + probes): model, messages (roles system|developer|user|assistant|tool; developer accepted on all 5 probed models), stream, max_tokens (docs mark it deprecated in favour of max_completion_tokens, but only 60/428 models list max_completion_tokens in supported_parameters while 417 list max_tokens → send max_tokens), temperature 0–2, top_p, top_k, min_p, top_a, frequency_penalty/presence_penalty −2..2, repetition_penalty, seed, stop (≤4), logit_bias, logprobs/top_logprobs (0–20), n, response_format (text|json_object|json_schema|grammar|python), tools, tool_choice (none|auto|required|{type:"function",function:{name}}), parallel_tool_calls, reasoning (§8), reasoning_effort (shorthand), include_reasoning (legacy), verbosity (low|medium|high), usage: {include: true} (deprecated, no effect — usage is always returned, docs + probed), stream_options: {include_usage: true} (same), provider (§11), plugins (§10), models/route (deprecated), modalities, image_config, web_search_options, prediction, session_id, user, metadata (≤16 pairs), trace, service_tier (auto|default|fast|flex|priority|scale), prompt_cache_key, prompt_cache_options, cache_control (message-part level, §13), debug: {echo_upstream_body: true} (stream only, dev only).

Unknown top-level params (foo_bar) are silently ignored (probed). Unsupported-but-known params are silently dropped unless provider.require_parameters: true (§9).

Non-streaming response (probed, x-ai/grok-4.20 with reasoning.effort: low):

json
{
  "id": "gen-1788849315-…", "object": "chat.completion", "created": 1788849315,
  "model": "x-ai/grok-4.20", "provider": "xAI", "system_fingerprint": null, "service_tier": "default",
  "choices": [{ "index": 0, "logprobs": null, "finish_reason": "stop", "native_finish_reason": "completed",
    "message": { "role": "assistant", "content": "2 + 2 equals 4.", "refusal": null,
      "reasoning": "The question is: \"What is 2+2? …\"\n",
      "reasoning_details": [
        { "type": "reasoning.summary", "summary": "The question is: …", "format": "xai-responses-v1", "index": 0 },
        { "type": "reasoning.encrypted", "data": "Tux7gDG5T2…", "format": "xai-responses-v1", "index": 1 } ] } }],
  "usage": { "prompt_tokens": 199, "completion_tokens": 156, "total_tokens": 355,
    "cost": 0.00050435, "is_byok": false,
    "prompt_tokens_details": { "cached_tokens": 128, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0 },
    "cost_details": { "upstream_inference_cost": 0.00050435, "upstream_inference_prompt_cost": 0.00011435, "upstream_inference_completions_cost": 0.00039 },
    "completion_tokens_details": { "reasoning_tokens": 148, "image_tokens": 0, "audio_tokens": 0 } }
}
  • provider (top level) = the upstream that actually served the call (OpenAI, Amazon Bedrock, Google, xAI, Novita, GMICloud, Parasail, StreamLake, AtlasCloud, Alibaba, Venice, Azure, DigitalOcean, NextBit…). It changes from call to call for open models (DeepSeek hit 9 different providers in one probe run) → show it in the usage panel; it explains quality/format differences.
  • model = the resolved slug (aliases/~latest/openrouter/auto resolve here: ~openai/gpt-mini-latest → openai/gpt-5.4-mini, openrouter/auto → deepseek/deepseek-v4-flash-0731 (probed)). Unknown variants are ignored: openai/gpt-5.4-nano:nope → 200 served as openai/gpt-5.4-nano (probed).
  • usage.cost = USD (credits) charged, always present (BYOK: is_byok: true, cost = OpenRouter fee only and cost_details.upstream_inference_cost = provider cost). Plugin fees are included in cost but not in upstream_inference_cost (web plugin: cost 0.007709 vs upstream 0.000709 → $0.007 Exa search fee) (probed). Use usage.cost directly for the cost display — no client-side price math needed for OpenRouter.
  • completion_tokens INCLUDES reasoning_tokens (DeepSeek: completion 24 = 8 visible + 16 reasoning; xAI: 156 = 8 + 148) (probed) — unlike xAI direct. And max_tokens caps reasoning + visible text together on most providers: DeepSeek with max_tokens: 40 and reasoning.exclude: true returned content: null, finish_reason: "length", 40 reasoning tokens; gpt-5.4-nano :online with max_tokens: 200 spent all 200 on reasoning → content: null (probed). Adapter rule: for reasoning-capable models never send a small max_tokens; default ≥ 2048 (or max_tokens = reasoning.max_tokens + visible budget).
  • finish_reason normalised to stop | length | tool_calls | content_filter | error (+ function_call in the schema); raw value in native_finish_reason (completed, end_turn, STOP, stop, length…) (probed).
  • message.reasoning may be null while reasoning_details is present (Gemini returns only an encrypted signature, no text) (probed).
  • Hidden prompt overhead: xAI models carry ≈ 196 prompt tokens (128 cached) on an empty request, DeepSeek ≈ 90 via some providers, others ~7–20 (probed).

# 5. Streaming protocol (probed)

SSE, data: {json} lines, terminated by data: [DONE], plus comment lines : OPENROUTER PROCESSING as keep-alives (1 on OpenAI/Anthropic, 8 on xAI while it was thinking, 0 on Gemini/DeepSeek). SSE comments must be skipped before JSON.parse (OpenAI SDK / eventsource-parser do it; a naive data: splitter that also feeds other lines would crash).

json
data: {"id":"gen-…","object":"chat.completion.chunk","created":1788849322,"model":"x-ai/grok-4.20","provider":"xAI",
       "choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":"The",
         "reasoning_details":[{"type":"reasoning.summary","summary":"The","format":"xai-responses-v1","index":0}]},
         "finish_reason":null,"native_finish_reason":null}]}
…
data: {"…","choices":[{"index":0,"delta":{"content":"Bonjour"},"finish_reason":null,"native_finish_reason":null}]}
data: {"…","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"completed"}]}
data: {"…","service_tier":"default","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"completed"}],
       "usage":{"prompt_tokens":196,"completion_tokens":380,"total_tokens":576,"cost":0.0010606,"is_byok":false,
                "prompt_tokens_details":{"cached_tokens":128,…},"cost_details":{…},"completion_tokens_details":{"reasoning_tokens":378,…}}}
data: [DONE]
  • Delta keys observed: role, content, reasoning (plain text), reasoning_details[] (fragments), tool_calls, annotations (web search). Every chunk carries provider and model.
  • The finish chunk is sent twice: once without usage, then a final chunk with usage that still has choices:[{delta:{content:"",role:"assistant"},finish_reason:"stop"}] (docs: deliberate deviation from OpenAI's empty choices "to prevent client crashes"). Do not double-count finish_reason; take usage from whichever chunk has usage. Usage is included without any flag (probed).
  • Reasoning streams as delta.reasoning text for DeepSeek (raw thoughts), xAI (summary), Anthropic (thinking text when enabled via reasoning.max_tokens); Gemini streams only reasoning_details with an encrypted signature and no text; OpenAI gpt-5.4-nano streamed nothing (0 reasoning tokens on trivial prompts at low).
  • Mid-stream errors (docs): HTTP 200 already sent, then an SSE event {"error":{"code":"server_error","message":"…","metadata":{"error_type","provider_code"}},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]} and the stream ends. Handle finish_reason: "error" + top-level error.
  • Cancellation: aborting the connection stops billing on OpenAI, Anthropic, Fireworks, Together, Cohere and ~20 others; not on Groq, Google, Mistral, HuggingFace (docs).

# 6. Tool / function calling (probed on all 5 models)

  • OpenAI nested format; tool_choice auto|none|required|{type:"function",function:{name}}; parallel_tool_calls (default true). Only 9/428 models list parallel_tool_calls in supported_parameters — sending it elsewhere is silently dropped (probed OK on all 5).
  • Streaming: tool calls arrive as fragmented deltas (OpenAI 8 chunks, DeepSeek 5, Anthropic 4, xAI 3, Gemini 2) → standard accumulation by index, then finish_reason: "tool_calls". Ids are provider-native: call_… (OpenAI/Gemini/DeepSeek), toolu_bdrk_… (Anthropic via Bedrock), call-<uuid>-0 (xAI).
  • Round 2 with assistant.tool_calls + {role:"tool", tool_call_id, content} worked on all 5 (finish_reason: stop).
  • Reasoning + tools: with Anthropic thinking (reasoning.max_tokens: 1024) and Gemini, reasoning_details came alongside the tool call (Anthropic: reasoning.text fragments + final signature, format: "anthropic-claude-v1"; Gemini: one reasoning.encrypted whose id equals the tool-call id, format: "google-gemini-v1"). Docs require passing reasoning_details back unmodified on the assistant message ("the entire sequence of consecutive reasoning blocks must match"). We merged the streamed fragments by (type,index) and passed them back → 200; omitting them also returned 200 on both (probed) — OpenRouter tolerates it, but pass them back anyway for cache and thought continuity (mandatory for OpenAI Responses-backed models per docs). Store reasoning_details in PolyLLM's message model.
  • Find tool-capable models: supported_parameters contains tools (361/428) / tool_choice (353). Endpoint-level supports_tool_choice in /endpoints.
  • Docs also mention server tools (openrouter:web_search, stop_server_tools_when) and :exacto (quality-first provider sort for tool reliability).

# 7. Structured output (probed)

  • response_format: {type:"json_schema", json_schema:{name, strict:true, schema}} with provider.require_parameters: true → valid JSON on gpt-5.4-nano, claude-haiku-4.5, gemini-3.5-flash-lite, grok-4.20; DeepSeek returned content: null (finish_reason: length, 200 reasoning tokens ate max_tokens: 200) — the usual reasoning-budget trap, not a schema failure. Without require_parameters, all 5 produced schema-conformant JSON in the params probe. Streaming json_schema works (partial JSON deltas, valid when concatenated) (probed on gpt-5.4-nano).
  • {type:"json_object"} works on all 5, but Anthropic wrapped it in a ```json fence (probed) → strip fences when parsing json_object output.
  • Capability flags: supported_parameters has response_format (368) and structured_outputs (345; = strict schema support). 52 models have tools but not structured_outputs. Docs: "the same model may be served by multiple providers, and only some… support structured outputs" → send provider: {require_parameters: true} whenever response_format.type === "json_schema"; docs say tools, response_format and verbosity are already "soft preferences" in routing.
  • Extra formats exist in the schema (grammar GBNF, python) and a response-healing plugin for non-streaming JSON repair (docs).

# 8. Reasoning controls

Request: reasoning: { effort?: "max"|"xhigh"|"high"|"medium"|"low"|"minimal"|"none", max_tokens?: number, exclude?: boolean, enabled?: boolean, summary?: "auto"|"concise"|"detailed", context?: "all_turns"|"current_turn"|"auto", mode?: "standard"|"pro" } (docs). reasoning_effort: "low" shorthand also works (probed). Effort→budget conversion for max_tokens-only providers: max/xhigh ≈95 %, high 80 %, medium 50 %, low 20 %, minimal 10 % of max_tokens (docs).

Listing field reasoning (present on 302/428 models; absent = non-reasoning): { mandatory: boolean, default_enabled?: boolean, supported_efforts?: string[], default_effort?: string, supports_max_tokens?: boolean }. Observed shapes: {mandatory} only (107), +default_enabled (34), +supported_efforts+default_effort (94/57), +supports_max_tokens (4/6).

Model (probed) listing reasoning observed
openai/gpt-5.4-nano {mandatory:false, default_enabled:false, supported_efforts:[xhigh,high,medium,low,none], default_effort:medium} off by default (0 reasoning tokens); effort: low accepted (still 0 on trivial prompt); none OK; max_tokens/exclude/enabled:false accepted (no-ops)
anthropic/claude-haiku-4.5 {mandatory:false} effort does nothing (0 tokens); only reasoning.max_tokens: 1024 turns thinking on (43–65 tokens) → reasoning text + reasoning_details[{type:"reasoning.text", text, signature, format:"anthropic-claude-v1"}]. Matches the PolyLLM note "Haiku 4.5 needs budget_tokens". enabled:false fine.
google/gemini-3.5-flash-lite {mandatory:true, default_enabled:true, supported_efforts:[high,medium,low,minimal], default_effort:minimal} effort: none and enabled: false → 400 "Reasoning is mandatory for this endpoint and cannot be disabled." (metadata.provider_name: null = OpenRouter-side validation). reasoning text never returned; reasoning_details[{type:"reasoning.text", signature, format:"google-gemini-v1"}] (signature only)
deepseek/deepseek-v4-flash {mandatory:false, supported_efforts:[xhigh,high], default_effort:high} reasoning on by default (15–40 tokens); effort: none and enabled:false → 0 tokens (works although none isn't listed); raw thoughts in reasoning + reasoning_details[{type:"reasoning.text", text, format:"unknown"}]; exclude:true hides the text but still bills/consumes max_tokens
x-ai/grok-4.20 {mandatory:false, default_enabled:false} off by default; effort: low / max_tokens / reasoning_effort turn it on (90–165 tokens) → reasoning.summary + reasoning.encrypted (format: xai-responses-v1)

Adapter rules: (1) reasoning absent → no reasoning UI. (2) mandatory: true → never send effort: none / enabled: false; offer only supported_efforts. (3) mandatory: false + supported_efforts → toggle + effort picker (none only if listed, else use enabled: false to turn off). (4) mandatory: false and no supported_efforts (Anthropic/Qwen/…): toggle maps to reasoning.max_tokens (budget slider, min 1024 for Anthropic per docs) or reasoning.enabled: true. (5) default_enabled decides the initial toggle state (absent → mandatory). (6) supports_max_tokens: true → expose a budget slider; docs say Anthropic (1024–128k) and Gemini (thinkingBudget) accept max_tokens even when the flag is absent. (7) Always keep reasoning_details and replay them.

# 9. Sampling & other parameters — support matrix (probed, chat completions; L = listed in supported_parameters, ✓ = 200 by default, 404 = rejected only with provider.require_parameters: true)

Param gpt-5.4-nano claude-haiku-4.5 gemini-3.5-flash-lite deepseek-v4-flash grok-4.20
temperature ✓ dropped (not L) ✓ L ✓ L ✓ L ✓ L
top_p ✓ dropped ✓ L ✓ L ✓ L ✓ L
top_k ✓ dropped / 404 rp ✓ L ✓ dropped / 404 rp ✓ L ✓ dropped / 404 rp
min_p, top_a dropped dropped dropped ✓ L dropped
seed ✓ L dropped / 404 rp ✓ L ✓ L ✓ L
stop dropped / 404 rp ✓ L ✓ L ✓ L dropped / 404 rp
frequency_penalty / presence_penalty dropped / 404 rp dropped / 404 rp dropped / 404 rp ✓ L dropped / 404 rp
repetition_penalty dropped / 404 rp dropped / 404 rp dropped / 404 rp ✓ L dropped / 404 rp
logprobs + top_logprobs dropped / 404 rp dropped / 404 rp dropped / 404 rp ✓ L ✓ L (logprobs: null returned)
max_tokens / max_completion_tokens ✓ / ✓ ✓ / ✓ ✓ / ✓ (not L) ✓ / ✓ ✓ / ✓ (not L)
n: 2 200, 1 choice 200, 1 choice 200, 1 choice 200, 1 choice 200, 1 choice
response_format json_object / json_schema ✓ / ✓ ✓ (fenced) / ✓ ✓ / ✓ ✓ / ✓ (needs big max_tokens) ✓ / ✓
tools / tool_choice: required / parallel_tool_calls ✓ ✓ ✓ ✓ ✓
developer role ✓ ✓ ✓ ✓ ✓
verbosity: low 200 (not L) 200 200 200 200
unknown foo_bar ignored ignored ignored ignored ignored

Default behaviour (docs + probed): parameters the routed endpoint does not support are silently dropped ("providers that don't support specified parameters receive requests anyway but ignore unsupported fields"); OpenRouter also "omits absent sampling params upstream rather than substituting defaults". With provider: {require_parameters: true} the router filters endpoints and returns 404 {"error":{"message":"No endpoints found that can handle the requested parameters. …","code":404,"metadata":{"routing_funnel":[{"step":"Initial Endpoints","endpoint_count":4},{"step":"Filter by Tier Endpoint Rows","endpoint_count":3}],"failed_routing_step":"Filter by Parameters"}}} — there is no per-parameter 400 like xAI. n > 1 is accepted but only one choice is returned. Out-of-range values are validated by OpenRouter itself: temperature: 7 → 400 "Expected temperature to be at most 2, received 7" (metadata.provider_name: null).

Adapter rule: drive the settings UI from supported_parameters (+ default_parameters for initial values: 262 models carry {temperature, top_p} defaults such as {1, 0.95}, most have nulls); filterSettings() must drop anything not listed; send provider.require_parameters: true only for response_format/tools (soft) — never for sampling params, otherwise multi-provider models lose endpoints for nothing.

  • Input modalities (architecture.input_modalities, 428 models): text 428, image 261, file 162, video 78, audio 46. Output: text 413, text+image 11 (google/gemini-3.1-flash-image*, google/gemini-3-pro-image*, google/gemini-2.5-flash-image, openai/gpt-5-image(-mini), openai/gpt-5.4-image-2, openrouter/auto(-beta)), text+audio 4 (openai/gpt-audio(-mini), google/lyria-3-* music). No model has a non-text output in the chat listing (embeddings/rerank/speech live behind output_modalities= filters and other endpoints).
  • Images (probed): {type:"image_url", image_url:{url:"data:image/png;base64,…", detail:"low"}} → OK on gpt-5.4-nano (21 prompt tokens), haiku (28), gemini (269), grok (202 incl. system prompt). Text-only model (DeepSeek) → 404 "No endpoints found that support image input" (failed_routing_step: "Filter by Image Support") — the router does the modality check before any provider is hit. Invalid base64 → 400 "Provider returned error" with metadata.raw (provider message), provider_name, provider_error_code. Formats: JPEG/PNG/GIF/WebP; public URLs recommended for large files (docs).
  • PDF / files (docs): {type:"file", file:{filename:"x.pdf", file_data:"data:application/pdf;base64,…" | "https://…"}}; plugin {id:"file-parser", pdf:{engine:"mistral-ocr"|"pdf-text"|"native"}} — mistral-ocr $2 / 1000 pages (default when the model has no native file support), pdf-text (a.k.a. cloudflare-ai on the page) free, native = provider input tokens. Response carries file_annotations (parsed text + hash) to resend and avoid re-parsing (also in error.metadata.file_annotations on errors). Models with "file" in input_modalities take PDFs natively.
  • Audio input (docs): {type:"input_audio", input_audio:{data:"<base64>", format:"wav"|"mp3"|…}} (base64 only); video_url parts for video. Out of scope for v1.
  • Context / output: context_length (never null in the listing) and top_provider.max_completion_tokens (null on 6 meta/router models). top_provider.context_length can differ from context_length (e.g. z-ai/glm-5.3-flash 1 310 720 vs 1 048 576) → use top_provider.context_length when present (that's the endpoint that will serve you), fall back to context_length. Requesting max_tokens above the model limit is silently clamped (300 000 on a 128k-output model → 200) but prompt+max_tokens above context → 400 "This endpoint's maximum context length is 400000 tokens. However, you requested about 1000000000 tokens (1 of text input, 999999999 in the output)…" (probed).
  • Web search (probed): model: "<id>:online" (docs: "deprecated, migrate to the openrouter:web_search server tool", still works) or plugins: [{id:"web", max_results: 3, search_prompt?, engine?: "native"|"exa"|"firecrawl"|"parallel"|"perplexity", mode?, include_domains?, exclude_domains?}]. Results come as message.annotations[] (non-stream) / delta.annotations[] (stream, one chunk before the text) shaped {type:"url_citation", url_citation:{url, title, content, start_index: 0, end_index: 0}} (indices were 0 — don't rely on them; the model also inlines Markdown links because the default search_prompt asks for it). Cost: Exa $0.007 per request (10 results) + $0.001/extra result, added to usage.cost; usage.server_tool_use_details.web_search_requests: 1 appears on the OpenAI-native path. The :online call on gpt-5.4-nano injected 8 572 prompt tokens of results → budget ≥ 10k context and ≥ 1k max_tokens. Native provider search via web_search_options: {search_context_size} on 17 models (pricing.web_search per call, e.g. OpenAI $0.01, Gemini $0.014, xAI $0.005).
  • Image generation (docs): modalities: ["image","text"] on an image-output model → message.images[] (data URLs); image_config for aspect ratio/size; pricing.image_output per image (e.g. gemini-3.1-flash-image $0.00006 → $0.06 per image? the string is USD per image unit as listed; gpt-5-image 0.00004). Hide these from the plain chat picker until PolyLLM supports image output.

# 11. Provider routing, variants, meta-models

  • provider: { order?: string[], allow_fallbacks?: true, require_parameters?: false, data_collection?: "allow"|"deny", zdr?: boolean, only?: string[], ignore?: string[], quantizations?: string[], sort?: "price"|"throughput"|"latency" | {by, partition:"model"|"none"}, max_price?: {prompt, completion, …}, preferred_min_throughput?, preferred_max_latency?, enforce_distillable_text? } (docs). Default = price-weighted load balancing among endpoints healthy in the last 30 s. For a BYOK privacy-minded app consider data_collection: "deny" (or zdr: true) as a user setting; note it removes cheap providers and can 404 (No endpoints found).
  • Variants (suffix after :): listing contains only :batch (69, Batch-API-only, 404 on chat completions) and :free (16). Virtual suffixes you can append at request time: :online (web plugin), :nitro (sort by throughput + priority tier, billed at the serving tier), :floor (sort by price + flex tier), :exacto (quality-first provider sort, tool-calling models), :thinking (deprecated, use reasoning). Unknown suffixes are ignored (200) (probed).
  • ~ latest aliases: 10 ids like ~openai/gpt-mini-latest, ~anthropic/claude-fable-latest, ~google/gemini-flash-latest, ~x-ai/grok-latest with alias_target: {slug, name}; the response model is the target (probed). Show them as aliases of the target, not as separate models.
  • Meta-models openrouter/auto, openrouter/auto-beta (Auto Router: classifier + community spend ranking, cost_tier, allowed_models/excluded_models, session stickiness; billed at the chosen model's rate), openrouter/free (free router), openrouter/fusion, openrouter/pareto-code, openrouter/bodybuilder — all have pricing.prompt: "-1" (unknown) except openrouter/free ("0"), top_provider nulls, tokenizer: "Router". openrouter/auto tiny probe → served by deepseek/deepseek-v4-flash-0731 (AtlasCloud), cost $0.000066 (probed).

# 12. Model listing & pricing units (probed against GET /api/v1/models, 428 entries)

Top-level fields: id, canonical_slug, hugging_face_id (177 non-null), name, created, description, context_length, architecture{modality, input_modalities, output_modalities, tokenizer, instruct_type}, pricing{…}, top_provider{context_length, max_completion_tokens, is_moderated}, per_request_limits (null on all 428), supported_parameters, default_parameters, supported_voices (TTS only), knowledge_cutoff (183 non-null, YYYY-MM-DD), expiration_date (8 non-null), links{details}, benchmarks{artificial_analysis{intelligence_index, coding_index, agentic_index}, design_arena[]}, reasoning (302), alias_target (10).

Pricing unit: strings, USD per single token (per image / per request / per search for the non-token keys). openai/gpt-5.4-nano prompt: "0.0000002" → $0.20 / M ✓ (OpenAI list price), completion: "0.00000125" → $1.25/M ✓; anthropic/claude-haiku-4.5 "0.000001"/"0.000005" → $1 / $5 per M ✓, input_cache_read "0.0000001" → $0.10/M (0.1×) ✓, input_cache_write "0.00000125" (1.25×), input_cache_write_1h "0.000002" (2×) ✓. Cross-check with the probes: haiku 20 prompt + 12 completion tokens → cost 0.00008 = 20×1e-6 + 12×5e-6 ✓. per million = Number(str) * 1_000_000. "-1" = unknown (routers), "0" = free. Keys seen: prompt, completion, input_cache_read, input_cache_write, input_cache_write_1h, image (31 models, per input image), image_output (per generated image), audio, audio_output, input_audio_cache, internal_reasoning (32 Google models: per reasoning token), web_search (160 models: per search call), request (none non-zero), discount (all 0), overrides[]. overrides = tiered pricing: [{min_prompt_tokens: 272000, prompt, completion, input_cache_read, input_cache_write}] (OpenAI ≥272k → 2×; Gemini Pro / Grok ≥200k → 2×) — the long-context threshold PolyLLM's xAI catalog already models.

Docs list more pricing keys (image_token, discount fraction, overrides with utc_start/utc_end/utc_days) and tokenizer enum (Router, Media, Other, GPT, Claude, Gemini, Gemma, Grok, Cohere, Nova, Qwen, Yi, DeepSeek, Mistral, Llama2/3/4, PaLM, RWKV, Qwen3), instruct_type (null on 388).

supported_parameters enum and frequency: max_tokens 417, response_format 368, tools 361, tool_choice 353, structured_outputs 345, temperature 343, top_p 325, seed 324, include_reasoning 301, reasoning 301, stop 299, frequency_penalty 232, presence_penalty 225, top_k 213, reasoning_effort 160, repetition_penalty 151, logprobs 147, top_logprobs 147, logit_bias 142, min_p 115, max_completion_tokens 60, verbosity 21, web_search_options 17, top_a 11, prediction 11, parallel_tool_calls 9. Models with no max_tokens: 11 (openai/gpt-5.2-codex, gpt-5.1-codex*, gpt-5.2-chat, gpt-3.5-turbo-0613, sakana/*, routers) — still accept it in practice; treat as "no cap control".

Free: 19 models with pricing.prompt === "0" (16 :free + google/lyria-3-* + openrouter/free). Expiration: 8 non-null (nex-agi/nex-n2-* 2026-09-08 = today, expired, dots-studio/dots-3-note-preview:free 2026-09-30, z-ai/glm-4.5* 2026-12-31, and 2098-12-31 placeholders on z-ai/glm-5-turbo/5v-turbo/5.3-flash).

# Mapping /api/v1/models → PolyModel

Reference implementation: research/openrouter/gen-models-json.ts (generated docs/provider-research/openrouter.models.json).

PolyModel field Source Rule
id id keep the full slug incl. variant (vendor/slug[:variant]); baseId = id.split(":")[0], variant = after ":"
displayName name e.g. "OpenAI: GPT-6 Astra" (already vendor-prefixed; strip "<Vendor>: " if the UI groups by vendor)
family / vendor id.split("/")[0] openai, anthropic, google, x-ai, deepseek, …; openrouter/* = meta
canonicalSlug canonical_slug dated slug (openai/gpt-6-astra-20260903) — useful for dedup/alias detection; ~ aliases: use alias_target.slug
contextTokens top_provider.context_length ?? context_length integers; routers have top_provider nulls → context_length
maxOutputTokens top_provider.max_completion_tokens null on routers → treat as unknown (no cap UI)
pricing.inputPerMillion Number(pricing.prompt) * 1e6 "-1" → null/unknown; "0" → free
pricing.outputPerMillion Number(pricing.completion) * 1e6 same
pricing.cachedInputPerMillion pricing.input_cache_read * 1e6 present on OpenAI/Anthropic/Google/xAI/DeepSeek
pricing.cacheWritePerMillion (+ 1h) input_cache_write, input_cache_write_1h Anthropic/Gemini/Qwen only
pricing.internalReasoningPerMillion internal_reasoning * 1e6 Google only (reasoning tokens billed separately)
pricing.imageInputPerImage pricing.image USD per image (some Google rows equal the token price — display "per image" only if > 0.0001 else treat as per-token)
pricing.imageOutputPerImage pricing.image_output image-gen models
pricing.webSearchPerRequest pricing.web_search native search fee; plugin fee is not in the listing ($0.007 Exa)
pricing.longContext* pricing.overrides[0] min_prompt_tokens threshold + overridden prices
capabilities.text output_modalities ∋ "text" all 428 today
capabilities.vision input_modalities ∋ "image" router pre-checks; text-only → 404
capabilities.files input_modalities ∋ "file" native PDF; otherwise PDF only via file-parser plugin
capabilities.audioInput / video ∋ "audio" / ∋ "video"
capabilities.imageGeneration / audioOutput output_modalities ∋ "image" / ∋ "audio" hide from chat picker (or separate section)
capabilities.tools supported_parameters ∋ "tools" tool_choice separately
capabilities.structuredOutput ∋ "structured_outputs" (strict json_schema) ; ∋ "response_format" = json_object at least send provider.require_parameters: true with json_schema
capabilities.reasoning reasoning != null plus reasoning.mandatory, default_enabled, supported_efforts, default_effort, supports_max_tokens (§8)
capabilities.webSearch always available via plugins/:online native search: ∋ "web_search_options"
capabilities.streaming true for all
parameters.temperature/topP/topK/minP/topA/seed/stop/frequencyPenalty/presencePenalty/repetitionPenalty/logprobs/logitBias/verbosity/parallelToolCalls supported_parameters ∋ name drop anything not listed (filterSettings)
parameters.maxTokens `∋ "max_tokens"
parameters.reasoningEffort reasoning.supported_efforts if reasoning present but no efforts → toggle only (reasoning.enabled / reasoning.max_tokens)
parameters.thinkingBudget reasoning.supports_max_tokens (or vendor ∈ {anthropic, google}) reasoning.max_tokens
parameters.defaults default_parameters non-null values seed the sliders
status derived expired if expiration_date <= today && < 2090; free if :free/prompt "0"; meta for openrouter/*; batch-only for :batch; else active
knowledgeCutoff knowledge_cutoff optional display
aliases ids whose alias_target.slug === id ~vendor/x-latest
moderated top_provider.is_moderated 125 models (OpenAI/Anthropic first-party) — informational
benchmarks benchmarks.artificial_analysis optional sort key

Which models to hide (default chat picker):

  1. output_modalities without "text" (none today, but filter anyway) and models whose output includes "image" or "audio" (image/audio generators: 15) unless PolyLLM supports those outputs — or show them in a separate "Image / Audio" group.
  2. Embeddings / rerank / speech / transcription models — they do not appear in the default /models (text output) listing; never fetch with output_modalities= filters other than text.
  3. expiration_date ≤ today (nex-agi/nex-n2-* today) — treat 2098-12-31 as "no expiration"; show a "retiring on …" badge when within 30 days.
  4. :batch variants (69) — chat completions returns 404; and pricing.prompt === "-1" routers except openrouter/auto (offer openrouter/auto explicitly as "Auto (OpenRouter)" with cost = unknown until the response usage.cost).
  5. ~ aliases — fold into the target as an alias badge rather than a duplicate row.
  6. Optional: :free models behind a "show free models" toggle (rate-limited 20 rpm / 50–1000 rpd, possible training on prompts) and openrouter/free.
  7. Prefer GET /models/user (account-filtered) when calling with the user's key; fall back to /models (works unauthenticated).

Registry refresh: cache the listing per day (models appear/disappear daily; total_count and created help diff). Keep supported_parameters per model — it is the only way to know what to send.

# 13. Prompt caching & provider-side state

  • Automatic for OpenAI (probed: second call with a 4.9k-token system prompt → cached_tokens: 4864, cost $0.000999 → $0.000124), DeepSeek, Grok (128 cached tokens on every call), Groq, Moonshot, Z.AI (docs). Read multipliers: OpenAI 0.25–0.5×, DeepSeek 0.1×, Grok 0.25×.
  • Explicit breakpoints for Anthropic / Gemini / Qwen: content-part cache_control: {type:"ephemeral", ttl?: "5m"|"1h"} (probed on haiku: first call cache_write_tokens: 5208 cost $0.006543, second call cached_tokens: 5208 cost $0.000554 — 0.1× read, 1.25× write). Minimums 1 024–4 096 tokens depending on model (docs). Gemini 2.5+ also has implicit caching (supports_implicit_caching per endpoint).
  • Usage fields: prompt_tokens_details.cached_tokens, cache_write_tokens; cost reflects the discount; /generation shows native_tokens_cached.
  • No server-side state: Responses API rejects store/previous_response_id; nothing to clean up. OpenRouter itself logs metadata (and prompts only if the user enabled logging in their account settings); provider.data_collection: "deny" / zdr: true restrict upstream retention.

# 14. Errors, rate limits, retries

Error body: {"error": {"code": <number>, "message": string, "metadata"?: {…}}, "user_id"?: string} — code is the HTTP status (number, not a string); OpenAI SDK surfaces it as e.status / e.error.

Case (probed) HTTP body
Invalid key (chat, /key) 401 {"error":{"message":"User not found.","code":401}}
No Authorization header 401 {"error":{"message":"No cookie auth credentials found","code":401}}
Unknown model 400 {"error":{"message":"openai/gpt-99-turbo is not a valid model ID","code":400},"user_id":"user_…"} (streaming: same, before any SSE)
Unknown variant suffix 200 ignored, base model served
Missing/invalid messages, malformed JSON 400 "Input required: specify \"prompt\" or \"messages\""
Out-of-range param 400 "Expected temperature to be at most 2, received 7", metadata.provider_name: null
require_parameters unsatisfiable 404 "No endpoints found that can handle the requested parameters.", metadata.routing_funnel[], failed_routing_step: "Filter by Parameters"
Image to text-only model 404 "No endpoints found that support image input", failed_routing_step: "Filter by Image Support"
:batch model on chat 404 "This model is only available through the Batch API…"
Reasoning disabled on mandatory model 400 "Reasoning is mandatory for this endpoint and cannot be disabled."
Provider-side validation (bad image bytes) 400 "Provider returned error", metadata.{raw (provider JSON as string), provider_name:"Azure", provider_error_code:"invalid_value", is_byok}
Prompt + max_tokens > context 400 "This endpoint's maximum context length is 400000 tokens. However, you requested about …"
Responses API store: true 400 {"error":{"code":"invalid_prompt","message":"Invalid Responses API request"},"metadata":{"raw":"[zod issues]"}} (note: string code here)
Insufficient credits / key limit 402 (docs) add credits or raise key limit; free models also 402 when balance negative
Moderation / guardrail 403 (docs) metadata.{reasons[], flagged_input (≤100 chars), provider_name, model_slug}
Rate limit 429 (docs) Retry-After, X-RateLimit-Limit/Remaining; mid-stream → SSE event with finish_reason: "error"
Provider down / bad upstream response 502 (docs) OpenRouter may have already retried other providers silently
No provider matches routing prefs 503 (docs) Retry-After
Also documented 408, 413, 422, 500, 524 (edge timeout), 529 (provider overloaded) canonical metadata.error_type values: context_length_exceeded, max_tokens_exceeded, authentication, permission_denied, payment_required, rate_limit_exceeded, provider_overloaded, provider_unavailable, invalid_request, invalid_prompt, not_found, content_policy_violation, refusal, invalid_image, image_too_large, image_too_small, unsupported_image_format, server, timeout, unmapped…

Rate limits (docs): paid models have no platform request caps (only DDoS protection + upstream limits); :free models 20 req/min and 50 req/day (<$10 lifetime credits) or 1 000 req/day (≥$10). Negative balance → 402 everywhere. GET /key gives limit, limit_remaining, is_free_tier for a pre-flight check.

Retry/timeout recommendation for the adapter: retry 429/502/503/524/529 with jittered backoff honouring Retry-After; never retry 400/401/402/403/404; map 401 "User not found." to invalid credentials, 402 to insufficient credits (link to openrouter.ai/credits), 404 "No endpoints found…" to a user-facing "this model/provider combination doesn't support " and automatically retry once without provider.require_parameters when it was set for soft preferences. Long read timeout (≥ 5 min) — xhigh/max efforts on frontier models can think for minutes; the : OPENROUTER PROCESSING comments (roughly every few seconds) are the liveness signal.

# 15. Lifecycle / aliases

  • Vendor ids on OpenRouter differ from the vendors' own: anthropic/claude-haiku-4.5 (dot, not -4-5), x-ai/grok-4.20 (not grok-4.20-0309-non-reasoning; there is no non-reasoning slug — the same id switches modes via reasoning), google/gemini-3.5-flash-lite, openai/gpt-5.4-nano, deepseek/deepseek-v4-flash (+ dated -0731). Always take ids from the listing.
  • canonical_slug carries the dated snapshot (openai/gpt-5.4-nano-20260317); /generation reports model with that dated slug.
  • Retired/renamed slugs redirect silently (docs: anthropic/claude-3-5-sonnet → claude-3.5-sonnet); unknown slugs 400. Variant suffixes never 400.
  • expiration_date is the only deprecation signal in the API; poll daily.
  • :thinking and :online are deprecated suffixes (docs) — prefer reasoning and plugins:[{id:"web"}].

# 16. Exact streaming code that worked

ts
import OpenAI from "openai";
const client = new OpenAI({
  apiKey: process.env.OPENROUTER_API_KEY, baseURL: "https://openrouter.ai/api/v1",
  timeout: 600_000, maxRetries: 0,
  defaultHeaders: { "HTTP-Referer": "https://www.polyllm.io", "X-OpenRouter-Title": "PolyLLM" },
});

const stream = await client.chat.completions.create({
  model: "anthropic/claude-haiku-4.5",
  messages: [{ role: "user", content: "What's the weather in Montreal? Use the tool." }],
  tools, stream: true, max_tokens: 1500,
  reasoning: { max_tokens: 1024 },                 // Anthropic: budget, not effort
  provider: { require_parameters: false },         // true only for json_schema
} as any, { headers: { "x-session-id": sessionId } });

const details: any[] = []; const calls: Record<number, any> = {};
for await (const chunk of stream as any) {
  if (chunk.error) throw Object.assign(new Error(chunk.error.message), chunk.error);   // mid-stream error, finish_reason "error"
  const c = chunk.choices?.[0]; const d = c?.delta ?? {};
  if (d.reasoning) onThinking(d.reasoning);
  if (d.reasoning_details) details.push(...d.reasoning_details);     // merge by (type,index), replay on the assistant message
  if (d.content) onText(d.content);
  if (d.annotations) onCitations(d.annotations);                     // url_citation
  for (const tc of d.tool_calls ?? []) accumulate(calls, tc);        // fragmented arguments
  if (c?.finish_reason) onFinish(c.finish_reason, c.native_finish_reason);   // arrives twice (2nd with usage)
  if (chunk.usage) onUsage(chunk.usage, chunk.provider, chunk.model);        // usage.cost = USD
}

Raw fetch SSE (research/openrouter/lib.ts → rawSSE): split on \n\n, ignore lines starting with : (keep-alives), take data: lines, JSON.parse, stop at [DONE].

# 17. Probe results table

# Probe Model(s) Result
00 GET /models (+ ?limit=1000, /models/user, /models/count, ?supported_parameters=tools), /key, /credits — 428 models, no pagination needed; user list 425; 345 tool models; /key and /credits 200 with a normal key
01a tiny completion, usage.include 5 models all 200; provider field; usage.cost + cost_details; completion_tokens includes reasoning; DeepSeek/xAI reasoning on by default (xAI only because we sent effort low), Gemini signature-only reasoning_details
01b streaming raw SSE 5 models : OPENROUTER PROCESSING comments (0–8); delta.reasoning + delta.reasoning_details; finish chunk twice, last one with usage; [DONE]
02 37 param variants × 5 models 5 models unsupported params silently dropped; 404 No endpoints found… with require_parameters; Gemini effort: none → 400 mandatory; Haiku effort no-op / max_tokens works; n:2 → 1 choice; DeepSeek max_tokens 40 eaten by reasoning
03 streamed function call + round trip (OpenAI SDK) 5 models fragmented tool_calls deltas (2–8 chunks); finish_reason: tool_calls; round 2 OK on all
04 json_schema strict + require_parameters, streamed once 5 models valid JSON on 4; DeepSeek content: null (length, reasoning consumed 200 tokens); streaming partial JSON valid
05 32×32 PNG data URL 5 models 4 vision models OK (21–269 prompt tokens); DeepSeek 404 No endpoints found that support image input
06 invalid key / no auth / unknown model & variant / malformed / bad temp / bad image / overflow / truncation / /generation — 401 User not found.; 400 not a valid model ID; variant ignored; 400 with metadata.raw from Azure; finish_reason: length; /generation 404 right away, 200 later
07 :online (gpt-5.4-nano), plugins:[{id:"web",max_results:3}] streamed (haiku), openrouter/auto, ~openai/gpt-mini-latest mixed 8.5k search tokens + reasoning ate max_tokens (content null); haiku: 3 url_citation annotations in one delta, +$0.007; auto → deepseek-v4-flash-0731; alias → gpt-5.4-mini
08 Responses API (basic, store:true, previous_response_id, stream), /endpoints, /endpoints/zdr, /providers, X-Title vs X-OpenRouter-Title, :batch, cache_control (Anthropic) + automatic caching (OpenAI), reasoning.max_tokens on Anthropic/Gemini mixed stateless 400s; 9 event types; 845 ZDR endpoints; 106 providers; both headers OK; :batch 404; cache write 5208 → read 5208 (0.1×); Anthropic reasoning.text + signature
09 Anthropic/Gemini thinking + tools streamed, reasoning_details replayed vs omitted; stream without usage flag; stream_options.include_usage; /generation later; /models vs /models/user haiku, gemini-lite, nano both round-2 variants 200; usage always present; generation record 200 with native token counts; 3 models hidden by account prefs

# Documentation pages used (all fetched 2026-09-08)