feat(gateway): opt-in model routing on outbound LLM calls, plus Nadir app connection - #441
Open
doramirdor wants to merge 1 commit into
Open
feat(gateway): opt-in model routing on outbound LLM calls, plus Nadir app connection#441doramirdor wants to merge 1 commit into
doramirdor wants to merge 1 commit into
Conversation
Adds Nadir as a connectable app and, optionally, as a body transform that right-sizes the `model` field on outbound LLM completion requests. Two independent pieces: 1. App catalog entry (`nadir`). An api_key app like Resend, so the gateway can inject a Nadir key as `X-API-Key` on `api.getnadir.com`. Works on its own with no routing config. 2. `BodyTransform::NadirModelRoute`. On `POST /v1/messages` and `POST /v1/chat/completions`, classifies the prompt and rewrites `model` to the tier the operator mapped that bucket to. Agent code, SDK, and credentials are untouched. Routing is off unless `NADIR_MODEL_ROUTING=1`, so a default deployment never buffers an LLM body or contacts Nadir. Even when enabled, only a model the operator placed on a tier ladder is eligible; an unranked model cannot be shown to be a downgrade, so it is forwarded untouched. Upgrades are refused unless `NADIR_ALLOW_UPGRADE=1`. Fails open: a classifier timeout, non-200, malformed body, or unmapped bucket all forward the original bytes with the original model. The only error returned is an unbufferable request body, which cannot be forwarded at all -- deliberately not reusing the GitHubCommitTrailer arm's empty-body fallback, which would silently drop a real prompt. `max_tokens` is clamped down when the routed-to model has a lower output ceiling, which the provider would otherwise reject with a 400. Note: when routing is enabled, prompt text of routed requests is sent to api.getnadir.com for classification. Documented in the feature doc, the README, and .env.example. Tests: 17 new unit tests plus one #[ignore]d live end-to-end check.
doramirdor
force-pushed
the
feat/nadir-model-routing
branch
from
July 31, 2026 15:37
3d775a6 to
6d6e509
Compare
Author
|
Bump — this has been open since Jul 23 and still applies cleanly to It is two independent pieces if that makes review easier: the app-catalog entry is pure credential injection and works with zero routing config, and the opt-in Happy to split, rebase, or trim scope — whatever gets it moving. |
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.
Adds Nadir to OneCLI in two independent pieces. The first is a plain catalog entry; the second is an opt-in body transform. They can be reviewed, and shipped, separately, and the first is useful without the second.
The problem
Agents pin one model and use it for everything. The same session that needs Opus for a refactor also uses it to rename a variable, reformat JSON, or answer "does this file exist". Those calls are billed at the top tier for work a small model does identically.
OneCLI is already the one component that sees every outbound LLM call from every agent, and already rewrites request bodies (
BodyTransform::GitHubCommitTrailer, andhooks::prepare_request_bodyon the cloud build). That makes it the only place this can be fixed once instead of in every agent.1. Nadir as a connectable app
An
api_keyapp definition modeled onresend.ts, so the gateway injects a stored Nadir key asX-API-Keyonapi.getnadir.com. Ordinary credential injection, no routing config, does nothing until a user connects it.The key field is
optionalbecause Nadir's classifier has a keyless tier; a key raises the rate limit and records savings.2.
BodyTransform::NadirModelRouteOn
POST /v1/messagesandPOST /v1/chat/completionson known LLM hosts (policy::is_llm_host), classify the outbound prompt and rewritemodelto the tier the operator mapped that bucket to. The agent's code, SDK, and credentials are untouched.Dispatch follows the existing finalizer pattern in
forward.rs:LLM endpoints are reached with a plain secret rather than an OAuth app connection, so they have no provider-registry entry to hang a transform on and must be matched by host.
body_transform_for_hostis a pure registry mirror of the existingfinalizer_for_host; the Nadir-specific host logic lives in the transform module, soapps.rsstays a provider registry.The classifier is one vendor behind a self-contained module seam. If you'd rather the seam be explicitly generic so a second backend is a sibling file, say so and I'll restructure.
One deliberate divergence from the existing arm
The
GitHubCommitTrailerarm falls back toreqwest::Body::from(vec![])when the transform errors, forwarding an empty body. I did not reuse that. For an LLM request it would silently replace a real prompt with nothing and bill the user for the result.Instead
try_route_modelreturns the original bytes on every recoverable failure, and?propagates only the unrecoverable one (an unbufferable body, where the stream is already consumed and there is nothing left to forward). Happy to fix the GitHub arm the same way in a separate PR if you want it.Behaviour
NADIR_MODEL_ROUTINGunsetNADIR_ALLOW_UPGRADE=1max_tokensclamped down (never up)Anthropic gets a default ladder. OpenAI does not, deliberately: its model names move fast enough that a hardcoded ladder would eventually route to a model the operator never chose, so OpenAI routing stays off until the ladder is set.
The tradeoff you should weigh
When routing is enabled, prompt text of routed requests is sent to
api.getnadir.comto be classified. Classification needs the text; there is no way around that.For a project whose pitch is "agents never see the keys", shipping anything that forwards prompt content deserves an explicit decision rather than a quiet default. That's why it's opt-in, off by default, and disclosed in
docs/nadir-integration.md, the README feature bullet,.env.example, and the module doc comment. Nothing is sent while the feature is off. If this alone makes it a no, that's a reasonable call and I'd rather hear it early.Verification
pnpm build— passpnpm check(lint + types + format, 9 tasks) — passcargo clippy --all-targets— zero warningscargo test— 523 passed, 0 failed (17 new)cargo test -- --ignored live_classifier— passes against the live API: an Opus request with a trivial prompt routes down, andmax_tokens/messagessurvive the rewrite byte for byteNew tests cover flavor detection (wrong host, wrong method, wrong path, query strings), ladder ranking, Anthropic's sibling
systemfield in both string and block-array form,max_tokensclamping in both directions, off-ladder and malformed-body passthrough, and byte-exact passthrough for unroutable requests.Not included
Per-project configuration.
AppConfigalready hasprovider/enabled/settingsand would need no migration, but threading it to the gateway means touching the connect path andConnectResponse, which felt like a separate change. Env config matches how the gateway reads its other settings today.