The AI gateway
Everything on the platform that reasons with a model goes through one module:
apps/api/llm/gateway/. Workflow nodes, job steps, the publishing composers and
the digest editorial pass all ask it for a binding and use that; none of them
resolves a channel, builds a client, or creates a usage collector of its own.
That is the whole reason it exists. A rule about AI — which model is the default, what happens when a provider is rate-limited, what a run costs — is written here once and is then true everywhere, which twelve separate call sites could never promise.
Asking for AI
Section titled “Asking for AI”A caller names a purpose, not a connector scheme:
const ai = await gateway.resolve({ purpose: 'enrich', scope, channelId });if (!ai.ok) return halt(ai.message, ai.fix);
const text = await ai.binding.chat({ messages });const usage = ai.binding.usage(); // one entry per provider that servedAiPurpose is one of enrich, classify, compose, digest, agent,
triage, implement. The purpose decides which capability channel carries the
credentials (llm:// for the content-facing work, dashboard:// for the
board-facing work), which feature the ledger records, and whether the call may
fail over. That table is purposePolicy.ts.
The AiBinding also answers asLlmClient() and asReasoner(), so the
prompt-shaped collaborators (IContentEnricher, ITriageDecider, the composers)
take a plain port and inherit failover and metering without knowing either
exists.
How a channel is chosen
Section titled “How a channel is chosen”resolve() builds an ordered chain, not a single lookup:
- Inline credentials, when the caller already holds them. The dispatch drafting path is why: a run bound to a channel carries that channel’s LLM in its own runConfig, and it should draft on that key.
- A pinned channel (
llmChannelId). A stale pin resolves to nothing and stops there — a deleted pin fails loudly rather than quietly switching keys. - The purpose’s own capability channel. This is where the managed LLM appears for a user who has connected nothing of their own.
- The other capability channel, so someone who connected one of the two is not refused by a step that happens to prefer the other.
- A different provider, for failover — see below.
Default when nothing names a model and no channel is connected: whatever the
platform’s own lend is configured to (MANAGED_LLM_PROVIDER /
MANAGED_LLM_MODEL, the managed LLM). There is no
code-level default beyond that — with the lend unconfigured and nothing
connected, resolve() finds no candidate at all and the caller gets a
halt, not a silent fallback model.
Failover
Section titled “Failover”The default provider is Anthropic; the fallback is Cline
(FALLBACK_PROVIDER). A failed call moves to the next candidate when the failure
is one another credential could survive:
| Failure | Fails over? |
|---|---|
rate_limited (429) | yes |
unavailable (5xx, timeout, dropped connection) | yes |
credit_exhausted (empty balance) | yes |
auth (401/403) | yes — the next candidate is a different key |
invalid_request (400) | no |
invalid_request is the exclusion that matters: a malformed request fails
identically everywhere, so failing it over spends a second key to arrive at the
same error. Note that a 400 whose body mentions credit or billing is classified
as credit_exhausted, not invalid_request — several providers report an empty
account that way.
Two more rules:
- Model ids are translated, never guessed. Handing Cline the direct
Anthropic id
claude-haiku-4-5is a 400; Cline spells that modelanthropic/claude-haiku-4.5. The catalog carries a provider-neutraltierandequivalentModel()does the mapping. No mapping means the candidate is skipped. - A tool loop only fails over before its first tool call. The agent’s tools
are real side effects —
create_taskwrites a card — so replaying the loop on another provider would replay them. A provider that dies on the first model call is safe to retry; one that dies on step nine is not, and the honest outcome is the partial trace.
What it costs
Section titled “What it costs”Every call that happens is charged, at the real rate of the model that served
it, plus the standard 20% margin (CREDIT_MARKUP).
-
Per-model rates.
llmPricing.tsprices from the model actually used — exact rate first, size class second, a conservative floor for a model it cannot place. An unlisted Opus id never prices as the cheap one. -
Per-provider attribution. A call that failed over was paid for at two providers’ rates, so
binding.usage()returns one entry per provider rather than one blended row. -
Failures are charged too. A call that dies mid-generation consumed tokens the provider bills; so did an agent loop that ran eleven steps and threw on the twelfth. Where a provider reports nothing, the usage is estimated from the text we know we exchanged and the debit is flagged
estimatedin the ledger, so an inferred number is never mistaken for a measured one. -
The platform’s key runs the platform’s model. On the managed lend a caller’s model override is refused: the lend is a cheap default to get started on, not an open tab. The refusal is reported as a
model_refusednotice rather than applied silently, because a run that says it succeeded while the named model never ran sends someone to debug a prompt that was never the problem. -
One exception: a role. A caller may name a
role—planner,workerorreader— and the managed key will run the model that role is bound to (ROLE_MODELSinplatform-domain). This is safe precisely because it runs in the opposite direction from the refusal above: a role asks for a cheaper model, not a better one, and the reader tier exists to carry token volume at a fraction of the planner’s price. The allowance is a fixed table lookup, never an id taken on trust, so the managed key still runs only models the platform chose. Naming any other model is refused as before, and a role only supplies a model for its own provider — against another provider’s key it is ignored, since the id would not resolve there.A role is orthogonal to a purpose: a purpose says where the credentials come from, a role says which model does the work. One
agentpurpose serves a Main agent and all three squad roles, which is why the role is the caller’s to state.
Providers
Section titled “Providers”Two, deliberately: Anthropic direct (the default) and Cline (the gateway, and the fallback). OpenRouter was a third and was retired on 2026-08-30 with zero connected channels in production — one Cline key already routes to Anthropic, Google and OpenAI models alike, so it added no coverage while doubling the credential surface and the cost-attribution matrix.
Adding a provider is still an adapter plus a catalog entry, not a change to any step. See Extend the platform.
