TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1# OpenRouter — provider research for PolyLLM23Last documentation audit: **2026-09-08**4Probes 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.56> 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".78---910## 1. Base URL, auth, headers1112| Item | Value |13|---|---|14| REST base URL | `https://openrouter.ai/api/v1` |15| Auth | `Authorization: Bearer <OPENROUTER_API_KEY>` (keys look like `sk-or-v1-…`) |16| 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). |17| 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-…`). |18| 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}}`. |19| 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". |20| 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. |2122## 2. SDK recommendation (TypeScript / Node)2324- 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).25- **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.26- The `models` array / `route` fallback fields of the old docs are now "deprecated — use provider.sort.partition" (API reference).2728## 3. Endpoints2930| Endpoint | Status | Notes |31|---|---|---|32| `POST /api/v1/chat/completions` | **primary** | OpenAI-compatible + OpenRouter extras. **(probed)** |33| `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. |34| `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)**. |35| `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. |36| `GET /api/v1/models/count` | active | `{"data":{"count":428}}` **(probed)** |37| `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. |38| `GET /api/v1/endpoints/zdr` | active | 845 zero-data-retention endpoints **(probed)** |39| `GET /api/v1/providers` | active | 106 providers with `slug, privacy_policy_url, terms_of_service_url, status_page_url, headquarters` **(probed)** |40| `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).** |41| `GET /api/v1/key`, `GET /api/v1/credits` | active | §1 |42| `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)**. |43| `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). |4445## 4. Chat Completions request/response (probed shapes)4647Request 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).4849Unknown top-level params (`foo_bar`) are **silently ignored** **(probed)**. Unsupported-but-known params are **silently dropped** unless `provider.require_parameters: true` (§9).5051Non-streaming response **(probed, x-ai/grok-4.20 with `reasoning.effort: low`)**:5253```json54{55 "id": "gen-1788849315-…", "object": "chat.completion", "created": 1788849315,56 "model": "x-ai/grok-4.20", "provider": "xAI", "system_fingerprint": null, "service_tier": "default",57 "choices": [{ "index": 0, "logprobs": null, "finish_reason": "stop", "native_finish_reason": "completed",58 "message": { "role": "assistant", "content": "2 + 2 equals 4.", "refusal": null,59 "reasoning": "The question is: \"What is 2+2? …\"\n",60 "reasoning_details": [61 { "type": "reasoning.summary", "summary": "The question is: …", "format": "xai-responses-v1", "index": 0 },62 { "type": "reasoning.encrypted", "data": "Tux7gDG5T2…", "format": "xai-responses-v1", "index": 1 } ] } }],63 "usage": { "prompt_tokens": 199, "completion_tokens": 156, "total_tokens": 355,64 "cost": 0.00050435, "is_byok": false,65 "prompt_tokens_details": { "cached_tokens": 128, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0 },66 "cost_details": { "upstream_inference_cost": 0.00050435, "upstream_inference_prompt_cost": 0.00011435, "upstream_inference_completions_cost": 0.00039 },67 "completion_tokens_details": { "reasoning_tokens": 148, "image_tokens": 0, "audio_tokens": 0 } }68}69```7071- **`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.72- **`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)**.73- **`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.74- **`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`).**75- `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)**.76- `message.reasoning` may be `null` while `reasoning_details` is present (Gemini returns only an encrypted signature, no text) **(probed)**.77- Hidden prompt overhead: xAI models carry ≈ 196 prompt tokens (128 cached) on an empty request, DeepSeek ≈ 90 via some providers, others ~7–20 **(probed)**.7879## 5. Streaming protocol **(probed)**8081SSE, `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).8283```json84data: {"id":"gen-…","object":"chat.completion.chunk","created":1788849322,"model":"x-ai/grok-4.20","provider":"xAI",85 "choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":"The",86 "reasoning_details":[{"type":"reasoning.summary","summary":"The","format":"xai-responses-v1","index":0}]},87 "finish_reason":null,"native_finish_reason":null}]}88…89data: {"…","choices":[{"index":0,"delta":{"content":"Bonjour"},"finish_reason":null,"native_finish_reason":null}]}90data: {"…","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"completed"}]}91data: {"…","service_tier":"default","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"completed"}],92 "usage":{"prompt_tokens":196,"completion_tokens":380,"total_tokens":576,"cost":0.0010606,"is_byok":false,93 "prompt_tokens_details":{"cached_tokens":128,…},"cost_details":{…},"completion_tokens_details":{"reasoning_tokens":378,…}}}94data: [DONE]95```9697- Delta keys observed: `role`, `content`, `reasoning` (plain text), `reasoning_details[]` (fragments), `tool_calls`, `annotations` (web search). Every chunk carries `provider` and `model`.98- **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)**.99- **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`).100- **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`.101- Cancellation: aborting the connection stops billing on OpenAI, Anthropic, Fireworks, Together, Cohere and ~20 others; **not** on Groq, Google, Mistral, HuggingFace (docs).102103## 6. Tool / function calling **(probed on all 5 models)**104105- 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)**.106- 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).107- Round 2 with `assistant.tool_calls` + `{role:"tool", tool_call_id, content}` worked on all 5 (`finish_reason: stop`).108- **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.109- Find tool-capable models: `supported_parameters` contains `tools` (361/428) / `tool_choice` (353). Endpoint-level `supports_tool_choice` in `/endpoints`.110- Docs also mention server tools (`openrouter:web_search`, `stop_server_tools_when`) and `:exacto` (quality-first provider sort for tool reliability).111112## 7. Structured output **(probed)**113114- `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)**.115- `{type:"json_object"}` works on all 5, but Anthropic wrapped it in a ```json fence **(probed)** → strip fences when parsing json_object output.116- 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.117- Extra formats exist in the schema (`grammar` GBNF, `python`) and a `response-healing` plugin for non-streaming JSON repair (docs).118119## 8. Reasoning controls120121Request: `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).122123Listing 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).124125| Model **(probed)** | listing `reasoning` | observed |126|---|---|---|127| 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) |128| 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. |129| 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) |130| 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` |131| 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`) |132133**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.134135## 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`)**136137| Param | gpt-5.4-nano | claude-haiku-4.5 | gemini-3.5-flash-lite | deepseek-v4-flash | grok-4.20 |138|---|---|---|---|---|---|139| `temperature` | ✓ dropped (not L) | ✓ L | ✓ L | ✓ L | ✓ L |140| `top_p` | ✓ dropped | ✓ L | ✓ L | ✓ L | ✓ L |141| `top_k` | ✓ dropped / **404** rp | ✓ L | ✓ dropped / **404** rp | ✓ L | ✓ dropped / **404** rp |142| `min_p`, `top_a` | dropped | dropped | dropped | ✓ L | dropped |143| `seed` | ✓ L | dropped / **404** rp | ✓ L | ✓ L | ✓ L |144| `stop` | dropped / **404** rp | ✓ L | ✓ L | ✓ L | dropped / **404** rp |145| `frequency_penalty` / `presence_penalty` | dropped / **404** rp | dropped / **404** rp | dropped / **404** rp | ✓ L | dropped / **404** rp |146| `repetition_penalty` | dropped / 404 rp | dropped / 404 rp | dropped / 404 rp | ✓ L | dropped / 404 rp |147| `logprobs` + `top_logprobs` | dropped / 404 rp | dropped / 404 rp | dropped / 404 rp | ✓ L | ✓ L (`logprobs: null` returned) |148| `max_tokens` / `max_completion_tokens` | ✓ / ✓ | ✓ / ✓ | ✓ / ✓ (not L) | ✓ / ✓ | ✓ / ✓ (not L) |149| `n: 2` | 200, 1 choice | 200, 1 choice | 200, 1 choice | 200, 1 choice | 200, 1 choice |150| `response_format` json_object / json_schema | ✓ / ✓ | ✓ (fenced) / ✓ | ✓ / ✓ | ✓ / ✓ (needs big max_tokens) | ✓ / ✓ |151| `tools` / `tool_choice: required` / `parallel_tool_calls` | ✓ | ✓ | ✓ | ✓ | ✓ |152| `developer` role | ✓ | ✓ | ✓ | ✓ | ✓ |153| `verbosity: low` | 200 (not L) | 200 | 200 | 200 | 200 |154| unknown `foo_bar` | ignored | ignored | ignored | ignored | ignored |155156Default 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`).157158**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 `null`s); `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.159160## 10. Modalities, context, output limits, files, images, web search161162- **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).163- **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).164- **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.165- **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.166- **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)**.167- **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).168- **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.169170## 11. Provider routing, variants, meta-models171172- `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`).173- **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)**.174- **`~` 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.175- **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)**.176177## 12. Model listing & pricing units **(probed against `GET /api/v1/models`, 428 entries)**178179Top-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)`.180181**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.182183Docs 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).184185`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".186187Free: 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).188189## Mapping `/api/v1/models` → `PolyModel`190191Reference implementation: `research/openrouter/gen-models-json.ts` (generated `docs/provider-research/openrouter.models.json`).192193| PolyModel field | Source | Rule |194|---|---|---|195| `id` | `id` | keep the full slug incl. variant (`vendor/slug[:variant]`); `baseId = id.split(":")[0]`, `variant = after ":"` |196| `displayName` | `name` | e.g. `"OpenAI: GPT-6 Astra"` (already vendor-prefixed; strip `"<Vendor>: "` if the UI groups by vendor) |197| `family` / vendor | `id.split("/")[0]` | `openai, anthropic, google, x-ai, deepseek, …`; `openrouter/*` = meta |198| `canonicalSlug` | `canonical_slug` | dated slug (`openai/gpt-6-astra-20260903`) — useful for dedup/alias detection; `~` aliases: use `alias_target.slug` |199| `contextTokens` | `top_provider.context_length ?? context_length` | integers; routers have `top_provider` nulls → `context_length` |200| `maxOutputTokens` | `top_provider.max_completion_tokens` | null on routers → treat as unknown (no cap UI) |201| `pricing.inputPerMillion` | `Number(pricing.prompt) * 1e6` | `"-1"` → null/unknown; `"0"` → free |202| `pricing.outputPerMillion` | `Number(pricing.completion) * 1e6` | same |203| `pricing.cachedInputPerMillion` | `pricing.input_cache_read * 1e6` | present on OpenAI/Anthropic/Google/xAI/DeepSeek |204| `pricing.cacheWritePerMillion` (+ `1h`) | `input_cache_write`, `input_cache_write_1h` | Anthropic/Gemini/Qwen only |205| `pricing.internalReasoningPerMillion` | `internal_reasoning * 1e6` | Google only (reasoning tokens billed separately) |206| `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) |207| `pricing.imageOutputPerImage` | `pricing.image_output` | image-gen models |208| `pricing.webSearchPerRequest` | `pricing.web_search` | native search fee; plugin fee is not in the listing ($0.007 Exa) |209| `pricing.longContext*` | `pricing.overrides[0]` | `min_prompt_tokens` threshold + overridden prices |210| `capabilities.text` | `output_modalities ∋ "text"` | all 428 today |211| `capabilities.vision` | `input_modalities ∋ "image"` | router pre-checks; text-only → 404 |212| `capabilities.files` | `input_modalities ∋ "file"` | native PDF; otherwise PDF only via `file-parser` plugin |213| `capabilities.audioInput` / `video` | `∋ "audio"` / `∋ "video"` | |214| `capabilities.imageGeneration` / `audioOutput` | `output_modalities ∋ "image"` / `∋ "audio"` | hide from chat picker (or separate section) |215| `capabilities.tools` | `supported_parameters ∋ "tools"` | `tool_choice` separately |216| `capabilities.structuredOutput` | `∋ "structured_outputs"` (strict json_schema) ; `∋ "response_format"` = json_object at least | send `provider.require_parameters: true` with json_schema |217| `capabilities.reasoning` | `reasoning != null` | plus `reasoning.mandatory`, `default_enabled`, `supported_efforts`, `default_effort`, `supports_max_tokens` (§8) |218| `capabilities.webSearch` | always available via `plugins`/`:online` | native search: `∋ "web_search_options"` |219| `capabilities.streaming` | true for all | |220| `parameters.temperature/topP/topK/minP/topA/seed/stop/frequencyPenalty/presencePenalty/repetitionPenalty/logprobs/logitBias/verbosity/parallelToolCalls` | `supported_parameters ∋ name` | drop anything not listed (`filterSettings`) |221| `parameters.maxTokens` | `∋ "max_tokens" || ∋ "max_completion_tokens"` | always send `max_tokens` (broadest support) |222| `parameters.reasoningEffort` | `reasoning.supported_efforts` | if `reasoning` present but no efforts → toggle only (`reasoning.enabled` / `reasoning.max_tokens`) |223| `parameters.thinkingBudget` | `reasoning.supports_max_tokens` (or vendor ∈ {anthropic, google}) | `reasoning.max_tokens` |224| `parameters.defaults` | `default_parameters` | non-null values seed the sliders |225| `status` | derived | `expired` if `expiration_date <= today && < 2090`; `free` if `:free`/prompt `"0"`; `meta` for `openrouter/*`; `batch-only` for `:batch`; else `active` |226| `knowledgeCutoff` | `knowledge_cutoff` | optional display |227| `aliases` | ids whose `alias_target.slug === id` | `~vendor/x-latest` |228| `moderated` | `top_provider.is_moderated` | 125 models (OpenAI/Anthropic first-party) — informational |229| `benchmarks` | `benchmarks.artificial_analysis` | optional sort key |230231**Which models to hide (default chat picker):**2321. `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.2332. 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.2343. `expiration_date` ≤ today (`nex-agi/nex-n2-*` today) — treat `2098-12-31` as "no expiration"; show a "retiring on …" badge when within 30 days.2354. `: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`).2365. `~` aliases — fold into the target as an alias badge rather than a duplicate row.2376. Optional: `:free` models behind a "show free models" toggle (rate-limited 20 rpm / 50–1000 rpd, possible training on prompts) and `openrouter/free`.2387. Prefer `GET /models/user` (account-filtered) when calling with the user's key; fall back to `/models` (works unauthenticated).239240Registry 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.241242## 13. Prompt caching & provider-side state243244- **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×.245- **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).246- Usage fields: `prompt_tokens_details.cached_tokens`, `cache_write_tokens`; `cost` reflects the discount; `/generation` shows `native_tokens_cached`.247- **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.248249## 14. Errors, rate limits, retries250251Error 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`.252253| Case **(probed)** | HTTP | body |254|---|---|---|255| Invalid key (chat, `/key`) | **401** | `{"error":{"message":"User not found.","code":401}}` |256| No Authorization header | 401 | `{"error":{"message":"No cookie auth credentials found","code":401}}` |257| 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) |258| Unknown variant suffix | 200 | ignored, base model served |259| Missing/invalid `messages`, malformed JSON | 400 | `"Input required: specify \"prompt\" or \"messages\""` |260| Out-of-range param | 400 | `"Expected temperature to be at most 2, received 7"`, `metadata.provider_name: null` |261| `require_parameters` unsatisfiable | **404** | `"No endpoints found that can handle the requested parameters."`, `metadata.routing_funnel[]`, `failed_routing_step: "Filter by Parameters"` |262| Image to text-only model | 404 | `"No endpoints found that support image input"`, `failed_routing_step: "Filter by Image Support"` |263| `:batch` model on chat | 404 | `"This model is only available through the Batch API…"` |264| Reasoning disabled on mandatory model | 400 | `"Reasoning is mandatory for this endpoint and cannot be disabled."` |265| 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}` |266| Prompt + max_tokens > context | 400 | `"This endpoint's maximum context length is 400000 tokens. However, you requested about …"` |267| Responses API `store: true` | 400 | `{"error":{"code":"invalid_prompt","message":"Invalid Responses API request"},"metadata":{"raw":"[zod issues]"}}` (note: string code here) |268| Insufficient credits / key limit | 402 (docs) | add credits or raise key limit; free models also 402 when balance negative |269| Moderation / guardrail | 403 (docs) | `metadata.{reasons[], flagged_input (≤100 chars), provider_name, model_slug}` |270| Rate limit | 429 (docs) | `Retry-After`, `X-RateLimit-Limit/Remaining`; mid-stream → SSE event with `finish_reason: "error"` |271| Provider down / bad upstream response | 502 (docs) | OpenRouter may have already retried other providers silently |272| No provider matches routing prefs | 503 (docs) | `Retry-After` |273| 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…` |274275Rate 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.276277**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 <feature>" 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.278279## 15. Lifecycle / aliases280281- 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.282- `canonical_slug` carries the dated snapshot (`openai/gpt-5.4-nano-20260317`); `/generation` reports `model` with that dated slug.283- Retired/renamed slugs redirect silently (docs: `anthropic/claude-3-5-sonnet` → `claude-3.5-sonnet`); unknown slugs 400. Variant suffixes never 400.284- `expiration_date` is the only deprecation signal in the API; poll daily.285- `:thinking` and `:online` are deprecated suffixes (docs) — prefer `reasoning` and `plugins:[{id:"web"}]`.286287## 16. Exact streaming code that worked288289```ts290import OpenAI from "openai";291const client = new OpenAI({292 apiKey: process.env.OPENROUTER_API_KEY, baseURL: "https://openrouter.ai/api/v1",293 timeout: 600_000, maxRetries: 0,294 defaultHeaders: { "HTTP-Referer": "https://www.polyllm.io", "X-OpenRouter-Title": "PolyLLM" },295});296297const stream = await client.chat.completions.create({298 model: "anthropic/claude-haiku-4.5",299 messages: [{ role: "user", content: "What's the weather in Montreal? Use the tool." }],300 tools, stream: true, max_tokens: 1500,301 reasoning: { max_tokens: 1024 }, // Anthropic: budget, not effort302 provider: { require_parameters: false }, // true only for json_schema303} as any, { headers: { "x-session-id": sessionId } });304305const details: any[] = []; const calls: Record<number, any> = {};306for await (const chunk of stream as any) {307 if (chunk.error) throw Object.assign(new Error(chunk.error.message), chunk.error); // mid-stream error, finish_reason "error"308 const c = chunk.choices?.[0]; const d = c?.delta ?? {};309 if (d.reasoning) onThinking(d.reasoning);310 if (d.reasoning_details) details.push(...d.reasoning_details); // merge by (type,index), replay on the assistant message311 if (d.content) onText(d.content);312 if (d.annotations) onCitations(d.annotations); // url_citation313 for (const tc of d.tool_calls ?? []) accumulate(calls, tc); // fragmented arguments314 if (c?.finish_reason) onFinish(c.finish_reason, c.native_finish_reason); // arrives twice (2nd with usage)315 if (chunk.usage) onUsage(chunk.usage, chunk.provider, chunk.model); // usage.cost = USD316}317```318319Raw 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]`.320321## 17. Probe results table322323| # | Probe | Model(s) | Result |324|---|---|---|---|325| 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 |326| 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` |327| 01b | streaming raw SSE | 5 models | `: OPENROUTER PROCESSING` comments (0–8); `delta.reasoning` + `delta.reasoning_details`; finish chunk twice, last one with `usage`; `[DONE]` |328| 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 |329| 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 |330| 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 |331| 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` |332| 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 |333| 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 |334| 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 |335| 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 |336337## Documentation pages used (all fetched 2026-09-08)338339- 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)340- 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.md341- 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-tokens342- 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.md343- 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.md344- https://openrouter.ai/docs/guides/routing/model-variants/{free,nitro,online,exacto,thinking}.md345- https://openrouter.ai/docs/api_reference/responses/overview.md · https://openrouter.ai/docs/api_reference/responses/basic-usage.md346- 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.md347- 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).348