All docs

Docs · Reference

AI gateway inlet

POST /v1/messages

Operator and author reference for the AI-gateway inlet: routes, credential modes, the llm envelope contract, and the flags that bound it. The concept page is AI Gateway._

The inlet speaks the Anthropic Messages protocol to clients, runs each request once through the tenant’s _llm stack, and forwards the result upstream. The gateway owns the transport: a stack that streams a response is an error, and the upstream stream is never routed through the processor.

Routes

Both are registered on the web head ahead of the catch-all, so they bypass BasicAuth and the opstack render path. The web personality must be enabled.

RouteStack runsMeteredNotes
POST /v1/messagesyesyesThe policy surface. Completion envelope fires afterwards.
POST /v1/messages/count_tokensnonoA metadata echo clients call constantly — forwarded transparently. Tenant routing, the _llm self-gate, and auth still apply.

The client’s path and query string are forwarded verbatim — clients append ?beta=true, and dropping the query would silently change protocol behavior.

Opt-in and routing

  1. Host → tenant, through the same DB-backed hostname resolver the web pipeline uses. A resolver failure is a 503 with Retry-After: 1, never a fabricated 404.
  2. Self-gate: one point read asks whether that tenant authored an _llm stack. No stack, no gateway — the hostname gets 404 not_found_error on these paths.

So enabling the gateway for a tenant is exactly: bind a hostname, author OPS/_llm/, apply. There is no --llm-enable flag.

Credential modes

Chosen per request by what the tenant’s secret store holds, scoped to the _llm stack (stack-scoped falls back to tenant-wide).

StoredModeBehavior
neitherpassthroughThe client’s own x-api-key is forwarded verbatim.
ANTHROPIC_KEY + LLM_GATEWAY_KEYswapThe inbound x-api-key is compared to LLM_GATEWAY_KEY in constant time; on match it is replaced with the real key and any client Authorization header is dropped.
ANTHROPIC_KEY alonefails closed401 for every client, plus a warning log. The real key is never spent on an unauthenticated request.
secret-store error503. The mode is unknowable, so neither is served.

The mode in effect is readable in the stack as @llm.auth_mode.

Note

These two secrets have no environment-variable fallback. Unlike OPENROUTER_KEY and OPENAI_KEY (see ai.md, governed by --ai-chat-env-fallback), they are read only from the secret store — so an operator’s own exported ANTHROPIC_API_KEY can never become a tenant’s upstream credential. Note the name: the secret is ANTHROPIC_KEY; ANTHROPIC_API_KEY is the client’s variable, forwarded in passthrough mode.

The _llm contract

Envelopes arrive with _txc.src == "llm" and route to _llm/0. @ is sugar for ._txc., so @llm.phase is _txc.llm.phase.

Stamped on the request (read-only)

Everything under _txc is chassis-stamped; the client’s bytes are confined to the top-level request key, so a hostile client cannot forge control state.

FieldWhat
@llm.phase"request"
@llm.protocol"anthropic.messages"
@llm.request_idCorrelates the request and completion runs
@llm.tenant, @llm.host, @llm.hostname_verifiedResolved routing
@llm.streamWhether the client asked for SSE
@llm.auth_mode"passthrough" or "swap"
@llm.req.user_agent, @llm.req.anthropic_version, @llm.req.anthropic_betaPresent only when the client sent them
requestThe client’s Messages JSON — mutable

Writable by the stack

These four subtrees are the entire author-writable surface under _txc.llm. The system context is default-closed to authors, so anything else under @llm is reserved. Use EMITSET does not persist into the envelope the gateway reads back.

PathEffect
@llm.reject.{status,type,message}Answer without contacting the upstream. A status outside 100–599 becomes 403; an empty type becomes permission_error.
@llm.upstream.urlOverride the base URL for this request. Must be http/https with a host, and is subject to the egress policy; anything else is a 500.
@llm.headersString-valued headers set on the upstream request.
@llm.contextItems for the gateway to inject — see below.

Rewriting the outbound request itself is a plain EMIT .request.….

The completion envelope

Fires asynchronously into the same stack after the exchange ends, with a new rid (reusing the request’s would overwrite its trace) and @llm.request_id carrying the original for correlation.

