TokenOps assumes the model identifier is known before a call is made. For stacks that route through a gateway which picks the model at request time, it is not — and the actual choice never reaches TokenOps at all, in either direction.
1. Context
TokenOps caps spend for a whole agent workflow and enforces it before every model call. Money is computed by a PriceFn — (provider, model, Usage) -> Micros — so every governance decision depends on knowing which model is being called.
Two policies consume that, at opposite ends of a call:
| Policy |
When |
Needs the model for |
pre_call_worst_case |
Before dispatch |
Pricing the projected input + capped output, to decide whether the call could exceed the budget |
cost_budget |
After dispatch |
Pricing the actual Usage observed, to decide whether the budget is now exhausted |
The model string flows from the caller, through wrap_complete, into both:
# src/tokenops/control/integration.py — inside governed()
request = CallRequest(
attr=attr,
provider=provider,
model=m, # <- supplied by the caller
estimated_input_tokens=estimate(messages),
max_output_tokens=controls.call.max_output_tokens,
)
governor.pre_call(request)
After the call, the crossing hook records an Observation, taking the model from the dispatch arguments:
# src/tokenops/control/crossing.py:153
model = str(state.get("model") or (gov.model if gov else "") or "")
state here is the dispatch call's arguments. So the model recorded post-call is the model that was passed in, not the model that answered.
2. Problem
A gateway — OpenRouter auto-routing, a LiteLLM proxy with fallbacks, a Bedrock or Vertex router, a Portkey config — accepts an alias (auto, a routing profile, a virtual key) and chooses a concrete model at request time. Selection can depend on load, price, availability, or a fallback chain after a 429.
That breaks the assumption in both directions.
Before the call: nothing to price
pre_call_worst_case receives the alias. Two outcomes, both wrong:
There is no third option today: the policy has no way to express "the model is not yet known".
After the call: the answer never arrives
Post-call billing could in principle recover, since the gateway knows what it used. It does not, because the dispatch contract has nowhere to put it:
# src/tokenops/providers/types.py
@dataclass
class ModelResponse:
content: str
input_tokens: int = 0
output_tokens: int = 0
No model field. Combined with crossing.py:153 reading the model from the request arguments, a gateway that returns model: "anthropic/claude-sonnet-4-5" in its response body has no way to tell TokenOps. The ledger bills the alias, and per-model attribution in the dashboard is wrong for every gateway user.
Some gateways go further and return a computed cost directly, which TokenOps has no way to accept either — it always prices from its own table.
Blast radius
Any deployment fronting models with a router. That is a common production shape: teams adopt a gateway precisely to get failover and cost routing, which are the same features that make the model unpredictable. cost_budget still functions as a backstop if an alias rate exists, but attribution is wrong and pre_call_worst_case is unusable.
3. Proposed approach
Sketch only — this needs design agreement before implementation.
Let the model be unknown until the response. Treat "resolved at request time" as a first-class case rather than an error, so the pre-call path can degrade deliberately instead of tripping.
Let dispatch report what actually answered. Add an optional model (and possibly cost_micros) to ModelResponse, and prefer it over the request argument in crossing.py:153 when present. This is the smallest change with the largest effect: it fixes post-call attribution and billing accuracy on its own, independently of anything pre-call.
Then choose a pre-call strategy. Options worth weighing:
| Option |
Behaviour |
Trade |
Skip pre_call_worst_case for gateway runs |
Rely on cost_budget as backstop |
Budget can overshoot by one call |
| Price a declared ceiling |
Caller names the most expensive model the gateway may return |
Preserves the pre-emptive guarantee; needs the caller to know the routing set |
| Accept gateway-reported cost |
Bypass the rate table when the gateway provides a cost |
Trusts the gateway's arithmetic; no pre-call help |
The ceiling option is the only one that keeps pre_call_worst_case meaningful, and it composes with #128's rate map — the ceiling is just another key.
4. Tradeoffs and non-scope
5. Acceptance
Related: #128 (rate resolution — assumes the model is known, and says so), #129 (failure-mode postures), #110 (streaming governance), #57 (output-token prediction for pre_call_worst_case).
Code references verified against main at v0.2.1.
TokenOps assumes the model identifier is known before a call is made. For stacks that route through a gateway which picks the model at request time, it is not — and the actual choice never reaches TokenOps at all, in either direction.
1. Context
TokenOps caps spend for a whole agent workflow and enforces it before every model call. Money is computed by a
PriceFn—(provider, model, Usage) -> Micros— so every governance decision depends on knowing which model is being called.Two policies consume that, at opposite ends of a call:
pre_call_worst_casecost_budgetUsageobserved, to decide whether the budget is now exhaustedThe model string flows from the caller, through
wrap_complete, into both:After the call, the crossing hook records an
Observation, taking the model from the dispatch arguments:statehere is the dispatch call's arguments. So the model recorded post-call is the model that was passed in, not the model that answered.2. Problem
A gateway — OpenRouter auto-routing, a LiteLLM proxy with fallbacks, a Bedrock or Vertex router, a Portkey config — accepts an alias (
auto, a routing profile, a virtual key) and chooses a concrete model at request time. Selection can depend on load, price, availability, or a fallback chain after a 429.That breaks the assumption in both directions.
Before the call: nothing to price
pre_call_worst_casereceives the alias. Two outcomes, both wrong:There is no third option today: the policy has no way to express "the model is not yet known".
After the call: the answer never arrives
Post-call billing could in principle recover, since the gateway knows what it used. It does not, because the dispatch contract has nowhere to put it:
No model field. Combined with
crossing.py:153reading the model from the request arguments, a gateway that returnsmodel: "anthropic/claude-sonnet-4-5"in its response body has no way to tell TokenOps. The ledger bills the alias, and per-model attribution in the dashboard is wrong for every gateway user.Some gateways go further and return a computed
costdirectly, which TokenOps has no way to accept either — it always prices from its own table.Blast radius
Any deployment fronting models with a router. That is a common production shape: teams adopt a gateway precisely to get failover and cost routing, which are the same features that make the model unpredictable.
cost_budgetstill functions as a backstop if an alias rate exists, but attribution is wrong andpre_call_worst_caseis unusable.3. Proposed approach
Sketch only — this needs design agreement before implementation.
Let the model be unknown until the response. Treat "resolved at request time" as a first-class case rather than an error, so the pre-call path can degrade deliberately instead of tripping.
Let dispatch report what actually answered. Add an optional
model(and possiblycost_micros) toModelResponse, and prefer it over the request argument incrossing.py:153when present. This is the smallest change with the largest effect: it fixes post-call attribution and billing accuracy on its own, independently of anything pre-call.Then choose a pre-call strategy. Options worth weighing:
pre_call_worst_casefor gateway runscost_budgetas backstopThe ceiling option is the only one that keeps
pre_call_worst_casemeaningful, and it composes with #128's rate map — the ceiling is just another key.4. Tradeoffs and non-scope
wrap_streamand time-windowed accrual are Multimodal stream governance: implement wrap_stream and time-windowed accrual (spec) #110.5. Acceptance
pre_call_worst_caseeither has a defined behaviour for unknown models or is documented as inapplicableRelated: #128 (rate resolution — assumes the model is known, and says so), #129 (failure-mode postures), #110 (streaming governance), #57 (output-token prediction for
pre_call_worst_case).Code references verified against
mainat v0.2.1.