From ae9748db026ffaa877d2e68c07dfe45a2b866864 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Sun, 5 Jul 2026 15:40:43 +0200 Subject: [PATCH] fix(upstream): break OpenRouter bare-tail ties by highest price When a bare model id matched several OpenRouter entries by tail, the first feed entry won, making the resolved price depend on feed order and risking an undercharge if a cheaper reseller happened to sort first. Pick the highest-priced candidate instead: deterministic and money-safe, since undercharging is the hazard. An exact id match still wins ahead of any tail match. Measured against the live feed there are zero bare-tail collisions today, so this only governs the latent case. Co-Authored-By: Claude Opus 4.8 --- routstr/upstream/pricing_resolver.py | 14 +++++++--- tests/unit/test_upstream_generic.py | 38 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/routstr/upstream/pricing_resolver.py b/routstr/upstream/pricing_resolver.py index f338c12e..f956334c 100644 --- a/routstr/upstream/pricing_resolver.py +++ b/routstr/upstream/pricing_resolver.py @@ -121,14 +121,22 @@ def _match_openrouter(model_id: str, feed: list[dict]) -> dict | None: Bare-tail matching (``deepseek-chat`` ↔ ``deepseek/deepseek-chat``) is a looser, lower-trust match — OpenRouter fans a model out across resellers — - so an exact id match always wins first. + so an exact id match always wins first. When several entries share the bare + tail, the highest-priced one wins: the choice must be deterministic (not + feed-order-dependent) and money-safe, since undercharging is the hazard. + The live feed has no such collisions today; this only governs the latent + case. """ bare = model_id.split("/", 1)[-1] exact = next((m for m in feed if m.get("id") == model_id), None) if exact is not None: return exact - return next( - (m for m in feed if m.get("id", "").split("/", 1)[-1] == bare), None + matches = [m for m in feed if m.get("id", "").split("/", 1)[-1] == bare] + if not matches: + return None + return max( + matches, + key=lambda m: _as_float(m.get("pricing", {}).get("prompt")) or 0.0, ) diff --git a/tests/unit/test_upstream_generic.py b/tests/unit/test_upstream_generic.py index 28bec0ff..65c9ada2 100644 --- a/tests/unit/test_upstream_generic.py +++ b/tests/unit/test_upstream_generic.py @@ -266,6 +266,44 @@ async def test_unknown_to_litellm_resolves_via_openrouter() -> None: or_feed.assert_awaited() +@pytest.mark.asyncio +async def test_openrouter_bare_tail_collision_picks_highest_price() -> None: + """When a bare model id matches several OpenRouter entries by tail + (``model`` ↔ ``a/model``, ``b/model``), the match must be deterministic and + money-safe: pick the highest-priced candidate regardless of feed order, so + ordering can never leave the node charging below true cost. (The live feed + has zero such collisions today; this guards the latent case.)""" + payload = { + "data": [ + {"id": "zzz-phantom-model", "object": "model", "owned_by": "mystery"}, + ] + } + # Same bare tail, different resellers; the pricier one is listed *second* + # so a first-wins match would pick the cheaper (undercharging) entry. + or_feed = AsyncMock( + return_value=[ + { + "id": "cheapco/zzz-phantom-model", + "context_length": 8192, + "pricing": {"prompt": "0.000001", "completion": "0.000002"}, + }, + { + "id": "premiumco/zzz-phantom-model", + "context_length": 8192, + "pricing": {"prompt": "0.000009", "completion": "0.000010"}, + }, + ] + ) + + with _patch_models_endpoint(payload): + with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed): + models = await GenericUpstreamProvider(base_url="http://x").fetch_models() + + model = _model_by_id(models, "zzz-phantom-model") + assert model.pricing.prompt == pytest.approx(9e-06) + assert model.pricing.completion == pytest.approx(1e-05) + + @pytest.mark.asyncio async def test_openrouter_feed_fetched_once_per_discovery() -> None: """Two models both missing litellm must share a single OpenRouter fetch —