FieldWhat
@llm.phase"completed"
@llm.completion.{status,duration_ms,bytes_in,bytes_out}The exchange. status is 0 when the dial failed.
@llm.completion.{requested_model,effective_request_model,response_model}Explicit provenance: the client’s ask, what went upstream, what was served. response_model is capture-dependent.
@llm.completion.{stream,upstream,client_disconnected}How it ran
@llm.completion.errorPresent only on failure
@llm.completion.usage.{input_tokens,output_tokens,cache_read_input_tokens,cache_creation_input_tokens}Presence-gated — absent means “not captured”, never zero
@llm.completion.stop_reasonPresent when captured
@llm.context_resultGateway-owned; see below
Warning

Gate every rule on @llm.phase. The completion envelope routes into the same stack, so an ungated request-shaping rule runs a second time against it.

Token capture is a passive tee over the same bytes the client receives, so it never delays the stream and never retains content. Streaming policy requests are forwarded with Accept-Encoding: identity so the SSE frames stay parseable; the client still sees an unencoded stream under its own negotiation.

Context injection

The stack emits @llm.context as an array of {source?, title?, content}; the gateway validates, dedups, budget-fits, and serializes the survivors into system blocks. Policy stays in txcl, serialization stays in Go — a stack cannot reliably write request.system itself, because a merge drops an emitted array when the envelope’s system is a string.

  • Blocks are appended after the client’s own system content, preserving its cached prefix, behind a chassis-owned guard block declaring the material untrusted evidence rather than instructions. That guard is not stack-writable and counts against the budget.
  • Items first-fit under both caps after the guard is reserved: a too-large item is dropped and a later smaller one may still fit. If nothing fits, nothing is injected — never a bare guard.
  • Deduplication is by content sha256 and by (source, title). Labels are stripped of control characters and capped at 200 bytes.
  • The token estimate is bytes/4 — a guardrail, not an invoice. Exact counts arrive later on the completion envelope.

@llm.context_result is the ground truth, one row per emitted item plus a synthetic guard row: {source, title, sha256, bytes, est_tokens, status, synthetic} where status is injected, dropped_budget, dropped_dup, or invalid. Rows carry sha256 and byte counts, never content — traces must not become a second copy of organizational memory. Stacks cannot write this path.

Warning

Injection is enabled only when both --llm-context-max-tokens and --llm-context-max-items are positive. Either at 0 disables it entirely, and the only signal is a debug log — emitted items are silently ignored.

Flags

Every flag is also an environment variable with the TXCO_ prefix and underscores (--llm-upstream-urlTXCO_LLM_UPSTREAM_URL) — see serve.md.

FlagDefaultMeaning
--llm-upstream-urlhttps://api.anthropic.comBase URL forwarded to after the stack runs. Point at a local fake for testing. Invalid at boot is a hard start failure.
--llm-context-max-tokens2000Estimated-token cap per request, guard block included
--llm-context-max-items8Item cap per request

Adjacent flags the inlet consumes:

  • --op-timeout-max (default 10m) — ceiling on the stack round-trip; falls back to --web-write-timeout if unparseable. A single blocked client write is bounded at 60s while total stream length stays unbounded.
  • --web-max-body-bytes (default 30 MiB) — request-body cap, enforced before admission; over it is a 413 request_too_large.
  • --egress-policy (default private) — applies at dial time to the configured upstream and to any @llm.upstream.url override, so a stack-supplied URL gets no more reach than any other outbound call.

Errors

Gateway-originated errors use the protocol’s own shape, so clients surface them natively:

{
  "type": "error",
  "error": {
    "type": "permission_error",
    "message": "…"
  }
}

The types the gateway itself emits are invalid_request_error, authentication_error, permission_error, not_found_error, request_too_large, rate_limit_error, and api_error. Upstream error bodies are never rewritten — the client sees exactly what the upstream said, including its status.

Admission denials map by status: 429rate_limit_error, 5xxapi_error, otherwise permission_error, with Retry-After passed through when the gate suggested one. An upstream dial failure is 502 api_error; the stack failing to run, leaving no request object, or streaming a response is 500 api_error.

Metering

One usage event per /v1/messages request records src: "llm", stack: "_llm", duration, bytes in/out, and billable: true. That is transfer metering — stack fuel is metered separately by the pipeline, and token counts are record-only: nothing is charged from them today. count_tokens records nothing.

See also

  • AI Gateway — the concept page
  • AI Operations — the outbound direction, ai://chat and ai://embed
  • Chassis runtime — the full flag reference
  • examples/llm-gateway-hello/ — a runnable stack, keyword and vector retrieval

Edit this page · View as markdown