From 2f1d36ae5fc607dcc5ce0a9e47a96c1d0095261d Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sun, 20 Sep 2026 15:37:41 +0200 Subject: [PATCH] feat: drop provider id from model paths and route them to the cheapest provider --- docs/api/endpoints.md | 30 ++++--- routstr/proxy.py | 24 ++++-- routstr/upstream/model_paths.py | 95 +++++++++++---------- tests/unit/test_model_path_routing.py | 115 +++++++++++++++++++------ tests/unit/test_model_paths.py | 118 ++++++++++++++++++++++++-- 5 files changed, 283 insertions(+), 99 deletions(-) diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 8e524c72..9b0ef3f7 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -345,13 +345,13 @@ GET /v1/models/paths "id": "anthropic/claude-sonnet-4", "paths": [ { - "path": "url=https%3A%2F%2Fapi.anthropic.com%2Fv1&provider-id=12&model-id=anthropic%2Fclaude-sonnet-4", - "provider": {"id": 12, "slug": "anthropic-primary", "type": "anthropic"}, + "path": "url=https%3A%2F%2Fapi.anthropic.com%2Fv1&model-id=anthropic%2Fclaude-sonnet-4", + "provider": {"slug": "anthropic-primary", "type": "anthropic"}, "endpoint": null }, { - "path": "url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1&provider-id=42&model-id=anthropic%2Fclaude-sonnet-4&endpoint=google-vertex%2Fus", - "provider": {"id": 42, "slug": "openrouter-main", "type": "openrouter"}, + "path": "url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1&model-id=anthropic%2Fclaude-sonnet-4&endpoint=google-vertex%2Fus", + "provider": {"slug": "openrouter-main", "type": "openrouter"}, "endpoint": {"tag": "google-vertex/us", "name": "Google"} } ] @@ -362,13 +362,21 @@ GET /v1/models/paths ``` `path` is an opaque, percent-encoded selector. Clients must store and return it -unchanged rather than parsing or reconstructing it. It identifies the exact -configured route with `url`, `provider-id`, and `model-id`. To avoid exposing -private network details, a configured private IP address or any URL with an -explicit port is advertised as `http://localhost`. OpenRouter routes additionally -preserve the exact machine-readable endpoint `tag`. Provider slugs/types and -endpoint names remain display data. When request-side selection is implemented, -an endpoint tag must not silently fall back to another backend. +unchanged rather than parsing or reconstructing it. It identifies the route with +`url` and `model-id`. To avoid exposing private network details, a configured +private IP address or any URL with an explicit port is advertised as +`http://localhost`. OpenRouter routes additionally preserve the exact +machine-readable endpoint `tag`. Provider slugs/types and endpoint names remain +display data. When request-side selection is implemented, an endpoint tag must +not silently fall back to another backend. + +A path names no provider, so several configured providers sharing an upstream URL +collapse onto a single path. Such a path always routes to the cheapest of those +providers, and the advertised slug, type and pricing describe that same cheapest +provider. A pinned path never fails over: if the selected provider errors, the +error is returned rather than retried elsewhere. Paths issued before this change +still carry `provider-id` and are still honoured, pinning the exact provider they +name. ### List Paths for One Model diff --git a/routstr/proxy.py b/routstr/proxy.py index 1f517779..491dbc77 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -132,12 +132,18 @@ def _candidate_for_selector( selector: ModelPathSelector, candidates: list[tuple[Model, BaseUpstreamProvider]], ) -> tuple[Model, BaseUpstreamProvider] | None: + """Resolve a route selector to one candidate. + + ``candidates`` is ranked by cost, so the first URL match is the cheapest + provider configured against that URL. A selector still carrying a legacy + ``provider-id`` keeps pinning that exact provider instead. + """ for model_obj, upstream in candidates: - if ( - upstream.db_id == selector.provider_id - and public_provider_url(upstream.base_url) == selector.base_url - ): - return model_obj, upstream + if public_provider_url(upstream.base_url) != selector.base_url: + continue + if selector.provider_id is not None and upstream.db_id != selector.provider_id: + continue + return model_obj, upstream return None @@ -536,10 +542,14 @@ async def _proxy( if selector is not None: pinned = _candidate_for_selector(selector, candidates) if pinned is None: + target = ( + f"provider {selector.provider_id}" + if selector.provider_id is not None + else f"'{selector.base_url}'" + ) return create_error_response( "invalid_model_path", - f"Model '{selector.model_id}' is not routable through provider " - f"{selector.provider_id}", + f"Model '{selector.model_id}' is not routable through {target}", 404, request=request, ) diff --git a/routstr/upstream/model_paths.py b/routstr/upstream/model_paths.py index 092bd318..fedb042e 100644 --- a/routstr/upstream/model_paths.py +++ b/routstr/upstream/model_paths.py @@ -2,11 +2,16 @@ Exposes every selectable upstream route a Routstr model is reachable through. A path is a standard percent-encoded query string containing the configured -upstream URL, provider ID, client-visible model ID and, for an exact OpenRouter -endpoint, its machine-readable tag:: +upstream URL, client-visible model ID and, for an exact OpenRouter endpoint, +its machine-readable tag:: - url=https%3A%2F%2Fapi.anthropic.com%2Fv1&provider-id=12&model-id=claude-sonnet-4 - url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1&provider-id=42&model-id=claude-sonnet-4&endpoint=google-vertex%2Fus + url=https%3A%2F%2Fapi.anthropic.com%2Fv1&model-id=claude-sonnet-4 + url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1&model-id=claude-sonnet-4&endpoint=google-vertex%2Fus + +A path names no provider, so several providers sharing an upstream URL collapse +onto one selector that routes to the cheapest of them. ``provider-id`` is still +accepted when decoding so paths issued before that change keep pinning the exact +provider they named. """ from __future__ import annotations @@ -16,6 +21,7 @@ import ipaddress import json import random import time +from collections.abc import Iterable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable from urllib.parse import parse_qsl, urlencode, urlsplit @@ -117,16 +123,11 @@ def public_provider_url(base_url: str) -> str: def encode_model_path( base_url: str, - provider_id: int, model_id: str, endpoint_tag: str | None = None, ) -> str: - """Encode the complete upstream route selector advertised to clients.""" - components: list[tuple[str, str | int]] = [ - ("url", base_url), - ("provider-id", provider_id), - ("model-id", model_id), - ] + """Encode the upstream route selector advertised to clients.""" + components = [("url", base_url), ("model-id", model_id)] if endpoint_tag: components.append(("endpoint", endpoint_tag)) return urlencode(components) @@ -137,9 +138,9 @@ class ModelPathSelector: """Decoded client-supplied route selector.""" base_url: str - provider_id: int model_id: str endpoint_tag: str | None = None + provider_id: int | None = None def decode_model_path(path: str) -> ModelPathSelector | None: @@ -166,20 +167,21 @@ def decode_model_path(path: str) -> ModelPathSelector | None: return None base_url = params.get("url", "") model_id = params.get("model-id", "") - raw_provider_id = params.get("provider-id", "") - if not base_url or not model_id or not raw_provider_id: - return None - try: - provider_id = int(raw_provider_id) - except ValueError: - return None - if provider_id <= 0: + if not base_url or not model_id: return None + provider_id: int | None = None + if (raw_provider_id := params.get("provider-id")) is not None: + try: + provider_id = int(raw_provider_id) + except ValueError: + return None + if provider_id <= 0: + return None return ModelPathSelector( base_url=base_url, - provider_id=provider_id, model_id=model_id, endpoint_tag=params.get("endpoint") or None, + provider_id=provider_id, ) @@ -490,9 +492,7 @@ async def _collect_provider_paths( model_id = exposed_model_id(model) return DiscoveredPath( model_id=model_id, - path=encode_model_path( - provider_identity.base_url, provider_identity.id, model_id - ), + path=encode_model_path(provider_identity.base_url, model_id), provider=provider_identity, model_metadata=_serialize_model_metadata(model, model_id), ) @@ -529,7 +529,6 @@ async def _collect_provider_paths( model_id=model_id, path=encode_model_path( provider_identity.base_url, - provider_identity.id, model_id, endpoint.tag, ), @@ -900,7 +899,6 @@ def _serialize_path(row: ModelPathRow, provider_fee: float) -> dict[str, Any]: return { "path": row.path, "provider": { - "id": row.upstream_provider_id, "slug": row.provider_slug, "type": row.provider_type, }, @@ -914,6 +912,25 @@ async def _provider_fees(session: "AsyncSession") -> dict[int, float]: return {row.id: row.provider_fee for row in rows if row.id is not None} +def _cheapest_rows_by_path( + rows: Iterable[ModelPathRow], fees: dict[int, float] +) -> list[tuple[ModelPathRow, float]]: + """Collapse rows sharing a path onto the cheapest provider, with its fee. + + Routing sends a provider-less path to the cheapest provider on that URL, so + the advertised slug and pricing must come from that same provider or clients + are quoted a price they will never be charged. + """ + best: dict[tuple[str, str], tuple[ModelPathRow, float]] = {} + for row in rows: + key = (row.model_id, row.path) + fee = fees.get(row.upstream_provider_id, 1.01) + incumbent = best.get(key) + if incumbent is None or fee < incumbent[1]: + best[key] = (row, fee) + return list(best.values()) + + async def get_all_model_paths() -> dict: """All models with exact routes and provider-specific model metadata.""" async with create_session() as session: @@ -929,16 +946,9 @@ async def get_all_model_paths() -> dict: fees = await _provider_fees(session) grouped: dict[str, list[dict[str, Any]]] = {} - seen_paths: dict[str, set[str]] = {} - updated_at = 0 - for row in rows: - updated_at = max(updated_at, row.updated_at) - if row.path in seen_paths.setdefault(row.model_id, set()): - continue - seen_paths[row.model_id].add(row.path) - grouped.setdefault(row.model_id, []).append( - _serialize_path(row, fees.get(row.upstream_provider_id, 1.01)) - ) + updated_at = max((row.updated_at for row in rows), default=0) + for row, fee in _cheapest_rows_by_path(rows, fees): + grouped.setdefault(row.model_id, []).append(_serialize_path(row, fee)) data = [ { "id": grouped_model_id, @@ -974,13 +984,8 @@ async def get_paths_for_model(model_id: str) -> dict: rows = await load_rows(session, unprefixed_id) fees = await _provider_fees(session) - seen: set[str] = set() - paths: list[dict] = [] - updated_at = 0 - for row in rows: - updated_at = max(updated_at, row.updated_at) - if row.path in seen: - continue - seen.add(row.path) - paths.append(_serialize_path(row, fees.get(row.upstream_provider_id, 1.01))) + updated_at = max((row.updated_at for row in rows), default=0) + paths = [ + _serialize_path(row, fee) for row, fee in _cheapest_rows_by_path(rows, fees) + ] return {"data": paths, "updated_at": updated_at or None} diff --git a/tests/unit/test_model_path_routing.py b/tests/unit/test_model_path_routing.py index 0b713652..4dfa2ef8 100644 --- a/tests/unit/test_model_path_routing.py +++ b/tests/unit/test_model_path_routing.py @@ -75,17 +75,31 @@ async def _run_proxy( def test_decode_model_path_round_trips_encode() -> None: selector = decode_model_path( - encode_model_path("https://openrouter.ai/api/v1", 7, MODEL_ID, "deepinfra/fp8") + encode_model_path("https://openrouter.ai/api/v1", MODEL_ID, "deepinfra/fp8") ) assert selector is not None assert selector.base_url == "https://openrouter.ai/api/v1" - assert selector.provider_id == 7 + assert selector.provider_id is None assert selector.model_id == MODEL_ID assert selector.endpoint_tag == "deepinfra/fp8" +def test_encoded_path_carries_no_provider_id() -> None: + assert "provider-id" not in encode_model_path("http://localhost", MODEL_ID) + + +def test_decode_model_path_still_accepts_a_legacy_provider_id() -> None: + selector = decode_model_path( + "url=http%3A%2F%2Flocalhost&provider-id=7&model-id=test-model" + ) + assert selector is not None + assert selector.provider_id == 7 + assert selector.base_url == "http://localhost" + assert selector.model_id == MODEL_ID + + def test_decode_model_path_without_endpoint_has_no_tag() -> None: - selector = decode_model_path(encode_model_path("http://localhost", 1, MODEL_ID)) + selector = decode_model_path(encode_model_path("http://localhost", MODEL_ID)) assert selector is not None assert selector.endpoint_tag is None @@ -94,10 +108,12 @@ def test_decode_model_path_without_endpoint_has_no_tag() -> None: "path", [ "", - "url=http://localhost&model-id=test-model", "url=http://localhost&provider-id=abc&model-id=test-model", + "url=http://localhost&provider-id=0&model-id=test-model", "provider-id=1&model-id=test-model", "url=http://localhost&provider-id=1", + "model-id=test-model", + "url=http://localhost", ], ) def test_decode_model_path_rejects_malformed_selectors(path: str) -> None: @@ -105,20 +121,63 @@ def test_decode_model_path_rejects_malformed_selectors(path: str) -> None: @pytest.mark.asyncio -async def test_model_path_routes_to_the_selected_provider() -> None: - first, selected = _make_upstream(1), _make_upstream(2) +async def test_model_path_routes_to_the_cheapest_provider_sharing_the_url() -> None: + # get_candidates ranks by cost, so the first match for a provider-less + # selector is the cheapest provider configured against that URL. + cheapest, pricier = _make_upstream(1), _make_upstream(2) request = _make_request( { "authorization": "Bearer sk-mpkey", - "x-routstr-model-path": encode_model_path("http://localhost", 2, MODEL_ID), + "x-routstr-model-path": encode_model_path("http://localhost", MODEL_ID), }, json.dumps({"model": MODEL_ID}).encode(), ) - await _run_proxy(request, [(MagicMock(), first), (MagicMock(), selected)]) + await _run_proxy(request, [(MagicMock(), cheapest), (MagicMock(), pricier)]) - selected.forward_request.assert_awaited_once() - first.forward_request.assert_not_awaited() + cheapest.forward_request.assert_awaited_once() + pricier.forward_request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cheapest_provider_failure_does_not_fall_back_to_the_pricier_one() -> ( + None +): + cheapest, pricier = _make_upstream(1, status_code=503), _make_upstream(2) + request = _make_request( + { + "authorization": "Bearer sk-mpkey", + "x-routstr-model-path": encode_model_path("http://localhost", MODEL_ID), + }, + json.dumps({"model": MODEL_ID}).encode(), + ) + + response = await _run_proxy( + request, [(MagicMock(), cheapest), (MagicMock(), pricier)] + ) + + assert response.status_code == 503 + cheapest.forward_request.assert_awaited_once() + pricier.forward_request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_legacy_provider_id_still_pins_that_exact_provider() -> None: + cheapest, pinned = _make_upstream(1), _make_upstream(2) + request = _make_request( + { + "authorization": "Bearer sk-mpkey", + "x-routstr-model-path": ( + f"url=http%3A%2F%2Flocalhost&provider-id=2&model-id={MODEL_ID}" + ), + }, + json.dumps({"model": MODEL_ID}).encode(), + ) + + await _run_proxy(request, [(MagicMock(), cheapest), (MagicMock(), pinned)]) + + pinned.forward_request.assert_awaited_once() + cheapest.forward_request.assert_not_awaited() @pytest.mark.asyncio @@ -131,7 +190,7 @@ async def test_model_path_failure_is_returned_without_falling_back( request = _make_request( { "authorization": "Bearer sk-mpkey", - "x-routstr-model-path": encode_model_path("http://localhost", 1, MODEL_ID), + "x-routstr-model-path": encode_model_path("http://localhost", MODEL_ID), }, json.dumps({"model": MODEL_ID}).encode(), ) @@ -146,12 +205,14 @@ async def test_model_path_failure_is_returned_without_falling_back( @pytest.mark.asyncio -async def test_unknown_provider_in_model_path_is_rejected() -> None: +async def test_unknown_legacy_provider_in_model_path_is_rejected() -> None: upstream = _make_upstream(1) request = _make_request( { "authorization": "Bearer sk-mpkey", - "x-routstr-model-path": encode_model_path("http://localhost", 99, MODEL_ID), + "x-routstr-model-path": ( + f"url=http%3A%2F%2Flocalhost&provider-id=99&model-id={MODEL_ID}" + ), }, json.dumps({"model": MODEL_ID}).encode(), ) @@ -170,7 +231,7 @@ async def test_model_path_disagreeing_with_the_body_model_is_rejected() -> None: { "authorization": "Bearer sk-mpkey", "x-routstr-model-path": encode_model_path( - "http://localhost", 1, "other-model" + "http://localhost", "other-model" ), }, json.dumps({"model": MODEL_ID}).encode(), @@ -207,7 +268,7 @@ async def test_endpoint_tag_pins_the_upstream_subprovider() -> None: { "authorization": "Bearer sk-mpkey", "x-routstr-model-path": encode_model_path( - "https://openrouter.ai/api/v1", 1, MODEL_ID, "deepinfra/fp8" + "https://openrouter.ai/api/v1", MODEL_ID, "deepinfra/fp8" ), }, json.dumps({"model": MODEL_ID}).encode(), @@ -245,7 +306,7 @@ async def test_selector_url_must_match_configured_provider() -> None: { "authorization": "Bearer key", "x-routstr-model-path": encode_model_path( - "http://169.254.169.254", 1, MODEL_ID + "http://169.254.169.254", MODEL_ID ), }, json.dumps({"model": MODEL_ID}).encode(), @@ -266,7 +327,7 @@ async def test_endpoint_pin_cannot_be_stripped_on_retry() -> None: { "authorization": "Bearer key", "x-routstr-model-path": encode_model_path( - upstream.base_url, 1, MODEL_ID, "deepinfra/fp8" + upstream.base_url, MODEL_ID, "deepinfra/fp8" ), }, json.dumps({"model": MODEL_ID}).encode(), @@ -298,7 +359,7 @@ async def test_cashu_receives_endpoint_pinned_body(path: str, handler: str) -> N { "x-cashu": "test-token", "x-routstr-model-path": encode_model_path( - upstream.base_url, 1, MODEL_ID, "deepinfra/fp8" + upstream.base_url, MODEL_ID, "deepinfra/fp8" ), }, json.dumps( @@ -354,7 +415,7 @@ async def test_cashu_pin_reaches_http_transport(path: str, status_code: int) -> { "x-cashu": "test-token", "x-routstr-model-path": encode_model_path( - upstream.base_url, 1, MODEL_ID, "deepinfra/fp8" + upstream.base_url, MODEL_ID, "deepinfra/fp8" ), }, json.dumps({"model": MODEL_ID, "provider": {"allow_fallbacks": True}}).encode(), @@ -418,7 +479,7 @@ async def test_pinned_exception_does_not_fall_back() -> None: request = _make_request( { "authorization": "Bearer key", - "x-routstr-model-path": encode_model_path(first.base_url, 1, MODEL_ID), + "x-routstr-model-path": encode_model_path(first.base_url, MODEL_ID), }, json.dumps({"model": MODEL_ID}).encode(), ) @@ -442,7 +503,7 @@ async def test_unsupported_endpoint_pins_fail_before_payment( headers = { "x-cashu": "test-token", "x-routstr-model-path": encode_model_path( - upstream.base_url, 1, MODEL_ID, "deepinfra/fp8" + upstream.base_url, MODEL_ID, "deepinfra/fp8" ), } if is_ehbp: @@ -471,7 +532,7 @@ async def test_ehbp_pin_does_not_fall_back(cashu: bool) -> None: headers = { "ehbp-encapsulated-key": "sealed", "x-routstr-model": MODEL_ID, - "x-routstr-model-path": encode_model_path(selected.base_url, 1, MODEL_ID), + "x-routstr-model-path": encode_model_path(selected.base_url, MODEL_ID), } headers.update({"x-cashu": "token"} if cashu else {"authorization": "Bearer key"}) request = _make_request(headers, b"encrypted-body") @@ -495,7 +556,7 @@ async def test_duplicate_header_fields_are_rejected() -> None: from starlette.datastructures import Headers selected = _make_upstream(1) - route = encode_model_path(selected.base_url, 1, MODEL_ID).encode() + route = encode_model_path(selected.base_url, MODEL_ID).encode() request = _make_request({}, json.dumps({"model": MODEL_ID}).encode()) request.headers = Headers( raw=[ @@ -512,7 +573,7 @@ async def test_duplicate_header_fields_are_rejected() -> None: @pytest.mark.asyncio async def test_attestation_does_not_ignore_model_path() -> None: request = _make_request( - {"x-routstr-model-path": encode_model_path("http://localhost", 1, MODEL_ID)}, + {"x-routstr-model-path": encode_model_path("http://localhost", MODEL_ID)}, b"", ) request.method = "GET" @@ -528,7 +589,7 @@ async def test_model_fallback_list_is_rejected_when_pinned() -> None: request = _make_request( { "authorization": "Bearer key", - "x-routstr-model-path": encode_model_path(selected.base_url, 1, MODEL_ID), + "x-routstr-model-path": encode_model_path(selected.base_url, MODEL_ID), }, json.dumps({"model": MODEL_ID, "models": ["other-model"]}).encode(), ) @@ -564,7 +625,7 @@ async def test_pinned_recovery_stays_on_selected_provider( { "authorization": "Bearer key", "x-routstr-model-path": encode_model_path( - selected.base_url, 1, MODEL_ID, endpoint + selected.base_url, MODEL_ID, endpoint ), }, json.dumps( @@ -608,7 +669,7 @@ async def test_pinned_recovery_preserves_routing_fields( { "authorization": "Bearer key", "x-routstr-model-path": encode_model_path( - selected.base_url, 1, MODEL_ID, endpoint + selected.base_url, MODEL_ID, endpoint ), }, json.dumps( diff --git a/tests/unit/test_model_paths.py b/tests/unit/test_model_paths.py index caab67b9..657d45a4 100644 --- a/tests/unit/test_model_paths.py +++ b/tests/unit/test_model_paths.py @@ -272,6 +272,17 @@ async def _set_provider_fee(engine: AsyncEngine, provider_id: int, fee: float) - await session.commit() +async def _set_provider_base_url( + engine: AsyncEngine, provider_id: int, base_url: str +) -> None: + async with AsyncSession(engine) as session: + provider = await session.get(UpstreamProviderRow, provider_id) + assert provider is not None + provider.base_url = base_url + session.add(provider) + await session.commit() + + def _paths_of(payload: dict, model_id: str) -> set[str]: for entry in payload["data"]: if entry["id"] == model_id: @@ -289,7 +300,7 @@ def _expected_path( endpoint_tag: str | None = None, ) -> str: return mp.encode_model_path( - f"https://provider-{provider_id}", provider_id, model_id, endpoint_tag + f"https://provider-{provider_id}", model_id, endpoint_tag ) @@ -308,7 +319,6 @@ def _path_entry( return { "path": _expected_path(provider_id, model_id, endpoint_tag), "provider": { - "id": provider_id, "slug": provider_slug or f"p{provider_id}", "type": provider_type or ("anthropic" if provider_id == 1 else "openrouter"), @@ -368,21 +378,20 @@ def test_public_provider_url_preserves_public_urls_without_ports() -> None: assert mp.public_provider_url("http://localhost") == "http://localhost" -def test_encode_model_path_includes_complete_route_identity() -> None: +def test_encode_model_path_omits_provider_id() -> None: assert mp.encode_model_path( - "https://openrouter.ai/api/v1", 42, "anthropic/claude-sonnet-4" + "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4" ) == ( "url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1" - "&provider-id=42&model-id=anthropic%2Fclaude-sonnet-4" + "&model-id=anthropic%2Fclaude-sonnet-4" ) assert mp.encode_model_path( "https://openrouter.ai/api/v1", - 42, "anthropic/claude-sonnet-4", "google-vertex/us-east5", ) == ( "url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1" - "&provider-id=42&model-id=anthropic%2Fclaude-sonnet-4" + "&model-id=anthropic%2Fclaude-sonnet-4" "&endpoint=google-vertex%2Fus-east5" ) @@ -506,7 +515,7 @@ async def test_direct_path_masks_private_configured_provider_url( await mp.refresh_model_paths([provider]) assert _paths_of(await mp.get_all_model_paths(), "local-model") == { - mp.encode_model_path("http://localhost", 1, "local-model") + mp.encode_model_path("http://localhost", "local-model") } @@ -886,7 +895,7 @@ async def test_openrouter_provider_adds_endpoint_paths( assert { item["endpoint"]["name"] for item in payload["data"] if item["endpoint"] } == {"Google"} - assert {item["provider"]["id"] for item in payload["data"]} == {2} + assert {item["provider"]["slug"] for item in payload["data"]} == {"p2"} @pytest.mark.asyncio @@ -984,6 +993,97 @@ async def test_path_pricing_uses_the_provider_fee_of_its_own_provider( ) +@pytest.mark.asyncio +async def test_providers_sharing_a_url_collapse_onto_the_cheapest_path( + patched_session: AsyncEngine, sats_quote: float +) -> None: + """One selector per URL, describing the provider routing will actually use.""" + shared_url = "https://shared.example/v1" + await _set_provider_base_url(patched_session, 1, shared_url) + await _set_provider_base_url(patched_session, 2, shared_url) + await _set_provider_fee(patched_session, 1, 2.0) + await _set_provider_fee(patched_session, 2, 1.1) + + await mp.refresh_model_paths( + [ + _FakeProvider( + provider_type="anthropic", + base_url=shared_url, + models=[_model("shared-model")], + db_id=1, + ), + _FakeProvider( + provider_type="openrouter", + base_url=shared_url, + models=[_model("shared-model")], + db_id=2, + ), + ] + ) + + payload = await mp.get_paths_for_model("shared-model") + assert [item["path"] for item in payload["data"]] == [ + mp.encode_model_path(shared_url, "shared-model") + ] + assert payload["data"][0]["provider"] == {"slug": "p2", "type": "openrouter"} + + +@pytest.mark.asyncio +async def test_collapsed_path_advertises_the_cheapest_provider_pricing( + patched_session: AsyncEngine, sats_quote: float +) -> None: + shared_url = "https://shared.example/v1" + await _set_provider_base_url(patched_session, 1, shared_url) + await _set_provider_base_url(patched_session, 2, shared_url) + await _set_provider_fee(patched_session, 1, 2.0) + await _set_provider_fee(patched_session, 2, 1.1) + + priced = _model("shared-model") + priced.pricing = {"prompt": "0.000005", "completion": "0.000005"} + + await mp.refresh_model_paths( + [ + _FakeProvider( + provider_type="anthropic", + base_url=shared_url, + models=[priced], + db_id=1, + ), + _FakeProvider( + provider_type="openrouter", + base_url=shared_url, + models=[priced], + db_id=2, + ), + ] + ) + + payload = await mp.get_paths_for_model("shared-model") + assert len(payload["data"]) == 1 + assert payload["data"][0]["model"]["pricing"]["prompt"] == pytest.approx( + 0.000005 * 1.1 + ) + + +@pytest.mark.asyncio +async def test_paths_advertise_no_provider_id(patched_session: AsyncEngine) -> None: + await mp.refresh_model_paths( + [ + _FakeProvider( + provider_type="anthropic", + base_url="https://provider-1", + models=[_model("claude-x")], + db_id=1, + ) + ] + ) + + payload = await mp.get_all_model_paths() + entry = payload["data"][0]["paths"][0] + assert "provider-id" not in entry["path"] + assert "id" not in entry["provider"] + + @pytest.mark.asyncio async def test_paths_keep_upstream_pricing_when_the_quote_is_unavailable( patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch