dendrux
v0.2.0a1 · alphaGet started

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.

SituationWhat happens
Tools passed, model advertises toolsRuns normally
Tools passed, model lacks toolsRaises with an explicit error naming the model and pointing at tool-capable alternatives
Tools passed, model lacks tools, require_native_tools=FalseWarns once, proceeds
No tools passedGuard never invoked — any model, zero friction
Catalog fetch fails or model slug unknownWarns, proceeds — a metadata hiccup never breaks a run

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 the ValueError.
  • agent.stream() reports it in-band as a run_error event 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:

FieldMeaning
idModel slug, e.g. deepseek/deepseek-chat
nameHuman-readable name
context_lengthMax context window in tokens
supported_parametersRequest params the model supports (tools, temperature, …)
prompt_price / completion_priceUSD per token; 0.0 for free models, None when unknown or variable
input_modalities / output_modalitiestext, image, audio, …
supports_toolsProperty: "tools" in supported_parameters
is_freeProperty: both prices are zero
is_multimodalProperty: accepts any non-text input

Two guarantees worth designing around:

  1. 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.
  2. Free variants are separate slugs (e.g. meta-llama/llama-3.3-70b-instruct:free) with their own supported_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 schema

Run 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.costcost_usd — what OpenRouter charged your account for the call, in USD (the same unit as the per-token prices list_models() reports).
  • prompt_tokens_details.cache_write_tokenscache_creation_input_tokens, alongside the cached_tokenscache_read_input_tokens mapping 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=Falsereasoning={"enabled": False} — OpenRouter's disable directive. (Verified against live models: effort="none" does not disable reasoning on Qwen3, and exclude still bills reasoning tokens — enabled: false is 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 with call.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.

  • effort is the cross-vendor knob (low/medium/high/xhigh; "extra"xhigh); OpenRouter also accepts minimal/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 control

For 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 exposed

Across 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.