Run any OpenRouter catalog model through dendrux — native tool calling with an explicit capability guard, list_models() for building model pickers and cost tiers, and the same pause/resume machinery as every other provider.
OpenRouter — open-source and premium models
OpenRouterProvider gives your agents OpenRouter's aggregated catalog — DeepSeek, Llama, Qwen, Mistral, Gemini and hundreds more — through one API key. It is a thin preset over OpenAIProvider (OpenRouter speaks the Chat Completions wire format), so streaming, native tool calling, usage capture, pause/resume, and the evidence layer all work unchanged.
from dendrux import Agent
from dendrux.llm.openrouter import OpenRouterProvider
# OPENROUTER_API_KEY in the environment
agent = Agent(
provider=OpenRouterProvider(model="deepseek/deepseek-chat"),
prompt="…",
tools=[…],
)
# or the recipe string — provider built for you
agent = Agent(provider="openrouter:deepseek/deepseek-chat", prompt="…")Install: the provider rides the OpenAI transport, so pip install "dendrux[openrouter]" (an alias of dendrux[openai]).
The one idea
A model that can't do native tool calling doesn't error — it silently ignores your tools.
It returns plain text, tool_calls comes back empty, and the run "succeeds" with an answer that never used your tools. You cannot detect this from a response: a capable model that simply chose not to call a tool this turn looks identical. OpenRouter's catalog mixes both kinds of model, so dendrux guards on the capability fact instead — OpenRouter's per-model supported_parameters metadata.
The native-tools guard
The guard fires only when tools are actually passed. Tool-free use of any catalog model is unrestricted.
The catalog is fetched lazily on the first tools-bearing call and cached per process.
How the error surfaces differs by API, but both are loud:
agent.run()re-raises theValueError.agent.stream()reports it in-band as arun_errorevent carrying the same message — streams surface failures as events, not exceptions.
dendrux also sends OpenRouter's provider.require_parameters routing flag by default, so requests only route to upstreams that honor the parameters you sent. The same model slug fans out to multiple upstream hosts with different tool fidelity; without this, an upstream can silently drop your tools.
list_models() — build on the catalog
The catalog is queryable as typed, immutable snapshots — no model needed, since discovery isn't tied to a chosen one. There are no filter kwargs either; every field is a filter axis, composed with plain Python:
async with OpenRouterProvider() as provider: # discovery-only: no model required
models = await provider.list_models()
free_tool_models = [m for m in models if m.is_free and m.supports_tools]
text_only = [m for m in models if not m.is_multimodal]
vision = [m for m in models if "image" in m.input_modalities]
cheap_big_ctx = [
m for m in models
if m.supports_tools
and (m.context_length or 0) >= 128_000
and m.prompt_price is not None and m.prompt_price < 1e-6
]OpenRouterModel fields and properties:
Two guarantees worth designing around:
- The list and the guard share one cache. A model picked from
list_models()cannot then fail the tools guard — they read the same data. - Free variants are separate slugs (e.g.
meta-llama/llama-3.3-70b-instruct:free) with their ownsupported_parameters. Some free routes have narrower capability than their paid siblings; per-slug data handles that correctly.
Caching is mechanism, not policy: list_models() serves from the per-process cache, list_models(refresh=True) forces a refetch, and it raises on fetch failure (unlike the guard's soft-warn — an explicit listing request deserves a real error). TTLs, persistence, and per-tenant caching are your application's concern.
What this unlocks: model-picker UIs that only offer models that will actually work, free-tier vs premium product plans driven by live pricing, cost-aware routing ("cheapest tool-capable model with 128k context"), and startup config validation that fails fast if a slug vanished from the catalog.
Persisting the catalog
list_models() fetches live and caches per process — deliberately nothing more. The catalog changes on OpenRouter's schedule, not yours, so most apps don't want a network round-trip on every model decision. The pattern: snapshot into your own store, refresh on a cadence you own, and read from the store at request time.
OpenRouterModel is a frozen dataclass of JSON-native fields, so it serializes without ceremony:
from dataclasses import asdict
async with OpenRouterProvider(model="deepseek/deepseek-chat") as provider:
for m in await provider.list_models(refresh=True):
await upsert_model_row(asdict(m)) # your table, your schemaRun that from anything that fires on a cadence — a weekly cron, a worker tick, a deploy hook. Between refreshes your "which model for this job?" logic reads stored rows and never touches the network:
rows = await load_tool_capable_models(min_context=128_000) # a SQL WHERE
model = cheapest(rows) # your ranking
agent = Agent(provider=f"openrouter:{model['id']}", prompt="…")Everything past the fetch is yours: the schema, the refresh cadence, evicting slugs that vanished, and — the load-bearing part — what "best" means. dendrux surfaces the capability axes (tools, context, price, modalities); ranking across them is a product decision it never makes for you.
Usage and cost accounting
OpenRouter returns real spend on every response — automatically, no request flag needed (the old usage: {include: true} opt-in is deprecated and always-on). dendrux maps it onto the same cross-provider UsageStats every provider reports, so cost is a first-class field, not something you reconstruct from a pricing table:
usage.cost→cost_usd— what OpenRouter charged your account for the call, in USD (the same unit as the per-token priceslist_models()reports).prompt_tokens_details.cache_write_tokens→cache_creation_input_tokens, alongside thecached_tokens→cache_read_input_tokensmapping every OpenAI-wire provider already gets.
Both stream and non-stream paths are covered — cost rides the final SSE chunk — and per-call values roll up into run-level totals through the normal accumulator, so RunStore and the dashboard show cost per run with nothing extra to wire:
resp = await agent.run("Summarize this thread.")
print(resp.usage.cost_usd) # e.g. 0.000123 (USD)
print(resp.usage.cache_creation_input_tokens) # tokens written to cache
# Run-level total, aggregated across every iteration:
run = await store.get_run(resp.run_id)
print(run.total_cost_usd)A model that reports no cost (a rare compatible backend) leaves cost_usd as None; a free model reporting 0.0 keeps the 0.0 — "didn't report" stays distinct from "reported zero." OpenRouter's cost_details.upstream_inference_cost (the underlying provider's cost, distinct from what OpenRouter billed you) isn't mapped to a first-class field in v1 — it's available on the raw response for anyone who needs the margin.
Reasoning controls
Reasoning-capable models (DeepSeek R1, Qwen3, GPT-5, …) reason by default on OpenRouter — so an app that omits reasoning config and shows "Think: Off" is misleading: the model still burns reasoning tokens. dendrux wires reasoning into its cross-vendor knobs so you send the right directive with one flag.
from dendrux.llm.openrouter import OpenRouterProvider
# Off — sends reasoning={"enabled": False}
OpenRouterProvider(model="qwen/qwen3-14b", thinking=False)
# On at a chosen depth — sends reasoning={"effort": "medium"}
OpenRouterProvider(model="qwen/qwen3-14b", thinking=True, effort="medium")thinking=False→reasoning={"enabled": False}— OpenRouter's disable directive. (Verified against live models:effort="none"does not disable reasoning on Qwen3, andexcludestill bills reasoning tokens —enabled: falseis the right one.) On a model whose metadata marks reasoning mandatory, this raises before the request.
Reliability caveat. A true zero depends on the model's OpenRouter upstream honoring the disable. Some Qwen3 hosts ignore the reasoning toggle and keep reasoning regardless — dendrux sends the correct directive, but can't force an upstream to obey it. When a hard zero matters, pin a compliant upstream:
extra_body={"provider": {"order": ["<host>"], "allow_fallbacks": False}}, and confirm withcall.reasoning_tokens(below).Recording stays honest either way: if a model reasons despite
thinking=False, those reasoning tokens — and any reasoning text it emits — are recorded as-is. dendrux reports what the model actually did (and billed), never what you asked for, so cost and observability stay accurate even when a directive is ignored.
effortis the cross-vendor knob (low/medium/high/xhigh;"extra"→xhigh); OpenRouter also acceptsminimal/none. It works even on budget-based models — OpenRouter cross-converts effort↔max_tokens.- Both are overridable per call.
Rendering Off / Low / Medium / High / Always-on
list_models() carries the metadata to build the control — and to hide "Off" when it isn't allowed:
for m in await provider.list_models():
caps = m.reasoning
caps.supported # accepts a reasoning parameter at all
caps.mandatory # reasoning can't be disabled → hide "Off"
caps.supported_efforts # ("high","medium","low",…); () ⇒ no effort selector
caps.default_effort # the model's default effort when on
caps.default_enabled # whether reasoning is on by default (bool | None)
caps.supports_max_tokens # takes a reasoning.max_tokens budget → show a budget controlFor a token budget instead of an effort level (Anthropic/Gemini/some Qwen), pass it straight through: extra_body={"reasoning": {"max_tokens": 2000}}.
Reasoning tokens vs. reasoning text
These are separate response data: reasoning_tokens counts internal reasoning the model billed; reasoning is a human-readable summary the provider chose to expose (often absent). dendrux surfaces both on the public LLM call, so an app can say precisely "reasoning happened, but no readable summary was recorded":
call = (await store.get_llm_calls(run_id))[-1]
call.reasoning_tokens # e.g. 773 — reasoning happened
call.reasoning # None — but no reasoning text was exposedAcross multi-step tool calls, dendrux preserves OpenRouter's reasoning_details and replays them on later turns (per OpenRouter's guidance), so the model keeps its reasoning context through a tool loop — and streamed reasoning arrives as reasoning_delta run events.
Attribution and routing extras
OpenRouterProvider(
model="meta-llama/llama-3.3-70b-instruct",
app_url="https://myapp.example", # HTTP-Referer — OpenRouter rankings
app_name="MyApp", # X-Title
extra_body={"provider": {"order": ["deepseek"]}}, # pin upstreams; merges over require_parameters
)extra_body passes any OpenRouter request extension; your provider entries merge over the default require_parameters: true.
Models without native tools
For now, non-tool models are text-only through dendrux — the guard makes that boundary explicit instead of silent. A prompt-based tool-call shim (tool definitions serialized into the system prompt, calls parsed from text) is a planned extension reached via require_native_tools=False. Most models people reach for through OpenRouter — DeepSeek, Llama 3.3, Qwen, Mistral Large, Gemini — support native tools today.
Reference example
examples/29_openrouter.py runs all of this against the live API: catalog filtering, a native tool round-trip, the recipe string, and the guard demo.