OpenRouter — capability study and how Heatwave uses it
Studied 2026-08-11 against the live docs (https://openrouter.ai/docs/llms.txt)
while evaluating open-weight models (GLM, Qwen, Kimi) for the Daily Focus
briefing workload. OpenRouter is wired at the RubyLLM level
(config.openrouter_api_key); a model is callable from app code once it has
Assistant::ChatService::MODELS + AiModelConstants entries — the opt-in
test fleet (glm-5.2, qwen3.7-plus, kimi-k2.6) is registered that way.
The one-paragraph mental model
One OpenAI-shaped endpoint (POST /api/v1/chat/completions, also /messages
Anthropic-shape and /responses) in front of ~500 models across ~60 hosting
providers. Requests carry OpenRouter-only extensions: models (fallback
array), provider (routing preferences), session_id, service_tier,
plugins. Responses carry exact billing (usage.cost, native-tokenizer token
counts, cache details) on every request — no polling needed. Everything else
below is layered on that pipe.
Routing
- Provider routing (
provider: {...}):order,only,ignore
(provider allow/deny),allow_fallbacks,require_parameters(route only
to hosts supporting every param you send — set this when sending
tools, otherwise a host that ignores tools is eligible),
quantizations(fp8/bf16… filter — open-weight hosts differ!),sort
(price/throughput/latency),max_price(hard $/Mtok ceiling),
data_collection: 'deny',zdr: true. - Suffix shorthands:
:nitro= sort throughput,:floor= sort price.
Variants also exist as:free,:extended,:thinking,:online. - Auto Exacto — on by default for every request that includes tools.
Replaces price-weighted routing with quality-first provider ordering built
from live tool-call schema validation error rates (InvalidJson /
UnknownName / SchemaMismatch per request), throughput, and a rolling
benchmark harness (GPQA Diamond + Tau2-Bench Airline). Setting
sort: 'price'or:flooropts out — don't, on agentic workloads. - Model fallbacks (
models: [...]): ordered array tried on any error
(rate limit, downtime, context overflow, moderation). Billed at whichever
model actually served. The/messagesshape calls itfallbacks
(max 3, model-only entries).modelwins overmodels[0]— verified
live in both directions (model: glm, models: [qwen]served GLM;
model: qwen, models: [glm]served Qwen), so the array is a true fallback
list and never a router that quietly demotes the model you asked for.
response.modelnames whoever actually served, which is what RubyLLM
records on the usage ledger (model: response.model || @model.id) — so a
server-side failover is attributed and priced as the model that ran, not
the one we requested. - Auto router (
openrouter/auto): a lightweight classifier assigns each
prompt one of ~30 task types, then ranks candidates by real community spend
over a trailing 7-day window;cost_tierpicks a price band. Billed at
the chosen model's rate. Inspect decisions viaX-OpenRouter-Metadata: enabled. Not for the briefing (we need a pinned model per test), but fine
for low-stakes internal one-offs.
Caching — two distinct layers
- Prompt caching (provider-side prefix cache): automatic on OpenAI, Grok,
Groq, DeepSeek, Moonshot, Z.AI (GLM); explicit Anthropic-style
cache_controlbreakpoints on Anthropic, Qwen (5-min TTL, writes
1.25×, reads 0.1×), Gemini. Read discounts: DeepSeek/Qwen 0.1×, Z.AI
~0.2×, Moonshot/Gemini/Grok 0.25×. OpenRouter translates Anthropic-style
markers to the target provider's dialect. Sticky routing: after a cache
hit, follow-up requests pin to the same host for 5 min; pass a
session_id(body orx-session-id) to pin immediately — this is why
multi-step plan runs (briefings) should always send one. Cache telemetry
comes back inusage.prompt_tokens_details(cached_tokens,
cache_write_tokens). - Response caching (OpenRouter-side, whole-response): SHA-256 of the
normalized body per API key; identical request → free replay (0 tokens
billed, own generation id). Off by default; enable per request with
X-OpenRouter-Cache: true(+X-OpenRouter-Cache-TTL, default 300s, max
24h). Useless for briefings (every prompt unique), handy for repeated
identical utility calls. Disabled entirely under account-level ZDR.
Privacy — the part that matters for briefings
Briefing prompts carry customer PII, and open-weight models route to
third-party hosts with wildly different retention policies (unverifiable
policies are assumed worst-case: retains + trains).
provider: {data_collection: 'deny'}— exclude hosts that may train on or
collect prompts. Filters on the host's own declared policy; unverifiable
policies are assumed worst-case.provider: {zdr: true}— restrict to Zero-Data-Retention endpoints
(GET /api/v1/endpoints/zdr, public, no auth). Same commitment, but as an
attested, enumerable list rather than a self-declaration. Implicit prompt
caching stays eligible (in-memory ≠ retention).
Both are on for every Heatwave OpenRouter call (decided 2026-08-12). ZDR is
the one that binds; data_collection stays because it costs nothing and the
two filter on different evidence. Measured the day we adopted it, counting only
hosts that also declare tool_choice (we send require_parameters):
| Model | Endpoints | ZDR + tool_choice |
|---|---|---|
z-ai/glm-5.2 (briefing) |
32 | 21 |
moonshotai/kimi-k2.6 |
21 | 15 |
qwen/qwen3.7-plus |
1 | 0 |
The cheapest ZDR GLM endpoint bills $0.50/$3.15 — the same floor we already
price against — so the briefing lost nothing. Qwen did: it publishes a single
endpoint and it isn't zero-retention, which is why the glm-5.2 models array
fails over to Kimi rather than to the A/B runner-up. Re-check the list before
adding an OpenRouter model — hosts move on and off it, and an all-non-ZDR
model is simply unroutable for us now.
- OpenRouter itself does not retain prompts unless prompt logging is opted
into. Account-wide ZDR toggles exist per model group; per-requestzdr
can only add enforcement, never disable account settings. - Guardrails (account feature) can enforce ZDR per API key and add
sensitive-info detection / prompt-injection regex screens. - EU-only routing exists (
eu.openrouter.ai, enterprise, by request).
Billing & observability
- Exact cost on every response:
usage.cost(credits actually charged),
cost_details.upstream_inference_cost, cached/reasoning token splits,
native-tokenizer counts. Beats static pricing tables — when we productize
an OpenRouter model,Ai::CostCalculatorshould prefer response-reported
cost over its own arithmetic (no pricing-table drift). Post-hoc:
GET /api/v1/generation?id=.... - Zero completion insurance: empty/errored completions aren't billed.
- Workspaces + budgets: per-workspace spending caps with automatic
enforcement — the platform-side twin of ourai_costguards. - Custom classifiers (workspace feature, dashboard-configured): a cheap
model asynchronously tags every generation against a taxonomy you define
(≤8 dimensions — department, task type, complexity presets); zero request
latency, results in Activity log rollups. We already attribute internally
viaai_usage_logs.feature, so this is redundant for us unless we want
OpenRouter-side rollups without shipping metadata. - Broadcast: zero-code trace export (prompts, completions, tokens, cost,
latency, tool usage) to Datadog, ClickHouse, OpenTelemetry Collector,
Langfuse, S3, webhooks…; per-destination sampling and a Privacy Mode that
strips prompt/completion bodies. Relevant to the AppSignal→HyperDX
migration: HyperDX ingests OTLP, so OpenRouter traffic could stream into
it with no app-side instrumentation. Free (no OpenRouter fee documented).
Batch API (beta)
POST /api/beta/batches — inline JSON array of requests (no JSONL upload;
endpoint and model fields must serialize before requests), 24h
window, results inline on poll, ~50% off token pricing, text-only, any
model. Single-shot only — no tool loops, so it cannot run the briefing
pipeline; it fits bulk translations, classification/backfill jobs, and
embeddings — anywhere we currently loop a worker over rows calling a model
once per row.
Everything else, one line each
- Presets (
@preset/slug): server-side named configs (model, provider
rules, system prompt, tools), versioned, shallow-merged under the request.
We keep model config in code/Setting— adopting presets would split the
source of truth; skip. - Service tiers (
service_tier: flex|priority): flex ≈ 50% off at
higher latency — OpenAI/Google/xAI models only, so irrelevant to the
open-weight tests; flex never falls back to standard (capacity errors
surface). - Server tools: OpenRouter-operated web search/fetch, shell,
apply-patch, subagents. We have our own tool layer; unused. - Plugins:
web,file-parser(PDF),response-healing(repairs
truncated/malformed JSON output),context-compression. Response-healing
is worth remembering for structured-output jobs on small models. - Structured outputs:
response_format: json_schemawithstrict—
pair withprovider.require_parametersso only schema-capable hosts are
eligible. - Multimodal/media APIs: image/video/TTS/STT generation endpoints exist;
out of scope here. - Embeddings:
/v1/embeddingsshape supported (incl. in batch). - API-key management API + OAuth PKCE: programmatic key provisioning —
useful if we ever hand per-agent scoped keys. userfield: stable end-user id for abuse detection and trace
grouping. Unused here — populating it puts an internal identifier in a body
that routes to third-party hosts, so it needs the same privacy call as any
other PII in the prompt, not a default-on.
How this maps onto RubyLLM (our stack)
RubyLLM::Chat exposes everything needed — no gem changes:
| OpenRouter feature | RubyLLM call |
|---|---|
Body extensions (provider, models, session_id, service_tier) |
with_provider_options(...) |
Headers (X-OpenRouter-Cache*, x-session-id, X-OpenRouter-Metadata) |
with_headers(...) |
| Prompt-cache breakpoints | with_caching (provider module renders cache_control) |
| Client-side fallback chain | with_fallbacks (or server-side via models in provider options) |
The provider module (ruby_llm/providers/openrouter/chat.rb) already maps
reasoning params and strips strict from schemas where hosts reject it.
with_provider_options REPLACES, it does not merge (@provider_options = provider_options.to_h). Anything set later in a configuration sequence — output
limits, generationConfig — wipes the routing prefs set earlier, and the
request still succeeds, just unrouted and unpinned. Read the current hash back
off the chat and merge (Assistant::ChatService#merge_provider_options!); a
test that exercises one setter against a fresh chat cannot see this.
Two failure layers, and why both
models: (above) fails over between models inside OpenRouter — same request,
no extra round trip, but useless when OpenRouter itself is the thing that's
down. RubyLLM::Chat#with_fallbacks is the outer layer: it retries the turn on
a different provider entirely, on the transient error classes in
RubyLLM::Fallback::DEFAULT_ERRORS. A fallback swaps model, provider and
connection but keeps provider_options, so the OpenRouter body extensions
have to be cleared in a before_fallback hook or the non-OpenRouter provider
400s on provider / models / session_id.
require_parameters narrows the host pool — count before adding a param
Every parameter we send is a filter under require_parameters: true, and hosts
declare support unevenly. Measured Aug 2026 across the endpoints for our three
models: tool_choice is declared by GLM 31/32, Qwen 1/1, Kimi 20/21 — a cheap
filter worth keeping. parallel_tool_calls is declared by GLM 1/32, Qwen 0/1,
Kimi 1/21, so sending it emptied the pool outright and every request failed with
No endpoints found that can handle the requested parameters. That error names
the parameters, not the count, which reads like a credentials or privacy problem
— check parameter declarations before assuming either.
Recommended defaults for Heatwave OpenRouter calls
chat.(
provider: {
data_collection: 'deny', # PII never lands on a training host
zdr: true, # attested zero-retention endpoints only
require_parameters: true # tools/schema support is mandatory, not best-effort
},
session_id: "conv-#{conversation.id}" # sticky routing → cache hits across plan steps
)
Leave sort unset (preserves Auto Exacto's quality-first ordering on tool
requests). Record usage.cost when available instead of recomputing from
pricing tables.
Enforcement lives on the key, not the request
The request-level zdr flag ORs with account and guardrail settings — it can
only add enforcement, never relax it — so the durable control is a
Guardrail bound to the API key: a key that cannot route to a retaining host
regardless of what any caller sends. Keep sending the body flag anyway; it
still holds if a key is ever provisioned without the guardrail, and it states
the requirement at the call site.
Provisioned 2026-08-12 via the management API (management_api_key in
credentials; every call below needs it, not the inference key):
# the guardrail: all five ZDR model groups on, no budget fields
curl -X POST https://openrouter.ai/api/v1/guardrails \
-H "Authorization: Bearer $MANAGEMENT_KEY" -H 'Content-Type: application/json' \
-d '{"name":"Heatwave ZDR","workspace_id":"…","enforce_zdr_anthropic":true,
"enforce_zdr_openai":true,"enforce_zdr_google":true,
"enforce_zdr_xai":true,"enforce_zdr_other":true}'
# bind it to the key (note: snake_case body, camelCase in the SDK docs)
curl -X POST https://openrouter.ai/api/v1/guardrails/<id>/assignments/keys \
-H "Authorization: Bearer $MANAGEMENT_KEY" -H 'Content-Type: application/json' \
-d '{"key_hashes":["<key hash from GET /api/v1/keys>"]}'
Live state: guardrail f7a69395-870d-46d4-8663-181164d5b6fa, assigned to key
heatwave-production. enforce_zdr_other is the group that governs GLM/Kimi;
the four frontier groups are on as free insurance since we don't route
Anthropic/OpenAI/Google/xAI through OpenRouter at all.
Three things that cost time and will again:
- The key is in
Default Workspace, not theheatwaveworkspace, despite
credentials.openrouter.workspacereadingheatwave. A guardrail only binds
to keys in its own workspace — assignment 400s with "Some keys do not belong
to the same workspace" otherwise. CheckGET /api/v1/keys→workspace_id
before creating one. - Assignment lives at
/guardrails/<id>/assignments/keys, not
/guardrails/<id>/keys(404). The flat "all assignments" listing is
/guardrails/assignments/keys. GET /api/v1/keyskeeps reportingguardrail_id: nulleven when bound.
The assignment listing is the source of truth, not that field.
Verify enforcement by asking for a model whose endpoints are all non-ZDR —
qwen/qwen3.7-plus is the live example, and it now returns 404 "No endpoints
available matching your guardrail restrictions and data policy" on our key
while z-ai/glm-5.2 serves normally. Budgets were deliberately left null:
a spend cap on this guardrail would silently kill the morning briefing.
Not covered by ZDR: plugins and OpenRouter-operated server tools (web
search, file-parser). Enforcement applies to inference provider routing only.
We use neither, and adopting one would need its own retention review.