llm-router
v1.4.28One front door + provider protocol in front of every LLM provider.
Install
iii trigger compose::add worker=llm-router@1.4.28Run 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 and where each credential comes from (credential_source, credential_ref, credential_error — never a value). |
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:// 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: store the key in the
secrets worker and put a versionable reference in the slice,
"api_key": "secret://ANTHROPIC_API_KEY" (provider keys reuse their env var
name; NAME matches ^[A-Za-z_][A-Za-z0-9_.-]{0,127}$). The secret's
consumers must list llm-router.
A provider's credential is the first of:
- the slice's
credentialobject (OAuth write-back viaupdate_credential); - a literal
api_key; - an
api_keyreference, 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, 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:// reference, when the slice uses one) 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://.
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. 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 keys in the entry. A key present only in another worker's environment shows up asconfigured: false. - 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:// 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. - Resolve credentials per request via
router::provider::resolve; never read keys directly. A response withcredential_source: "secret"and no credential is an unresolvablesecret://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.
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 and secret:// 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).