mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user