feat(gateway): add the LLM lane — governed, brokered, metered model egress - #235
Open
Ar9av wants to merge 2 commits into
Open
feat(gateway): add the LLM lane — governed, brokered, metered model egress#235Ar9av wants to merge 2 commits into
Ar9av wants to merge 2 commits into
Conversation
…gress
The gateway has governed the tool lane (MCP) since 1.29. This adds the model
lane: a local HTTP proxy an SDK points at instead of the provider, so model
traffic gets the same treatment tool traffic already gets.
`prismor gateway` is now the umbrella command with two lanes that share one
policy engine and one session id — `gateway mcp` (the existing aggregator,
unchanged) and `gateway llm` (new). `prismor mcp-gateway` stays as an alias.
What the model lane does per request:
* Evaluates it as a `network` event carrying the outbound payload. Reusing
the existing event type is the whole trick — the egress allowlist,
secret-in-payload rules, and taint escalation apply to model calls without
one new policy surface to configure. A block is refused before egress, so
the provider never receives the body.
* Resolves the provider credential server-side, from a Cloak placeholder or
the gateway's own environment, so the real key never has to exist on a
developer's machine.
* Meters tokens and cost from the provider's own usage accounting and
flushes `llm_usage` records through the shared uploader, inheriting its
offline spool and at-least-once dedupe.
* Re-scans the completion as untrusted content before returning it.
Streaming is a first-class path, not a fallback: SSE bytes are relayed
unbuffered while a tap recovers the usage block and assistant text, so
time-to-first-token is unchanged. Usage is only knowable at end-of-stream, so
the response scan on a streamed call is post-hoc by construction; the pre-call
check has already run and the scan still reaches telemetry.
Fail-closed in enforce mode: a policy engine that errors denies the call
rather than waving model traffic through. Denials are rendered in the
provider's own error shape so SDKs surface a readable refusal instead of
crashing on an unexpected body.
Cost uses a built-in per-1M-token table matched by longest model-id prefix,
with cache reads discounted. An unknown model falls back to a conservative
non-zero rate — a silent $0 reads as "this model is free". Overridable with
--pricing.
45 new tests in tests/test_llm_gateway.py, including transport tests that run
the real handler against a fake upstream on loopback so the streaming relay,
header rewriting, and credential swap are exercised rather than mocked.
`cloaked-secret-in-mcp-args` fires on any event carrying an outbound_payload. Until now that was only remote MCP tool calls, so hardcoding "outbound MCP tool arguments" in the title was accurate. The LLM lane reaches the same check with a model prompt, and an operator blocked while pasting a token into a chat message was being told the secret was found in "MCP tool arguments" — which is wrong and sends them looking at the wrong subsystem. Caught on st3ve running the real gateway against an enrolled secret, not in unit tests. The title now reflects the surface; the ruleId deliberately does not change. It is referenced by the enforcement floor and by existing RuleExemptions, so renaming it would silently drop protections and admin-granted exemptions. 3 tests covering both lanes and the invariant that the title names the secret but never its value.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The gateway has governed the tool lane since the MCP aggregator shipped: one connector in front of every MCP server, every
tools/callpolicy-evaluated before it forwards, every result scanned before the model sees it.Model traffic had no equivalent. An agent could be fully governed on the tool side and still paste a live credential straight into a prompt, and there was no attribution for what any of it cost.
This adds the model lane, and makes
prismor gatewaythe umbrella command over both.Both lanes share one policy engine and one session id, so a session's tool calls and model calls land on the same trace.
What the model lane does
A local HTTP proxy an SDK points at instead of the provider. Per request:
networkevent carrying the outbound payload. Reusing the existing event type is the whole trick: the egress allowlist, secret-in-payload rules, and taint escalation apply to model calls with no new policy surface to configure. A block is refused before egress, so the provider never receives the body.llm_usagerecords through the shared uploader, inheriting its offline spool and at-least-once dedupe.Only the base URL changes on the client:
Design notes worth reviewing
Streaming is a first-class path. Agent traffic is overwhelmingly SSE; a proxy that only handled buffered JSON would be useless. Bytes relay unbuffered (time-to-first-token unchanged) while a tap recovers the usage block and assistant text. Usage is only knowable at end-of-stream, so the response scan on a streamed call is post-hoc by construction — the pre-call check has already run and the scan still reaches telemetry. Flagged in the code and docs rather than papered over.
Fail-closed in enforce. A policy engine that errors denies the call rather than waving model traffic through — same stance as the MCP lane.
Denials wear the provider's error shape (HTTP 403,
permission_error), so SDKs surface a readable refusal instead of crashing on an unexpected body.Cost is attribution, not invoicing. Built-in USD/1M-token table matched by longest model-id prefix, cache reads discounted. An unknown model falls back to a conservative non-zero rate — a silent
$0reads as "this model is free". Override with--pricing.Bind is loopback-only by default. The gateway is a credential-bearing egress point;
--hostis available but documented as needing an authenticating proxy in front.Second commit: a real bug this surfaced
cloaked-secret-in-mcp-argsfires on any event with anoutbound_payload. That was only ever remote MCP calls, so the hardcoded title "outbound MCP tool arguments" was accurate. The LLM lane reaches the same check with a prompt — so an operator blocked while pasting a token into a chat message was being pointed at the wrong subsystem.Fixed the title to name the surface it saw. Deliberately did not rename the ruleId — the enforcement floor and existing
RuleExemptionskey on it, so renaming would silently drop both protections and admin-granted exemptions.Found by running the real gateway against an enrolled secret on a real box, not in unit tests.
Testing
48 unit + transport tests (
tests/test_llm_gateway.py). Transport tests run the real handler against a fake upstream on loopback, so the streaming relay, header rewriting, and credential swap are exercised rather than mocked.End to end on st3ve (Ubuntu, Python 3.12 — vs 3.14 locally), real CLI process, real sockets, isolated Cloak store via
PRISMOR_SECRETS_DIR:Note the finding names the secret but never its value — asserted as an invariant in the unit tests too.
Also verified on st3ve: credential brokered server-side (the client's key is never forwarded), tokens metered from provider usage, cost attributed, blocked attempts still metered so they appear in spend rather than vanishing.
Regression: 231 passed / 2 skipped across
test_policy_engine,test_enforcement_floor,test_egress,test_mcp_gateway,test_cloak_secret_guard.The 4 failures in
test_cli.py(3 x analyze, 1 x cloak env import) pre-exist on cleanorigin/main— verified by stashing and re-running.Not in this PR
llm_usageinto the control plane's ingest + spend dashboards — separate prismor-web PR