Add Ramp Router support: detect a Router-pointed client and bill the model that served the call - #34
Conversation
…model that served the call The Python twin of lago-agent-sdk-js#36, ported from the rebased branch so it carries the full history's decisions at once: - Detection: a parsed-HOST arm after _provider_hint_for's path table — Router serves every provider it fronts through one dedicated host with no distinguishing path, and a substring test would be spoofable. - provider:provider-model[:service-tier] candidates parse into a bare model plus extras (router_provider, service_tier); first-colon split so Fireworks path-shaped ids survive; unknown trailing segments stay in the model. - ramp_router is TOKEN_BILLED (BYOK requests bill $0 undetectably; every observed catalog input rate is empty) and carries a MEASURED OPENAI_SHAPED_APIS entry: Router normalizes the numbers to OpenAI's convention even for Anthropic-served models — warm cache_control call, cached block inside an unchanged input_tokens; reasoning inside output. Both decisions pinned by tests that fail on revert. - Fixtures byte-identical with the JS repo (ten live captures, scrubbed); capture_ramp_router.py is the script that reproduces them, including the probes the first capture got wrong (candidates must be provider:model strings; caching only activates with an explicit cache_control part). - No gateway adapter, deliberately: Router exposes no programmatic usage surface; the note in gateway/__init__.py carries the seam and the reason. - Live-verified against a real Router account into a real Lago instance: plain, streamed and models-fallback calls billed the SERVED dated-snapshot model as provider=ramp_router token events in price mode with no error noise. Python nuance, documented in the README: the typed client rejects the models kwarg, so a fallback list goes through extra_body.
The README had grown to 526 lines, most of it the four gateway deep-dives. It now keeps a short quickstart per provider and per gateway; the full guides — backfill mechanics, attribution, measured billing caveats, account setup — move verbatim to docs/cloudflare.md, docs/databricks.md, docs/snowflake.md, docs/ramp-router.md, and the coverage/token-semantics tables to docs/providers.md.
Centered logo header with a dark-mode variant (the same wordmark the other Lago repos use), title and tagline; the ASCII diagram's box borders and connector spine now actually line up.
The logo already carries the name; the package id folds into the tagline line instead of standing alone as an h1.
sarkissianraffi
left a comment
There was a problem hiding this comment.
Ran the suite on this branch: 817 passed. Read the adapter, wrapper, semantics wiring, docs, capture script, and every fixture. Hygiene swept the tree and the branch history: clean, and the scrub-as-you-write capture script is the right systemic answer — value-first key redaction with a pattern backstop, content blanked not deleted, provenance annotated on every fixture. That is the standard the earlier capture scripts should adopt.
The hard design calls are right and well-evidenced. Treating Router as a provider in its own right instead of resolving to the served vendor, with both reasons stated (BYOK-vs-list price ambiguity, and the measured fact that Router normalizes NUMBERS to OpenAI's convention even for Anthropic-served models). Parsed-host detection instead of a substring row, with the spoof case written down. The tier whitelist so an unknown trailing segment cannot rename a model. The first-colon split for Fireworks paths. The documented ABSENCE of a gateway adapter, checked against the whole doc corpus, is exactly how to scope honestly.
One blocker, one line to fix, flagged inline: the total_tokens guard runs BEFORE the Router block reassigns api, so for a Router call the guard reads token_semantics("ramp_router", "responses") — all-additive — while compute_cost and deoverlapped_token_total read the reassigned api = "ramp_router" and get subset semantics. That is the exact divergence token_semantics.py exists to make impossible; its docstring says the guard and the money paths cannot answer the convention question differently, and on this surface they do. Reachability today is nil (every capture reports total = input + output), but the guard exists for the day Router misreports, and on that day it under-folds by cache_read + reasoning. Move the reassignment (or compute the effective api) above the guard, and pin it with a Router payload whose declared total exceeds input + output — no current test covers that shape, so the fix is test-safe and closes an untested branch at once.
Two non-blocking notes: one inline on the candidate-parse heuristic, and the fixture-hygiene guard gained no Router patterns — defensible since the capture script scrubs at the source, but say so in the guard's comment so the next surface knows which of the two mechanisms is load-bearing, and fold it into the source-file guard extension already requested on #28.
Fix the ordering and this is approved. Same finding applies to the JS twin (#36), commented there.
| # `/v1/responses` (`/v1/chat/completions` 404s), so a `chat_completions` value | ||
| # here is drift worth seeing rather than a case to handle. | ||
| extras["router_surface"] = api | ||
| api = RAMP_ROUTER_PROVIDER |
There was a problem hiding this comment.
The totals guard above (line 390) runs before this reassignment, so a Router call is guarded with ("ramp_router", "responses") — every subset check False — while the emit path bills the same row under api="ramp_router" with subset semantics. The guard's accounted sum then over-counts by cache_read + reasoning, and a genuine positive remainder is under-folded or suppressed: the disarmed-guard case token_semantics.py warns about, reintroduced one surface later. Move this reassignment (or compute the effective api) above the guard, and pin it with a Router payload whose declared total exceeds input + output. Blocker, one line plus a test.
| rest = model_id[first_colon + 1 :] | ||
| # A head that is not a plausible provider token — a path, or something long — means | ||
| # this is an opaque id that merely happens to contain a colon, not a candidate. | ||
| if not rest or not _ROUTER_PROVIDER_SEGMENT.match(head): |
There was a problem hiding this comment.
Non-blocking: an opaque account-specific id that happens to contain a colon with a short lowercase head — my-model:v2 — passes this check and splits, billing the model as v2 with router_provider: "my-model". The tier arm got a whitelist for exactly this reason; the provider arm relies on shape alone. Cheap hedge: record the unsplit id in extras whenever this branch splits, so a misparse is recoverable at reconciliation instead of invisible.
The adapter reassigns `api` to "ramp_router" after parsing the model, and the
guard ran before that. So the guard asked token_semantics("ramp_router",
"responses") — a pair in no subset set, answering all-additive — while
compute_cost and deoverlapped_token_total read the stamped api="ramp_router"
and de-overlapped the same row as subset. That is the divergence
token_semantics.py exists to make impossible.
Unreachable on every captured payload: all ten report total == input + output,
streamed included. But the guard exists for the day Router misreports, and on
that day it under-folds a genuine remainder by cache_read + reasoning — or,
where that over-count exceeds the declared total, suppresses the fold entirely
and drops generated tokens with no extras key and no on_error.
The code change is a pure move of the Router block above the guard; nothing
between model resolution and the guard touches `api`. Two tests pin it, both
failing if the block moves back: one on a payload whose remainder is larger
than its subsets (folds 850, not 740), one where it is smaller (folds 50
rather than vanishing). No captured fixture has that shape, so this closes an
untested branch at the same time.
|
Confirmed and fixed in d2832f0. Reproduced it before touching anything. On a Router call the guard read
The control isolates it — the identical payload as Your reachability call was right: all ten captures report Fix: pure move of the Router block above the guard. 819 pass, One thing worth flagging on the twin: the port is not mechanical. In JS the guard read Taking the two non-blocking notes (the parse hedge, the guard comment + #28) as a follow-up. |
osmarluz
left a comment
There was a problem hiding this comment.
Reviewed at d2832f0. The ordering fix is correct and the design calls hold up. Three findings below, two of which I'd treat as blocking.
Common root: the Router-specific block was written against a hand-modeled wire shape that the PR's own ten live captures contradict.
| # the one asked for, so the response is the only place the SERVED model appears. | ||
| model = resolved_model | ||
| if provider_hint == RAMP_ROUTER_PROVIDER: | ||
| router_provider, parsed_model, tier = _parse_router_model(resolved_model) |
There was a problem hiding this comment.
Blocker: this parse matches a shape no real Router response has, so service_tier is dropped on all live traffic.
_parse_router_model expects provider:model[:tier] in the response model. Every committed capture reports a bare vendor snapshot instead, and carries the tier as a top-level service_tier field:
| fixture | model |
service_tier |
|---|---|---|
| 02 buffered | gpt-5.4-nano-2026-03-17 |
flex |
| 03 fallback | gpt-5.4-nano-2026-03-17 |
default |
| 04 streamed | gpt-5.4-nano-2026-03-17 |
flex |
| 05b/06b | claude-haiku-4-5-20251001 |
default |
| 07 reasoning | o4-mini-2025-04-16 |
default |
first_colon <= 0 short-circuits on all of them, so extras["router_provider"] and extras["service_tier"] are reachable only from the hand-built router_response() tests. Running the fixtures through extract_openai_native(provider_hint="ramp_router") gives extras == {"input_tokens_details.cache_write_tokens": 0, "router_surface": "responses"} — neither key present.
Consequence is exactly what the comment two lines below warns about: a flex call is indistinguishable in Lago from a default-tier one, and the PR description, docs and CHANGELOG all claim otherwise.
Fix: read the field that actually exists — extras["service_tier"] = resp.get("service_tier"). For router_provider, the served vendor isn't in the response at all, so either derive it in the wrapper from the requested models candidates or drop the field.
Worth noting none of the twelve captures (232 KB) is loaded by any test — every Router test uses router_response(). A parametrized test over 02/03/04/06b/07 _body would have caught this immediately, and CONTRIBUTING.md:40-42 asks for exactly that.
There was a problem hiding this comment.
Fixed in 89b66d6 (JS twin: getlago/lago-agent-sdk-js@5cc2978). The mechanical half of this is exactly right and I reproduced all of it — thank you, the fixture table saved me the capture pass.
Confirmed by running all 12 captures through extract_openai_native(provider_hint="ramp_router"): first_colon <= 0 short-circuits on every one, and extras came back {'input_tokens_details.cache_write_tokens': 0, 'router_surface': 'responses'} on the nose. The compound form only ever exists in the request (_requested_models: ["openai:gpt-5.4-nano", ...]) — Router resolves it away before answering.
What I changed:
service_tiernow reads the response's own top-level field, with the candidate suffix kept only as a fallback for an id that reaches the adapter unresolved. Recorded verbatim rather than filtered through_ROUTER_SERVICE_TIERS— that set exists to disambiguate a colon segment that might be part of a model name, and a dedicated field has no such ambiguity, so a tier Router adds later gets recorded instead of dropped.- Dropped
router_provider, per your second option. The served vendor isn't in the response, and deriving it in the wrapper from the requested candidates would record what was asked for whileservice_tierrecords what served — amodelsfallback can make those differ, and one field meaning two things is worse than no field. - Took the parametrized fixture pass you asked for: 8 captures × served tier + bare served snapshot. All 8 fail without the fix; CONTRIBUTING.md:40-42 was right and we weren't following it.
- Docs and CHANGELOG corrected — they claimed the tier was recorded and it wasn't.
One push back: I don't think this was a blocker, and I'd rather we not label it one. Not to dispute the bug — it's real and it's fixed — but the stated consequence doesn't hold:
a
flexcall is indistinguishable in Lago from a default-tier one
That's true both before and after the fix, because extras is never serialized. sdk.py:555 builds event properties from model/provider/api/price_source/markup plus the per-field token counts; there are zero references to .extras in sdk.py (you make this same point yourself in the api-stamping comment below). So the patch you proposed — and the one I shipped — does not change a single byte of any Lago event. Nobody was over- or under-billed, and nobody is billed differently now.
What was actually broken is dead code defended by fake fixtures, plus two docs claims that were false. Worth fixing, and I did. But by the bar we use here — a blocker moves money or corrupts a bill — this sat below it. Your other finding on the Anthropic surface is squarely above it, and that's the one gating the merge; I've verified it end-to-end and will reply there separately.
| # `/v1/responses` (`/v1/chat/completions` 404s), so a `chat_completions` value | ||
| # here is drift worth seeing rather than a case to handle. | ||
| extras["router_surface"] = api | ||
| api = RAMP_ROUTER_PROVIDER |
There was a problem hiding this comment.
Suggestion (cheap now, breaking later): stamping api with the provider name makes the two properties duplicates and loses the real surface.
After this line every Router event carries provider == api == "ramp_router". The same client emits {provider: openai, api: responses} at OpenAI but {provider: ramp_router, api: ramp_router} at Router — so a Lago charge filtered on api == "responses" silently matches nothing after a base_url repoint. extras["router_surface"] doesn't recover it: sdk.py has zero references to extras, so it's never serialized.
The identical semantics are reachable without the reassignment — add ramp_router to INPUT_INCLUDES_CACHE_READ, INPUT_INCLUDES_CACHE_WRITE and OUTPUT_INCLUDES_REASONING, and drop it from OPENAI_SHAPED_APIS. That gives token_semantics("ramp_router", "responses") == (True, True, True), keeps test_the_subset_sets_only_name_known_providers green, and removes the "MUST stay ABOVE the guard" hazard by construction rather than by comment — which is the ordering bug d2832f0 just fixed.
token_semantics.py's rationale for api-keying is that a surface row reuses live vendor names; that doesn't apply when the provider name is the gateway. Same precedent it cites for keeping workers-ai provider-keyed.
|
|
||
| - **There is no backfill path**, because Router exposes no programmatic usage surface. Its only routes are `GET /v1/models`, `POST /v1/responses`, `POST /v1/messages` and `POST /v1/messages/count_tokens`; usage lives in the dashboard's Logs view. An "analytics API" is mentioned once in Router's limits table with no path, auth or record shape. Unlike the [Cloudflare connector](cloudflare.md), there is no Logs API loop to show you. | ||
| - **Nothing is skipped as a gateway cache hit**, because Router has no response cache: "Self-service Router response caching, which would reuse an entire previous response without calling a model provider, is a separate optimization and is not currently configurable." Provider _prompt_ caching does pass through, and those cache-read and cache-write tokens are billed like any other. | ||
| - **Router's Anthropic Messages surface is not instrumented yet.** `POST /v1/messages` exists and routes to the same providers, so pointing a wrapped `Anthropic` client at `https://api.router.com/v1` will _work_ as an LLM client but bill nothing. Use the Responses surface until that lands. |
There was a problem hiding this comment.
Blocker: "bill nothing" is the opposite of what happens — that traffic bills as native Anthropic, at list price, with the wrong cache convention.
wrappers/anthropic.py has no base_url detection and anthropic_native.py hardcodes provider="anthropic", so a wrapped Anthropic client at https://api.router.com/v1 emits ordinary provider=anthropic api=native events.
Reproduced with usage {input_tokens: 18825, output_tokens: 15, cache_read_input_tokens: 18810}: three events stamped anthropic. Since anthropic isn't in INPUT_INCLUDES_CACHE_READ, deoverlapped_token_total bills 18825 + 18810 — a double count if Router folds cached tokens inside input on /v1/messages the way fixture 06b proves it does on /v1/responses. In price mode it also bills Anthropic's list rate for a call Router may have served BYOK at $0.
That's the exact mis-billing this PR exists to prevent, and the bullet tells the reader it can't happen. Either fix the sentence ("bills as native Anthropic with the wrong provider and convention — do not point a wrapped Anthropic client at Router") or add the host arm to the Anthropic wrapper.
There was a problem hiding this comment.
Confirmed and fixed in 55c2179 (JS twin: getlago/lago-agent-sdk-js@973b85b). Took the first of your two options — the corrected sentence, not the wrapper arm.
I reproduced it end-to-end before touching anything: wrapped an Anthropic client whose only difference is base_url="https://api.router.com/v1", fed it your exact usage, and got what you described.
events emitted: 3
llm_input_tokens provider=anthropic api=native value=18825
llm_output_tokens provider=anthropic api=native value=15
llm_cached_input_tokens provider=anthropic api=native value=18810
deoverlapped total: 37650 (true consumption: 18825)
You're right on the mechanism too — the override table lives only in wrappers/openai.py, and anthropic_native.py:102 hardcodes provider="anthropic".
The bullet now says the call bills as native Anthropic, names all three consequences (wrong provider stamp, Anthropic list rate on a call Router may have served BYOK at $0, additive de-overlap against Router's inside-input convention), and tells the reader to stay on the Responses surface.
One thing I stated more carefully than you did, and I think it strengthens the warning rather than weakening it: the 2× is conditional. 05b/06b prove the inside-input fold on /v1/responses — input_tokens holds at 18825 and total_tokens at 18830 across cold and warm — but we have no capture of /v1/messages, so the fold is inferred there, not measured. The bullet says so explicitly, and frames that uncertainty as the reason to stay off the surface rather than as a reason to hedge the warning.
Agreed this was the one worth blocking on. "Bill nothing" describes a loud, visible failure — the kind you notice. The actual failure is silent and wrong, which is the category this connector exists to prevent, and the bullet was pointing readers straight at it.
Leaving the wrapper base_url arm out of this PR deliberately: doing it properly needs a /v1/messages capture to pin Router's convention on that surface, and guessing it is how we'd end up with the mirror-image of this bug. I'll file it with the capture as a prerequisite.
osmarluz
left a comment
There was a problem hiding this comment.
Reviewed at d2832f0. The ordering fix is correct and the design calls hold up. Three findings below, two of which I'd treat as blocking.
Common root: the Router-specific block was written against a hand-modeled wire shape that the PR's own ten live captures contradict.
The tier was read only from a `provider:model[:tier]` candidate suffix. Router resolves that away before answering — `openai:gpt-5.4-nano` in, `gpt-5.4-nano-2026-03-17` out — so `_parse_router_model` short-circuited on every real response and `service_tier` was dropped on 100% of live traffic. The tier is right there as a top-level field on the response, which is now where it is read from; the candidate suffix stays as a fallback for an id that reaches the adapter unresolved. Sourcing it from the candidate also answered the wrong question: the candidate says what was ASKED for, `service_tier` says what SERVED, and a `models` fallback list can make those differ. The value is recorded verbatim rather than filtered through _ROUTER_SERVICE_TIERS — that set disambiguates a colon segment that might instead be part of a model name, and a dedicated field has no such ambiguity, so a tier Router adds later is recorded rather than dropped. No billing change: `extras` is diagnostic and is not serialized into Lago events. The docs and CHANGELOG said otherwise and now say what happens. Every Router test drove a hand-built response, so nothing could catch this. Adds a parametrized pass over the eight captured responses that carry usage, asserting the served tier and the bare served snapshot. All eight fail without the fix.
The bullet claimed such a call would "bill nothing". It bills as native
Anthropic: the wrapper has no base_url arm, so the call emits ordinary
provider=anthropic api=native events at Anthropic's list rate, de-overlapped
with Anthropic's additive cache convention.
Reproduced with usage {input_tokens: 18825, output_tokens: 15,
cache_read_input_tokens: 18810}: three events stamped anthropic, and
deoverlapped_token_total returns 37650 for a call that consumed 18825.
The double count depends on Router folding cached tokens inside input on
/v1/messages the way 05b/06b prove it does on /v1/responses. That is not
measured on /v1/messages, and the bullet now says so — the uncertainty is the
argument for staying off the surface, not for softening the warning.
"Bill nothing" describes a loud, visible failure. The real one is silent and
wrong, so the bullet led a reader toward the mis-billing this connector exists
to prevent.
Reported by @osmarluz on #34.
The bullet claimed such a call would "bill nothing". It bills as native
Anthropic: the wrapper has no baseURL arm, so the call emits ordinary
provider=anthropic api=native events at Anthropic's list rate, de-overlapped
with Anthropic's additive cache convention.
Reproduced with usage {input_tokens: 18825, output_tokens: 15,
cache_read_input_tokens: 18810}: three events stamped anthropic, and
deoverlappedTokenTotal returns 37650 for a call that consumed 18825.
The double count depends on Router folding cached tokens inside input on
/v1/messages the way 05b/06b prove it does on /v1/responses. That is not
measured on /v1/messages, and the bullet now says so — the uncertainty is the
argument for staying off the surface, not for softening the warning.
"Bill nothing" describes a loud, visible failure. The real one is silent and
wrong, so the bullet led a reader toward the mis-billing this connector exists
to prevent.
Reported by @osmarluz on getlago/lago-agent-sdk-python#34.
Python twin: 55c2179
The Python twin of lago-agent-sdk-js#36, ported from that branch's rebased, live-verified state — so the pair merges together per the repo rule.
What it carries
_provider_hint_for's path table (Router's only signal is its dedicated host;"api.router.com" in base_urlwould be spoofable, so the host is parsed).provider:provider-model[:service-tier]→ bare model +extras.router_provider/extras.service_tier; first-colon split; tier matched against Router's documented set only.ramp_routerinTOKEN_BILLED_PROVIDERS(BYOK bills $0 undetectably; every catalog input rate observed is empty) and inOPENAI_SHAPED_APIS— measured, not guessed: on an Anthropic-served model a warmcache_controlcall reports the cached block inside an unchangedinput_tokens, reasoning insideoutput(fixtures 06b/07).capture_ramp_router.py— the script that reproduces them, including the corrected probes (candidates must beprovider:modelstrings; caching only activates with an explicitcache_controlpart).gateway/__init__.pycarries the seam and the reason.Live evidence
Driven against a real Router account into a real Lago instance: plain, streamed and
models-fallback calls all billed the SERVED dated-snapshot model asprovider=ramp_routertoken events, in price mode, with zero error-hook noise. One Python-ecosystem nuance surfaced live and is documented in the README: the typed client rejects the non-standardmodelskwarg, so a fallback list goes throughextra_body={"models": [...]}.Rebase notes
_provider_hint_forgains the host arm after the path table;wrap_openai_clientis otherwise untouched — the INT-246 eventId plumbing and stream paths are unchanged.extract_openai_nativegains the Ramp block between the total_tokens guard and the return; the guard's arithmetic is untouched.TOKEN_BILLED_PROVIDERS,KNOWN_PROVIDERSandOPENAI_SHAPED_APISeach gain one entry.