llm-router
v1.4.36One front door + provider protocol in front of every LLM provider.
Install
iii trigger compose::add worker=llm-routerRun in your project with the iii engine running. Exact versions are immutable and digest-pinned.
llm-router
One front door for every LLM provider. The router owns routing, the provider registry, credential resolution, the model catalog, streaming relay, retries, and a single failure contract — consumers call one chat surface and never talk to a provider directly.
llm-router is a standalone iii worker. Providers plug in as separate workers
at runtime through a self-registration protocol
(iii trigger compose::add worker=provider-); the router never compiles against a provider, and removing a
provider worker removes the provider.
Install
iii trigger compose::add worker=llm-routerQuickstart
A consumer streams a turn by creating an iii channel, handing the router the
channel's write endpoint, and reading frames from the read endpoint
while router::chat runs. Any SDK works; Node shown:
import { createChannel } from 'iii-sdk';
const { reader, writerRef } = await createChannel(iii);
reader.onMessage((frame) => {
const event = JSON.parse(frame); // AssistantMessageEvent
if (event.type === 'text_delta') process.stdout.write(event.delta);
});
const res = await iii.trigger('router::chat', {
writer_ref: writerRef, // direction "write"
model: 'claude-sonnet-4',
messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }], timestamp: Date.now() }],
}, { timeout_ms: 620_000 }); // outer timeout ≥ the router's 600s stream budget
// res: { ok, provider, model, stop_reason, usage }The streaming contract: every stream ends with exactly one terminal frame
(done or error). When the router has to kill a stream itself (idle
timeout, provider crash), it synthesizes the terminal frame and attaches the
partial content, so consumers never hang on a half-open stream.
Functions
Consumer surface
| Function | Purpose |
|---|---|
router::chat |
Stream a turn into the caller's channel; returns the turn summary. |
router::complete |
Non-streaming convenience over the same pipeline; returns the final message. |
router::abort |
Cancel an in-flight turn by request_id. |
router::route |
Read-only routing preview: {model, provider?} → {provider, candidates}, same rules and error codes as router::chat. Pin the result as the explicit provider on the chat call when you need the provider before streaming. |
router::models::list |
List catalog models, filterable by provider / capability / modality (chat by default, stt, tts, any). |
router::models::get |
Fetch one model record (null when unknown). |
router::models::supports |
Check one capability flag for one model. |
router::embed |
Batch text embeddings through the first embed-capable provider in the registry ({model?, provider?, input[]} → {provider, model, embeddings[][]}); providers without an embed surface are skipped. |
router::transcribe |
Speech to text: {model?, provider?, audio_base64, mime?, language?, prompt?} → {provider, model, text, segments[]?, language?, duration_secs?}. The provider comes from the named provider, else from the catalog owner of the stt model, else the first provider that declared one. |
router::speak |
Text to speech: {model?, provider?, text, voice?, format?, language?, speed?} → {provider, model, audio_base64, mime, voice?, duration_secs?}. Same resolution over tts models. |
router::count_tokens |
Count prompt tokens: {model, provider?, system_prompt?, tools?, messages} → {provider, model, tokens, estimator}, resolved with the same routing rules as router::chat and forwarded to provider::. Never runs the model and costs nothing; estimator is provider (metering API) or tiktoken (local tokenizer). A provider without the surface is a typed router/no_token_counter error, so callers can fall back to their own estimate. |
router::provider::list |
Registered providers with configured / available status, where each credential comes from (credential_source, credential_ref, credential_error — never a value), and each provider's starting point: default_model and default_thinking_level (see Default model). |
Only the read surface is agent-callable (router::models::list / get /
supports, router::provider::list); everything else is denied to in-run
agents — see Security model.
Error conventions
Errors use stable router/ identifiers. Streaming failures return an
error object with a user-facing message, the shared kind, retryable,
and an optional diagnostic detail. User interfaces should show message
and keep code / detail behind technical details; automation should branch
on code and retryable, never parse the prose.
The main recovery codes are router/provider_unavailable,
router/capacity_exceeded, router/stream_idle_timeout,
router/stream_incomplete, router/provider_auth_expired,
router/provider_rate_limited, router/context_overflow, and
router/provider_rejected.
Provider protocol
Token-gated after the first declare: the response to register carries a
registration token, and every later protocol call must present it.
| Function | Purpose |
|---|---|
router::provider::register |
Self-declaration at attach time; idempotent re-declare with the token. |
router::provider::resolve |
Per-request credential + endpoint resolution (config > secret:// / env:// reference > env > none, see Credentials). |
router::provider::update_credential |
Persist a refreshed credential (OAuth write-back). |
router::models::reconcile |
Replace the provider's catalog slice in one write. |
The provider worker itself exposes provider:: and, when
capable, provider:: (model discovery),
provider:: (prompt token counting),
provider:: (embeddings), and the speech pair
provider:: / provider::.
router::chat accepts an optional structured prompt next to the flat
system_prompt: system_sections (ordered {text, cache_boundary} blocks)
and cache_intent ({surface_digest}, the caller's sha256: identity for
the text before the boundary). A sections-only request is flattened into
system_prompt (sections joined with \n\n); when both are sent they must
agree byte for byte or the request is rejected as invalid_request. Both are
forwarded verbatim to provider:: alongside the flat string, so a
provider that knows nothing about sections keeps reading system_prompt.
Cache-aware providers put their prefix marker on the boundary block
(Anthropic, Claude Code) or derive a shared prompt_cache_key from the digest
(OpenAI, Codex) so every session on the same frozen prefix lands on one cache
entry. The digest is a routing hint, never evidence of a hit — read
usage.cache_read for that.
Configuration
All operator configuration lives in the engine's llm-router configuration
entry — no env vars, no config file. The entry schema is composed at runtime
from each registered provider's declaration. The router fetches the complete
entry at boot and keeps an in-memory snapshot synchronized by the
configuration:updated trigger; request handlers never poll
configuration::get.
{
"default_provider": "anthropic",
"providers": {
"anthropic": {
"api_key": "secret://ANTHROPIC_API_KEY",
"api_url": "https://api.anthropic.com/v1/messages",
"max_tokens": 8192
}
},
"routing_heuristics": [{ "pattern": "^gpt-", "provider": "openai" }],
"settings": {
"stream_timeout_ms": 600000,
"idle_timeout_ms": 120000,
"retry_max": 2,
"output_token_max": 32000
}
}| Setting | Default | Meaning |
|---|---|---|
stream_timeout_ms |
600000 |
Hard budget for one streamed turn. |
idle_timeout_ms |
120000 |
Max silence between provider frames before the attempt is cut. |
retry_max |
2 |
Retries per turn for retryable failures before the first forwarded frame (0–10). |
output_token_max |
unset | Optional ceiling on max_output_tokens. Unset forwards each model's own output ceiling, so providers run without an artificial limit; set it only to cap spend. |
Pasting a key into a provider's slice is the whole onboarding flow: the
router diffs the changed slice, debounces ~2 s, and kicks that provider's
provider:: discovery; discovered models land in the
catalog via router::models::reconcile and show up in router::models::list
within seconds — no restart.
Credentials and secret references
The configuration service's default filesystem adapter persists this entry at
./config/ (llm-router unless III_CONFIG_NAME overrides it),
a folder meant to be committed, so keep keys out of it: keep the key in the
secrets worker, which llm-router depends on, and put a versionable
reference in the slice:
"api_key": "secret://ANTHROPIC_API_KEY"— encrypted in the secrets worker's vault;"api_key": "env://ANTHROPIC_API_KEY"— the variable in the project's env file (.env, or the secrets worker'senv_filefor this namespace; else the secrets worker's environment), read by the secrets worker each time, so an edit to the file applies without restarting the router.
Provider keys reuse their env var name; NAME matches
^[A-Za-z_][A-Za-z0-9_.-]{0,127}$. Either way the secret's consumers must
list llm-router; the Console's key field does this for you.
A provider's credential is the first of:
- the slice's
credentialobject (OAuth write-back viaupdate_credential); - a literal
api_key; - an
api_keyreference (secret://orenv://), resolved throughsecrets::resolve; - the provider's
credential_env_var, read in the router's process; - none.
A reference is never forwarded to a provider as a key. When it does not
resolve, the provider is not configured, and the error says why: secret NAME not found in the secrets worker, environment variable NAME is not set in the project's .env or the secrets worker's environment, llm-router is not allowed to read secret NAME; add llm-router to the secret's consumers, or
secrets worker is not running. The env var is deliberately not used as a fallback past an
explicit reference: you chose the stored secret, so a missing one is reported
rather than silently replaced by a different key. Providers built on the
shared scaffold skip their own env fallback in that case too, and
router::chat fails before dispatch with router/not_configured and the same
reason.
router::provider::list entries and the router::provider::resolve response
carry credential_source (config | env | secret | none),
credential_ref (the secret:// or env:// reference, when the slice uses
one; credential_source is secret for both, as the secrets worker resolved
it) and
credential_error. All three are optional on the wire. The older source
field keeps its config | env | none values: a resolved reference reports
config there.
Resolved values live only in the router's memory: never in the entry, logs or traces, and a cached value is wiped when replaced. They are re-read:
- on
secrets::changed(the router binds it at boot; without a secrets worker the engine holds the binding until the type registers). A rotated, deleted or revoked secret then nudges every provider whose slice references it throughprovider::, so the provider drops its cached resolve, re-declares and refreshes its models;::on_router_ready - when a slice that references it changes, before the debounced
refresh_modelsfires, so a pasted reference is resolvable by the time discovery asks for it; - when the secrets worker registers after the router, so a router that booted first recovers without a restart;
- on demand after 5 minutes (10 seconds for a failure), bounding staleness if an event is ever missed. While the secrets worker is unreachable, a value already resolved keeps being served: secrets cannot rotate or be revoked while it is down.
The reference itself is not secret: it is committed, listed and logged. Never
paste a key after secret:// or env://.
Operational notes
- Secret references resolve as
llm-router. The secrets worker checks the router's engine-stamped worker identity against the secret'sconsumers, not the provider's. - Env-var credential fallback resolves in the router's process. Without
a reference, a provider's
credential_env_var(e.g.ANTHROPIC_API_KEY) is read by the llm-router binary, not by the provider worker — launch the router with those variables set, or put a reference in the entry. A key present only in another worker's environment shows up asconfigured: false.env://NAMEis different: the secrets worker reads it from the project's.env, so the router needs noenv_filefor it. - Registration-token recovery. Re-registering a provider id without its
original token is rejected (anti-takeover). If a provider durably lost its
token, delete the router's registry state (state scope
llm-router, keyregistry) and restart the affected providers to re-bind; pasted credentials in the configuration entry are unaffected.
Security model
The agent-callable surface is governed by the engine-wide permission policy
(repo-root iii-permissions.yaml), not a per-worker
file. In-run agents may only read the catalog and provider list:
- Allowed:
router::models::list,router::models::get,router::models::supports,router::provider::list. - Denied to agents: the chat/spend surface (
router::chat,router::complete,router::abort,router::route), the whole provider/credential protocol (router::provider::resolve/register/update_credential,router::models::reconcile), and directprovider::*calls.
Worker-to-worker calls bypass the agent gate, so the harness, context-manager,
and provider workers reach the full surface — only in-run agents are
restricted. Provider credentials live in the configuration entry, or behind a
secret:// or env:// reference in the secrets worker, and are never readable back
through any allowed function: router::provider::list reports where a
credential comes from, never its value.
Events
The router registers three custom trigger types and fans out to every bound handler. Bind with the standard two-step pattern; the handler receives the payload verbatim (no envelope).
| Trigger type | Fires when | Payload |
|---|---|---|
router::models::changed |
a provider reconciles its catalog slice | { "provider": " |
router::provider::changed |
the registry changes (declare / availability flip) | { "provider": " |
router::ready |
the router finishes booting; providers re-declare on it | {} |
The router itself binds the engine's configuration trigger (its entry) and,
when the optional secrets worker is installed, secrets::changed
(router::on_secret_changed).
iii.registerFunction('my-worker::on-models-changed', async (payload) => {
console.log('catalog changed:', payload); // { provider, count }
return {};
});
iii.registerTrigger({
type: 'router::models::changed',
function_id: 'my-worker::on-models-changed',
config: {},
});Writing a provider worker
A provider worker must:
- Register
provider::honouring the channel-writer contract: forward upstream output as::stream AssistantMessageEventframes into thewriter_refit receives, ending with one terminal frame. - Declare itself at startup via
router::provider::register— retrying with backoff until acknowledged (covers provider-before-router boot order) — and re-declare on therouter::readyevent after a router restart. The declaration may carryicon_svg: one self-contained, monochromedocument (squareviewBox, no fixed size, at most 32 KiB). The router echoes it inrouter::provider::listand the console paints it as acurrentColormask beside the provider's models; a missing or malformed mark falls back to the provider's initial. The declaration may also carrydefault_models, the provider's recommended starting models in preference order (the current mid-range model first, then its predecessors), anddefault_thinking_level, the level to pair with it; see Default model. - Resolve credentials per request via
router::provider::resolve; never read keys directly. A response withcredential_source: "secret"and no credential is an unresolvablesecret://orenv://reference: report itscredential_error, do not substitute a key of your own. - Treat closure of its stream channel as cancellation: abort the upstream request and stop writing frames.
- Map upstream failures to the shared
ErrorKindtaxonomy on itserrorframes. Transport retries (429 / 5xx / connect) are the router's job, not the provider's.
A provider may also register provider:: ({model, system_prompt?, tools?, messages} → {model, tokens, estimator}) to serve
router::count_tokens: an exact count from a provider metering API
(estimator: "provider") or a local tokenizer estimate (estimator: "tiktoken"). Providers without it simply make router::count_tokens return
a typed router/no_token_counter error for that provider.
Default model
router::provider::list reports one default_model per provider so that a
caller with no model choice of its own (a fresh harness::send naming only
a provider, a console opening its first chat) starts on something sensible.
It is resolved at read time against the provider's current catalog slice:
- the model variant first. A variant is the first all-letter id segment
after the family (
terraingpt-6.1-terra,sonnetinclaude-sonnet-5-5), ranked in the orderdefault_modelsfirst names it. Within a ranked variant the newest version in the slice wins, listed or not, so a newer Luna never beats a Terra when Terra is listed first. A model with a ranked variant counts even outside the first preference's family, as long as it has the samevendor/prefix, so Copilot ranks GPT and Claude models in one list; - ids with no ranked variant come last: the first id in the declared
default_modelsthe slice holds, else the slice model sharing the longest id prefix with the first preference, as long as they share the family (the id up to its first-:claude,gpt,codex/gpt). On a tie the highest version wins (gpt-4.1overgpt-4o), then the plain id over a dated snapshot or a-minivariant; - otherwise absent, and the caller keeps its previous behaviour.
A provider that declares no default_models (local model servers, speech
providers) never reports one. default_thinking_level is copied from the
declaration; absent means the caller should omit the level and let the
provider apply its own default.
A declaration may also carry context_overflow_hint: one or two plain
sentences a console shows under a context-overflow failure on one of the
provider's models, for causes compacting cannot fix (a self-hosted server
started with a small window, say), optionally with a link to the provider's
docs. router::provider::list reports it verbatim; absent means the console
shows only its generic steps.
A provider that works without a key (a local server started without one)
declares credential_optional: true. The router then reports it
configured when no credential resolves; a key that is set is still
resolved and sent. Absent or false means a key is required.
Thinking levels
router::chat takes thinking_level: off, minimal, low, medium,
high or xhigh. Each provider maps a level onto its own knob and warns
when a model cannot honour it. off asks for no reasoning at all and is
honoured only where the model's descriptor says supports_thinking_off: true (a native switch exists: OpenAI reasoning_effort: none on the
models that take it, DeepSeek and GLM thinking.type: disabled, Anthropic
Sonnet 5.5 between_tools). false means the model always reasons or the
provider has no switch; absent means the provider does not know. Consoles
offer, hide or disable the Off choice accordingly. A provider that cannot
switch reasoning off sends its lowest effort instead and reports a warning.
Speech providers
A speech provider is the same kind of worker with no chat stream: it
declares its models with a speech block and serves one or both speech
surfaces. router::models::list hides speech models from chat pickers
unless asked (modality: "stt" | "tts" | "any"), and router::chat
refuses them with a message that names the right door.
{ "id": "scribe-v1", "provider": "elevenlabs", "context_window": 0, "max_output_tokens": 0,
"speech": { "modality": "stt", "languages": ["en", "hi"], "streaming": true } }| Function | Request | Response |
|---|---|---|
provider:: |
{model?, audio_base64, mime, language?, prompt?} |
{model, text, segments[{text, start_secs?, end_secs?}]?, language?, duration_secs?} |
provider:: |
{model?, text, voice?, format, language?, speed?} |
{model, audio_base64, mime, voice?, duration_secs?} |
model arrives as the id the caller named, or null for the provider's
default. format is what the caller asked for (mp3, wav, pcm16,
opus); answer with the container you produced and say so in mime.
Credentials resolve through router::provider::resolve exactly as for
chat providers.
The first real provider implementing this protocol is
provider-anthropic/ — useful as a reference
implementation alongside the scripted provider in the integration tests.
provider-openai/ follows the same structure for the
OpenAI Chat Completions API (native structured output, reasoning_effort).
provider-opencode-go/ follows the same structure for the
OpenCode Go Chat Completions API (curated model metadata, reasoning_effort).
Local development & testing
cargo test # unit suite, no engine needed
cargo test --test integration # engine-backed suite; self-skips without an engineThe integration suite spawns a throwaway engine per test when iii is on
PATH (or III_ENGINE_BIN points at a binary) and covers the chat relay,
cancellation, abort, restart recovery, registration token gating, paste-a-key
discovery (literal keys, secret:// and env:// references, against a fake
secrets worker), and event delivery end to end.
To run the worker locally against an engine:
cargo run -- --url ws://127.0.0.1:49134--url defaults to ws://127.0.0.1:49134 and honours the III_WS_URL
environment variable when the flag is not set. --config is accepted per
the standard worker CLI but ignored with a warning — operator config lives
in the engine's llm-router configuration entry (see Configuration above).