diff --git a/strix/report/pricing.py b/strix/report/pricing.py index 57c899592..028c04596 100644 --- a/strix/report/pricing.py +++ b/strix/report/pricing.py @@ -6,6 +6,29 @@ from functools import lru_cache from typing import Any, cast +def _preferred_route( + matches: list[str], + model_cost: dict[str, dict[str, Any]], + name: str, +) -> str: + """Pick the provider's own route when several equally priced ones match. + + LiteLLM lists the same model under its native provider (``xai/grok-4.5``) and + under the aggregators that resell it (``openrouter/x-ai/grok-4.5``, + ``perplexity/xai/grok-4.5``). Alphabetical order would hand back an + aggregator; the canonical route is the one shaped exactly + ``{litellm_provider}/{name}``. + """ + for key in matches: + entry = model_cost.get(key) + if not isinstance(entry, dict): + continue + provider = entry.get("litellm_provider") + if isinstance(provider, str) and key == f"{provider}/{name}": + return key + return matches[0] + + @lru_cache(maxsize=512) def resolve_litellm_model(model: str) -> str | None: """Return a provider-qualified model name that LiteLLM can price.""" @@ -48,7 +71,7 @@ def resolve_litellm_model(model: str) -> str | None: if isinstance(model_cost.get(key), dict) } if len(matches) == 1 or len(prices) == 1: - return matches[0] + return _preferred_route(matches, model_cost, name) return None # noqa: TRY300 except Exception: # noqa: BLE001 return None diff --git a/tests/test_pricing.py b/tests/test_pricing.py index c9e9e5dfb..c97736789 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -20,6 +20,35 @@ def test_resolves_common_bare_model_names() -> None: assert resolve_litellm_model("minimax/MiniMax-M3") == "minimax/MiniMax-M3" +def test_prefers_native_provider_route_over_aggregators() -> None: + """Equally priced routes must resolve to the provider's own, not the first alphabetically.""" + resolve_litellm_model.cache_clear() + model_cost = { + "openrouter/x-ai/grok-4.5": { + "litellm_provider": "openrouter", + "input_cost_per_token": 2e-06, + "output_cost_per_token": 6e-06, + }, + "perplexity/xai/grok-4.5": { + "litellm_provider": "perplexity", + "input_cost_per_token": 2e-06, + "output_cost_per_token": 6e-06, + }, + "xai/grok-4.5": { + "litellm_provider": "xai", + "input_cost_per_token": 2e-06, + "output_cost_per_token": 6e-06, + }, + } + with patch.object(litellm, "model_cost", model_cost): + assert resolve_litellm_model("grok-4.5") == "xai/grok-4.5" + + # An explicitly aggregator-qualified name still resolves to that aggregator. + resolve_litellm_model.cache_clear() + with patch.object(litellm, "model_cost", model_cost): + assert resolve_litellm_model("x-ai/grok-4.5") == "openrouter/x-ai/grok-4.5" + + def test_resolver_returns_none_for_unresolvable_model() -> None: resolve_litellm_model.cache_clear() assert resolve_litellm_model("provider/not-a-real-model") is None