LLM Gateway¶
The LLM gateway is an OpenAI-compatible HTTP surface that fronts the
in-process provider registry. It exists so a process outside the runtime can
point its client at a base_url and still route every call through
SynthOrg's governance: explicit (provider, model) binding, per-run cost
and prompt attribution, a hard token-budget kill, and SEC-1 log
redaction. Provider-agnosticism becomes a gateway property, not a
property of whatever dials it.
Nothing the product itself runs dispatches here: the agent loop reaches the
provider registry in-process. The gateway's live consumer is the recording
harness under evals/, which routes every completion of a measured run
through it precisely because that is the one place a run's real spend and its
prompts are both observable.
Boundary¶
There is no network-facing gateway in the base system: LiteLLMDriver
calls litellm.acompletion in-process. The gateway mounts a controller
(api/gateway/controller.py) on the existing Litestar app at
POST /gateway/v1/chat/completions, so the full mounted route (under the
app's /api/v1 prefix) is /api/v1/gateway/v1/chat/completions; the
harness's OpenAI base_url is the app address plus /api/v1/gateway/v1.
It is reachable only from the agent sandbox over the sidecar egress
allowlist, and is excluded from the session/bearer auth middleware
because it authenticates with its own per-run signed bearer.
That exclusion leaves scope["user"] unset, which the rate limiter would
otherwise read as anonymous: the tier sized for a stranger with an IP, which an
agent doing ordinary work spends in seconds before dying on a 429 from its own
control plane. api/rate_limits/tiers.py::bears_own_credential therefore puts
this route on the authenticated tier,
but only when the request actually presents a well-formed
Authorization: Bearer header. The path alone says where a request was aimed,
not who sent it, and the authenticated tier is far larger. Syntax is all the
throttle can check: verifying the signature is the handler's job and doing it
twice would put the signing key in the rate-limit path, so a forged-but-
well-formed header still reaches the larger bucket. It stays keyed by client IP
there (no user is bound on these routes), and every request in it still fails
the handler's verification.
Per-run signed bearer¶
The gateway mints one short-lived HMAC-SHA256 token per agent run
(llm/gateway_token.py) and hands it to the harness as its OpenAI
api_key. The token binds (execution_id, agent_id, task_id, project_id,
provider, model_id, cost_ceiling, currency) and an expiry, so the gateway
can enforce Explicit Provider Binding and the budget from the request
alone, with no server-side session table. The signer is shared
in-process: whatever mints a run token pulls the same GatewaySigner
instance the gateway verifies with, out of the gateway feature slice, so a
minted token is accepted without a second secret to keep in sync. A caller
holding an instance of its own gets a 401 on every request, which is the
defect the sharing exists to prevent.
Minting is the single enforcement point for Explicit Provider Binding
(llm/gateway_binding.py): a ModelRef with no bound provider raises
GatewayModelUnboundError rather than letting the gateway later auto-pick.
Request pipeline (order is load-bearing)¶
- Verify bearer into a claims object; reject expired/invalid
(
GatewayTokenInvalidError, 401). The raw token is never logged. - Explicit Provider Binding: dispatch is bound to the token's
(provider, model_id), resolved viaProviderRegistry.get(provider), never the request'smodelfield. A missing driver is a 503. - Hard token budget / kill: a per-run cost ledger
(
api/gateway/ledger.py) is checked pre-flight; once a run has spent its ceiling, further calls are refused (GatewayBudgetExhaustedError, 402, which the adapter maps toBUDGET_EXHAUSTED). The check is boundary-based per call, matching the native loop'srun_hard_ceilingsemantics (an in-flight call finishes). Theproviders.gateway_enabledmaster toggle short-circuits to 503. - Dispatch under cost scope:
provider.complete/provider.streamrun insidecost_recording_scope(purpose=None, call_category=PRODUCTIVE, ...). The gateway carries no single registered prompt purpose (a caller outside the runtime issues arbitrary prompts), sopurposeisNone: cost is attributed by run and call-category through the single provider chokepoint, not by prompt purpose. The response is translated back to the OpenAI shape withusageechoed for the adapter'sTurnRecords. - SEC-1 posture: fencing of untrusted upstream content is enforced at
the source, where the tool that fetched it knows what it is. At the
gateway we route every log through
safe_error_description/scrub_secret_tokens, run the shared injection heuristics over inbound content as an advisory signal, and set OTLP spans withrecord_exception=False.
Streaming uses text/event-stream; setup errors (token/binding/budget)
are surfaced as HTTP status codes by eagerly fetching the first frame,
never as a half-open stream.
Both shapes carry the model's reasoning on reasoning_content, the streaming
path per delta and the buffered path on the message. It rides its own key
rather than content because it is the model's working and not its answer, so
a harness folds it into the transcript only if it chooses to; and it is on both
shapes because otherwise whether a client can see reasoning at all depends on
whether it streams, which is a decision about transport rather than about the
model.
Token counts follow the same rule. A buffered response always carries usage;
a stream carries it when the client sets stream_options.include_usage, as a
terminal chunk with an empty choices list immediately before [DONE]. It is
conditional because that is the OpenAI contract: a client that did not ask
expects every chunk to carry a choice. What the gateway never does is invent
the numbers. When a client asks and the provider stream reports no usage, the
gateway sends no usage chunk and logs gateway.usage.unreported, because zeros
would tell the harness the call was free. The gateway's own ledger is fed from
the same provider event, so a client's accounting and the run's cost ceiling
are reading one measurement rather than two.
Settings¶
One, providers.gateway_enabled, re-read live per request so toggling it
takes effect on the next call.
A bearer's lifetime is not a setting. It has to outlive the longest run that
dispatches through the gateway, and the harness minting the bearer is the only
thing that knows how long that is, so it owns the value
(_BEARER_TTL_SECONDS in evals/harness/binding.py). An operator knob would
be a second answer to a question the caller has already settled, and a wrong
one fails a paid run part way through rather than at the ceiling that bounds
it.
gateway_enabled ships off. Nothing inside the product dispatches through
the gateway, so an enabled endpoint with no caller is surface for nothing; the
recording harness turns it on for the length of a run. The route carries no
ambient authority even when enabled, authenticating only with a per-run signed
bearer, but enabling it opens a path on which billed LLM calls can be made
from outside the runtime. The first stored true therefore routes through
the deliberate confirm+reason+actor guardrail in
settings/write_governance.py, unset counting as off, because for a
default-off capability the first enable is the transition that matters.
Wiring¶
The gateway is an auto-discovered feature (api/gateway/feature.py): the
construction wirer builds the signer, the run-cost ledger and the request
pipeline unconditionally and commits them to GatewayStateSlice; the
controller mounts whenever the pipeline is wired and 503s while disabled.
Reuse¶
ProviderRegistry / CompletionProvider, cost_recording_scope,
resolve_ref_provider (Explicit Provider Binding),
wrap_untrusted / safe_error_description / scrub_secret_tokens.
Convention gate¶
scripts/check_gateway_explicit_binding.py enforces that the gateway
dispatch path binds (provider, model) from the token, never the request
model, and never auto-picks a provider.