fix(ehbp): bill the routed model object, not its bare id via the alias map

The cache discount still did not apply after 3c813da because the EHBP
finalization path never handed the serving ``Model`` to ``calculate_cost``:
it passed only the model *string* (``{"model": pricing_model_id, ...}``),
so ``_get_pricing_rates`` fell into its "settling without routed model
identity" branch and re-derived pricing through the global alias map.

That lookup resolves the string to the *best-ranked* candidate for the
id, not the serving one. Tinfoil's catalog id (``deepseek-v4-1-flash``)
is also a bare cross-provider alias, and the cheaper cross-provider
candidate carries ``input_cache_read=0``. ``_get_pricing_rates`` treats a
zero cache rate as "missing" and falls back to the full input price, so
every request billed at the undiscounted rate (and at the cross-provider
price, ~28% low): production showed ``Applied model-specific pricing:
input=494.55, cache_read=494.55`` with ``source=configured`` even though
the enclave reported ``cached_prompt_tokens=12800``.

The sibling path is correct: ``BaseUpstreamProvider.get_x_cashu_cost``
already passes ``model_obj`` to ``calculate_cost``. The EHBP path is the
only caller that omitted it.

Fix: thread the pricing model object through ``_compute_ehbp_actual_cost``
— the routed ``model_obj`` normally, or the resolved served model on a
genuine mismatch — and pass it to ``calculate_cost``. The requested model
string stays in ``response_data["model"]`` for logging. 3c813da's
namespace resolution is still what decides *which* object to bill; this
commit makes that object actually reach pricing.

Verified numerically in the new regression test: 12800 cached tokens at
the Tinfoil cache rate (~112 msat/1k) bill ~1434 msats instead of ~8800
msats at the full input rate.

Tests: 2 new in tests/unit/test_tinfoil_integration.py; full suite 2001
passed, 14 skipped. ruff check + mypy clean.
This commit is contained in:
redshift
2026-09-17 22:50:23 +02:00
parent 3c813daedc
commit 6c91eac02c
2 changed files with 114 additions and 0 deletions
+10
View File
@@ -443,6 +443,14 @@ async def _compute_ehbp_actual_cost(
# look up the actual model's pricing.
actual_model: str | None = usage_dict.pop("model", None) # type: ignore[arg-type]
pricing_model_id = model_obj.id
# Bill the model we actually routed to. Passing only the model *string*
# to calculate_cost makes it re-derive pricing from the global alias map,
# which resolves the id to the best-ranked candidate — not the serving
# one. Tinfoil's catalog id (e.g. ``deepseek-v4-1-flash``) is also a
# cross-provider alias, and that cheaper candidate has no cache rate, so
# the cache discount silently disappeared (and the request was
# undercharged). Hand calculate_cost the identity it cannot reconstruct.
pricing_model_obj: Model = model_obj
expected_upstream_model = model_obj.forwarded_model_id or model_obj.id
expected_identity = _normalize_upstream_model_id(expected_upstream_model)
served_identity = _normalize_upstream_model_id(actual_model)
@@ -504,6 +512,7 @@ async def _compute_ehbp_actual_cost(
},
)
pricing_model_id = actual_model_obj.id
pricing_model_obj = actual_model_obj
else:
# A different registry/client alias resolved to the same
# upstream model; retain the requested model's pricing.
@@ -516,6 +525,7 @@ async def _compute_ehbp_actual_cost(
cost = await calculate_cost(
{"model": pricing_model_id, "usage": usage_dict},
max_cost_for_model,
pricing_model_obj,
)
except Exception as e:
logger.warning(
+104
View File
@@ -608,6 +608,110 @@ class TestComputeEhbpActualCost:
call_args = mock_calc.call_args
assert call_args[0][0]["model"] == "tinfoil-glm-5-3-flash"
@pytest.mark.asyncio
async def test_calculate_cost_receives_routed_model_obj(self) -> None:
"""The routed ``Model`` is handed to ``calculate_cost`` so pricing is
billed directly. Without it, ``calculate_cost`` re-derives pricing from
the response's model *string* through the global alias map, which
resolves a bare id to the best-ranked (cheaper) cross-provider
candidate rather than the serving one."""
model_obj = MagicMock()
model_obj.id = "deepseek-v4-1-flash"
model_obj.forwarded_model_id = "tinfoil-deepseek-v4-1-flash"
resolved = MagicMock()
resolved.id = "tinfoil-deepseek-v4-1-flash"
resolved.forwarded_model_id = "tinfoil-deepseek-v4-1-flash"
with (
patch(
"routstr.proxy.get_model_instance",
return_value=resolved,
),
patch(
"routstr.upstream.ehbp.calculate_cost",
new_callable=AsyncMock,
) as mock_calc,
):
from routstr.payment.cost_calculation import CostData
mock_calc.return_value = CostData(
base_msats=0,
input_msats=5,
output_msats=10,
total_msats=15,
total_usd=0.0,
input_tokens=5,
output_tokens=10,
)
await _compute_ehbp_actual_cost(
"prompt=12952,completion=1,total=12953,"
"cached_prompt_tokens=12800,uncached_prompt_tokens=152,"
"model=deepseek-v4-1-flash,cost_usd=0.00176715",
model_obj,
100_000,
)
# The routed model object itself must be passed through.
assert mock_calc.call_args[0][2] is model_obj
@pytest.mark.asyncio
async def test_routed_model_cache_rate_beats_bare_id_alias(self) -> None:
"""Production regression: the routed Tinfoil model's id *is* a bare
cross-provider alias, so re-deriving pricing from the echoed model
string silently swapped in the cheaper candidate's full input rate and
the cache discount vanished. Billing must use the routed model's own
discounted cache rate."""
from routstr.payment.models import Pricing
model_obj = MagicMock()
model_obj.id = "deepseek-v4-1-flash"
model_obj.forwarded_model_id = "tinfoil-deepseek-v4-1-flash"
# ~688 msat/1k input, ~112 msat/1k cached read (the good Tinfoil rate).
model_obj.sats_pricing = Pricing(
prompt=6.88e-4,
completion=2.0e-3,
input_cache_read=1.12e-4,
)
# The cross-provider candidate the bare id resolves to globally: no
# cache rate at all, so a re-derivation charges the full input rate.
cross_provider_model = MagicMock()
cross_provider_model.id = "deepseek-v4-1-flash"
cross_provider_model.forwarded_model_id = "deepseek-v4-1-flash"
cross_provider_model.sats_pricing = Pricing(
prompt=4.9455e-4,
completion=2.0e-3,
input_cache_read=0.0,
)
registry = {
"tinfoil-deepseek-v4-1-flash": model_obj,
"deepseek-v4-1-flash": cross_provider_model,
}
with (
patch(
"routstr.proxy.get_model_instance",
side_effect=lambda name: registry.get(name),
),
patch(
"routstr.payment.cost_calculation.sats_usd_price",
return_value=5.0e-5,
),
):
result = await _compute_ehbp_actual_cost(
"prompt=12952,completion=1,total=12953,"
"cached_prompt_tokens=12800,uncached_prompt_tokens=152,"
"model=deepseek-v4-1-flash,cost_usd=0.00176715",
model_obj,
100_000,
)
assert result["cache_read_input_tokens"] == 12800
# 12800 cached tokens at the discounted (~112 msat/1k) rate, not the
# full input rate (which would be ~8800 msat here).
assert result["cache_read_msats"] == pytest.approx(1434, abs=10)
@pytest.mark.asyncio
async def test_model_mismatch_unknown_model_falls_back(self) -> None:
"""When the served model is not in the registry, use requested model."""