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 ishttps://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/sdkfor TypeScript…)". The OpenAI SDK withbaseURL: "https://openrouter.ai/api/v1"+defaultHeadersis still a documented, fully supported path ("drop-in replacement", community pageguides/community/openai-sdk), with OpenRouter-only fields passed throughextra_body/untyped properties.@openrouter/ai-sdk-providerexists 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 rawfetchSSE 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,providerfield) is untyped in the OpenAI SDK, so cast (as any) or use raw fetch. The OpenAI SDK's SSE parser already skips the: OPENROUTER PROCESSINGcomment lines. Only adopt@openrouter/sdkif we want typed access to those extras. - The
modelsarray /routefallback 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):
{
"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/autoresolve 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 asopenai/gpt-5.4-nano(probed).usage.cost= USD (credits) charged, always present (BYOK:is_byok: true,cost= OpenRouter fee only andcost_details.upstream_inference_cost= provider cost). Plugin fees are included incostbut not inupstream_inference_cost(web plugin: cost 0.007709 vs upstream 0.000709 → $0.007 Exa search fee) (probed). Useusage.costdirectly for the cost display — no client-side price math needed for OpenRouter.completion_tokensINCLUDESreasoning_tokens(DeepSeek: completion 24 = 8 visible + 16 reasoning; xAI: 156 = 8 + 148) (probed) — unlike xAI direct. Andmax_tokenscaps reasoning + visible text together on most providers: DeepSeek withmax_tokens: 40andreasoning.exclude: truereturnedcontent: null,finish_reason: "length", 40 reasoning tokens; gpt-5.4-nano:onlinewithmax_tokens: 200spent all 200 on reasoning →content: null(probed). Adapter rule: for reasoning-capable models never send a smallmax_tokens; default ≥ 2048 (ormax_tokens = reasoning.max_tokens + visible budget).finish_reasonnormalised tostop | length | tool_calls | content_filter | error(+function_callin the schema); raw value innative_finish_reason(completed,end_turn,STOP,stop,length…) (probed).message.reasoningmay benullwhilereasoning_detailsis 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).
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 carriesproviderandmodel. - The finish chunk is sent twice: once without
usage, then a final chunk withusagethat still haschoices:[{delta:{content:"",role:"assistant"},finish_reason:"stop"}](docs: deliberate deviation from OpenAI's emptychoices"to prevent client crashes"). Do not double-countfinish_reason; take usage from whichever chunk hasusage. Usage is included without any flag (probed). - Reasoning streams as
delta.reasoningtext for DeepSeek (raw thoughts), xAI (summary), Anthropic (thinking text when enabled viareasoning.max_tokens); Gemini streams onlyreasoning_detailswith an encryptedsignatureand no text; OpenAI gpt-5.4-nano streamed nothing (0 reasoning tokens on trivial prompts atlow). - 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. Handlefinish_reason: "error"+ top-levelerror. - 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_choiceauto|none|required|{type:"function",function:{name}};parallel_tool_calls(default true). Only 9/428 models listparallel_tool_callsinsupported_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, thenfinish_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_detailscame alongside the tool call (Anthropic:reasoning.textfragments + finalsignature,format: "anthropic-claude-v1"; Gemini: onereasoning.encryptedwhoseidequals the tool-call id,format: "google-gemini-v1"). Docs require passingreasoning_detailsback 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). Storereasoning_detailsin PolyLLM's message model. - Find tool-capable models:
supported_parameterscontainstools(361/428) /tool_choice(353). Endpoint-levelsupports_tool_choicein/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}}withprovider.require_parameters: true→ valid JSON on gpt-5.4-nano, claude-haiku-4.5, gemini-3.5-flash-lite, grok-4.20; DeepSeek returnedcontent: null(finish_reason: length, 200 reasoning tokens atemax_tokens: 200) — the usual reasoning-budget trap, not a schema failure. Withoutrequire_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_parametershasresponse_format(368) andstructured_outputs(345; = strict schema support). 52 models havetoolsbut notstructured_outputs. Docs: "the same model may be served by multiple providers, and only some… support structured outputs" → sendprovider: {require_parameters: true}wheneverresponse_format.type === "json_schema"; docs saytools,response_formatandverbosityare already "soft preferences" in routing. - Extra formats exist in the schema (
grammarGBNF,python) and aresponse-healingplugin 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.
10. Modalities, context, output limits, files, images, web search
- 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 behindoutput_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"withmetadata.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 carriesfile_annotations(parsed text + hash) to resend and avoid re-parsing (also inerror.metadata.file_annotationson errors). Models with"file"ininput_modalitiestake PDFs natively. - Audio input (docs):
{type:"input_audio", input_audio:{data:"<base64>", format:"wav"|"mp3"|…}}(base64 only);video_urlparts for video. Out of scope for v1. - Context / output:
context_length(never null in the listing) andtop_provider.max_completion_tokens(null on 6 meta/router models).top_provider.context_lengthcan differ fromcontext_length(e.g.z-ai/glm-5.3-flash1 310 720 vs 1 048 576) → usetop_provider.context_lengthwhen present (that's the endpoint that will serve you), fall back tocontext_length. Requestingmax_tokensabove 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 theopenrouter:web_searchserver tool", still works) orplugins: [{id:"web", max_results: 3, search_prompt?, engine?: "native"|"exa"|"firecrawl"|"parallel"|"perplexity", mode?, include_domains?, exclude_domains?}]. Results come asmessage.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 defaultsearch_promptasks for it). Cost: Exa $0.007 per request (10 results) + $0.001/extra result, added tousage.cost;usage.server_tool_use_details.web_search_requests: 1appears on the OpenAI-native path. The:onlinecall on gpt-5.4-nano injected 8 572 prompt tokens of results → budget ≥ 10k context and ≥ 1kmax_tokens. Native provider search viaweb_search_options: {search_context_size}on 17 models (pricing.web_searchper 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_configfor aspect ratio/size;pricing.image_outputper 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-image0.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 considerdata_collection: "deny"(orzdr: 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, usereasoning). 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-latestwithalias_target: {slug, name}; the responsemodelis 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 havepricing.prompt: "-1"(unknown) exceptopenrouter/free("0"),top_providernulls,tokenizer: "Router".openrouter/autotiny probe → served bydeepseek/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):
output_modalitieswithout"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.- Embeddings / rerank / speech / transcription models — they do not appear in the default
/models(text output) listing; never fetch withoutput_modalities=filters other than text. expiration_date≤ today (nex-agi/nex-n2-*today) — treat2098-12-31as "no expiration"; show a "retiring on …" badge when within 30 days.:batchvariants (69) — chat completions returns 404; andpricing.prompt === "-1"routers exceptopenrouter/auto(offeropenrouter/autoexplicitly as "Auto (OpenRouter)" with cost = unknown until the responseusage.cost).~aliases — fold into the target as an alias badge rather than a duplicate row.- Optional:
:freemodels behind a "show free models" toggle (rate-limited 20 rpm / 50–1000 rpd, possible training on prompts) andopenrouter/free. - 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 callcache_write_tokens: 5208cost $0.006543, second callcached_tokens: 5208cost $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_cachingper endpoint). - Usage fields:
prompt_tokens_details.cached_tokens,cache_write_tokens;costreflects the discount;/generationshowsnative_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: truerestrict 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(notgrok-4.20-0309-non-reasoning; there is no non-reasoning slug — the same id switches modes viareasoning),google/gemini-3.5-flash-lite,openai/gpt-5.4-nano,deepseek/deepseek-v4-flash(+ dated-0731). Always take ids from the listing. canonical_slugcarries the dated snapshot (openai/gpt-5.4-nano-20260317);/generationreportsmodelwith 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_dateis the only deprecation signal in the API; poll daily.:thinkingand:onlineare deprecated suffixes (docs) — preferreasoningandplugins:[{id:"web"}].
16. Exact streaming code that worked
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)
- https://openrouter.ai/docs/quickstart · https://openrouter.ai/docs/api-reference/overview · https://openrouter.ai/docs/api-reference/chat-completion · https://openrouter.ai/docs/llms.txt (index)
- https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties.md · https://openrouter.ai/docs/api/api-reference/api-keys/get-current-api-key.md · https://openrouter.ai/docs/api-reference/get-credits · https://openrouter.ai/docs/api_reference/parameters.md · https://openrouter.ai/docs/guides/overview/models.md
- https://openrouter.ai/docs/api-reference/streaming · https://openrouter.ai/docs/api-reference/errors · https://openrouter.ai/docs/api-reference/limits · https://openrouter.ai/docs/use-cases/usage-accounting · https://openrouter.ai/docs/use-cases/reasoning-tokens
- https://openrouter.ai/docs/guides/features/tool-calling.md · https://openrouter.ai/docs/features/structured-outputs · https://openrouter.ai/docs/features/images-and-pdfs · https://openrouter.ai/docs/guides/overview/multimodal/overview.md · https://openrouter.ai/docs/guides/overview/multimodal/pdfs.md · https://openrouter.ai/docs/guides/overview/multimodal/image-generation.md
- https://openrouter.ai/docs/features/web-search · https://openrouter.ai/docs/features/prompt-caching · https://openrouter.ai/docs/features/provider-routing · https://openrouter.ai/docs/features/model-routing · https://openrouter.ai/docs/guides/features/zdr.md
- https://openrouter.ai/docs/guides/routing/model-variants/{free,nitro,online,exacto,thinking}.md
- https://openrouter.ai/docs/api_reference/responses/overview.md · https://openrouter.ai/docs/api_reference/responses/basic-usage.md
- https://openrouter.ai/docs/client-sdks/typescript/overview.md (
@openrouter/sdk) · https://openrouter.ai/docs/guides/community/openai-sdk.md · https://openrouter.ai/docs/guides/community/vercel-ai-sdk.md - 404 at audit time:
/docs/api-reference/list-available-models,/docs/api-reference/get-current-api-key,/docs/features/tool-calling,/docs/api-reference/models/list-models,/docs/api-reference/api-keys/get-current-key,/docs/api-reference/responses/create-responses,/docs/sdks/typescript,/docs/guides/routing/model-variants(index page).