From 16fc548b48d982211f4f4a8afde63a6a36f6da9c Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sun, 26 Jul 2026 20:12:55 +0200 Subject: [PATCH] fix: make model path identity selectable --- docs/api/endpoints.md | 56 +-- .../4e0c3d195a49_add_model_paths_table.py | 4 + routstr/core/admin.py | 28 ++ routstr/core/db.py | 14 +- routstr/payment/models.py | 10 +- routstr/upstream/model_paths.py | 107 +++--- tests/unit/test_model_paths.py | 347 +++++++++++++----- 7 files changed, 401 insertions(+), 165 deletions(-) diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 44e6207e..2d202659 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -329,8 +329,8 @@ GET /v1/models ### List Model Paths -Get the upstream provider paths each advertised model can be reached through. -This is discovery data only; routing still chooses the provider per request. +Get the selectable upstream routes for each advertised model. This endpoint is +discovery-only; request-side selection will be added separately. ```http GET /v1/models/paths @@ -342,45 +342,45 @@ GET /v1/models/paths { "data": [ { - "id": "claude-sonnet-4", + "id": "anthropic/claude-sonnet-4", "paths": [ - {"path": "anthropic"}, - {"path": "openrouter:Anthropic"} + { + "path": "provider=12", + "provider": {"id": 12, "slug": "anthropic-primary", "type": "anthropic"}, + "endpoint": null + }, + { + "path": "provider=42&endpoint=google-vertex%2Fus", + "provider": {"id": 42, "slug": "openrouter-main", "type": "openrouter"}, + "endpoint": {"tag": "google-vertex/us", "name": "Google"} + } ] } - ] + ], + "updated_at": 1753500000 } ``` +`path` is an opaque, percent-encoded selector. Clients must store and return it +unchanged rather than parsing or reconstructing it. The configured provider's +stable node-local ID defines the upstream route; no upstream URL is exposed. +OpenRouter routes additionally use the exact machine-readable endpoint `tag`. +Provider slugs/types and endpoint names are display data and never participate +in identity. When request-side selection is implemented, an endpoint tag must +not silently fall back to another backend. + ### List Paths for One Model -Use a query parameter so model IDs containing `/` are handled safely. Lookup is -by the public, unqualified model ID: `glm-5v-turbo` resolves -`z-ai/glm-5v-turbo`, and `deepseek-v4-pro` and `deepseek/deepseek-v4-pro` -return the same merged path set. +Use the exact model ID advertised by `/v1/models`. The query parameter safely +supports IDs containing `/`. ```http GET /v1/models/paths/model?model_id=anthropic/claude-sonnet-4 ``` -**Response:** - -```json -{ - "data": [ - {"path": "anthropic"}, - {"path": "openrouter:Anthropic"} - ] -} -``` - -Model IDs in responses are base model IDs: the leading provider prefix such as -`z-ai/` or `openai/` is stripped (the same rule routing uses, so the ID can be -sent back to `/v1/chat/completions` verbatim). Path values match the provider -string stamped on chat-completion responses, such as `anthropic`, -`generic:Anthropic`, `openrouter:Anthropic`, or `unknown` (native OpenRouter -with no usable sub-provider). Responses also carry an `updated_at` Unix -timestamp of the last successful refresh (`null` when no refresh has run). +The response uses the same path objects and `updated_at` field as the collection +endpoint. An unknown model returns `404 Model not found`. A known model whose +paths have not been discovered yet returns `200` with an empty `data` array. ## Wallet Management diff --git a/migrations/versions/4e0c3d195a49_add_model_paths_table.py b/migrations/versions/4e0c3d195a49_add_model_paths_table.py index 5641e488..688e2bc5 100644 --- a/migrations/versions/4e0c3d195a49_add_model_paths_table.py +++ b/migrations/versions/4e0c3d195a49_add_model_paths_table.py @@ -22,6 +22,10 @@ def upgrade() -> None: sa.Column("id", sa.Integer(), nullable=False), sa.Column("model_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("path", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("provider_slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("provider_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("endpoint_tag", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("endpoint_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("upstream_provider_id", sa.Integer(), nullable=False), sa.Column("updated_at", sa.Integer(), nullable=False, server_default="0"), sa.ForeignKeyConstraint( diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 66a1d288..f581b704 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -51,6 +51,23 @@ ADMIN_SESSION_DURATION = 3600 MAX_USAGE_ANALYTICS_HOURS = 365 * 24 +async def _refresh_provider_model_paths(upstream_provider_id: int) -> None: + """Best-effort immediate discovery sync after an admin mutation.""" + from ..upstream.model_paths import refresh_model_paths_for_provider + + try: + await refresh_model_paths_for_provider(upstream_provider_id) + except Exception as exc: # noqa: BLE001 - committed admin writes must survive + logger.warning( + "Failed to refresh model paths after admin mutation", + extra={ + "upstream_provider_id": upstream_provider_id, + "error": str(exc), + "error_type": type(exc).__name__, + }, + ) + + async def require_admin_api(request: Request) -> None: auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): @@ -579,6 +596,7 @@ async def upsert_provider_model( await session.refresh(row) await refresh_model_maps() + await _refresh_provider_model_paths(provider_pk) return _row_to_model( row, apply_provider_fee=True, provider_fee=provider.provider_fee ).dict() # type: ignore @@ -633,6 +651,7 @@ async def delete_provider_model(provider_id: str, model_id: str) -> dict[str, ob await session.delete(row) await session.commit() await refresh_model_maps() + await _refresh_provider_model_paths(provider_pk) return {"ok": True, "deleted_id": model_id} @@ -652,6 +671,7 @@ async def delete_all_provider_models(provider_id: str) -> dict[str, object]: await session.delete(row) # type: ignore await session.commit() await refresh_model_maps() + await _refresh_provider_model_paths(provider_pk) return {"ok": True, "deleted": len(rows)} @@ -705,6 +725,9 @@ async def batch_override_provider_models( json.dumps(model_data.alias_ids) if model_data.alias_ids else None ) existing_row.enabled = model_data.enabled + existing_row.forwarded_model_id = ( + model_data.forwarded_model_id or model_data.id + ) session.add(existing_row) else: # Create new @@ -735,6 +758,7 @@ async def batch_override_provider_models( ), upstream_provider_id=provider_pk, enabled=model_data.enabled, + forwarded_model_id=model_data.forwarded_model_id or model_data.id, ) session.add(row) @@ -743,6 +767,7 @@ async def batch_override_provider_models( await session.commit() await refresh_model_maps() + await _refresh_provider_model_paths(provider_pk) return { "ok": True, "count": overridden_count, @@ -943,6 +968,7 @@ async def create_upstream_provider( await reinitialize_upstreams() await refresh_model_maps() + await _refresh_provider_model_paths(_provider_pk(provider)) return _serialize_provider(provider) @@ -968,6 +994,7 @@ async def update_upstream_provider( await reinitialize_upstreams() await refresh_model_maps() + await _refresh_provider_model_paths(_provider_pk(provider)) return _serialize_provider(provider) @@ -1003,6 +1030,7 @@ async def update_upstream_provider_by_slug( await reinitialize_upstreams() await refresh_model_maps() + await _refresh_provider_model_paths(_provider_pk(provider)) return _serialize_provider(provider) diff --git a/routstr/core/db.py b/routstr/core/db.py index 8b3586ec..151d8627 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -338,8 +338,18 @@ class ModelPathRow(SQLModel, table=True): # type: ignore description="Client-visible /v1/models id (forwarded_model_id or id)" ) path: str = Field( - description="Provider path stamped on chat completion responses, e.g. " - "'anthropic' or 'openrouter:Anthropic'" + description="Opaque selector containing provider ID and optional endpoint tag" + ) + provider_slug: str = Field( + description="Public slug of the configured upstream provider" + ) + provider_type: str = Field(description="Configured upstream provider type") + endpoint_tag: str | None = Field( + default=None, + description="Exact OpenRouter endpoint tag used for request-side selection", + ) + endpoint_name: str | None = Field( + default=None, description="Human-readable endpoint display name" ) upstream_provider_id: int = Field( index=True, diff --git a/routstr/payment/models.py b/routstr/payment/models.py index c433ddfa..5c634ced 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -613,9 +613,17 @@ async def model_paths_for_model(model_id: str) -> dict: model ids containing ``/`` (e.g. ``anthropic/claude-opus-4.6``) need no URL encoding and there is no dynamic-route ambiguity. """ + from ..proxy import get_unique_models from ..upstream.model_paths import get_paths_for_model - return await get_paths_for_model(model_id) + result = await get_paths_for_model(model_id) + if not result["data"]: + advertised_ids = { + model.forwarded_model_id or model.id for model in get_unique_models() + } + if model_id not in advertised_ids: + raise HTTPException(status_code=404, detail="Model not found") + return result @models_router.get("/v1/models") diff --git a/routstr/upstream/model_paths.py b/routstr/upstream/model_paths.py index f3bb9971..45ce6887 100644 --- a/routstr/upstream/model_paths.py +++ b/routstr/upstream/model_paths.py @@ -4,12 +4,13 @@ Exposes every selectable upstream route a Routstr model is reachable through. This PR remains discovery-only: request-side routing will consume the opaque selectors in a follow-up. -A path is a standard percent-encoded query string containing the normalized -upstream URL and, for an exact OpenRouter endpoint, its machine-readable tag. -Display names never participate in identity:: +A path is a standard percent-encoded query string containing the configured +provider's stable node-local ID and, for an exact OpenRouter endpoint, its +machine-readable tag. Upstream URLs and display names never participate in or +leak through public identity:: - url=https%3A%2F%2Fapi.anthropic.com%2Fv1 - url=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1&provider=google-vertex%2Fus + provider=42 + provider=42&endpoint=google-vertex%2Fus """ from __future__ import annotations @@ -57,15 +58,24 @@ class EndpointIdentity: provider_name: str | None +@dataclass(frozen=True) +class ConfiguredProviderIdentity: + """Public-safe identity of one configured upstream provider.""" + + id: int + slug: str + provider_type: str + + @dataclass(frozen=True) class DiscoveredPath: """One model route ready for persistence and API serialization.""" model_id: str path: str - upstream_url: str - provider_tag: str | None = None - provider_name: str | None = None + provider: ConfiguredProviderIdentity + endpoint_tag: str | None = None + endpoint_name: str | None = None @dataclass(frozen=True) @@ -76,16 +86,11 @@ class ProviderPathSnapshot: preserve_model_ids: frozenset[str] = frozenset() -def normalize_upstream_url(base_url: str) -> str: - """Normalize route identity without changing URL semantics.""" - return base_url.rstrip("/") - - -def encode_model_path(base_url: str, provider_tag: str | None = None) -> str: - """Encode a stable opaque selector for future request-side routing.""" - components = [("url", normalize_upstream_url(base_url))] - if provider_tag: - components.append(("provider", provider_tag)) +def encode_model_path(provider_id: int, endpoint_tag: str | None = None) -> str: + """Encode a stable opaque selector without exposing upstream URLs.""" + components: list[tuple[str, str | int]] = [("provider", provider_id)] + if endpoint_tag: + components.append(("endpoint", endpoint_tag)) return urlencode(components) @@ -218,9 +223,11 @@ async def _fetch_openrouter_endpoint_subproviders( return None try: - endpoints = resp.json().get("data", {}).get("endpoints", []) + payload = resp.json() + data = payload.get("data") if isinstance(payload, dict) else None + endpoints = data.get("endpoints") if isinstance(data, dict) else None if not isinstance(endpoints, list): - endpoints = [] + raise ValueError("endpoints must be a list") identities: dict[str, EndpointIdentity] = {} for endpoint in endpoints: if not isinstance(endpoint, dict): @@ -238,6 +245,8 @@ async def _fetch_openrouter_endpoint_subproviders( else None, ), ) + if endpoints and not identities: + raise ValueError("endpoints contain no usable tags") result = list(identities.values()) except Exception as e: # noqa: BLE001 logger.warning( @@ -251,7 +260,9 @@ async def _fetch_openrouter_endpoint_subproviders( async def _load_model_visibility() -> tuple[ - dict[ModelKey, ModelRow], set[ModelKey], set[int] + dict[ModelKey, ModelRow], + set[ModelKey], + dict[int, ConfiguredProviderIdentity], ]: """Load the same DB model visibility inputs used by routing. @@ -271,12 +282,16 @@ async def _load_model_visibility() -> tuple[ overrides_by_key: dict[ModelKey, ModelRow] = {} disabled_model_keys: set[ModelKey] = set() - enabled_provider_ids: set[int] = set() + provider_identities: dict[int, ConfiguredProviderIdentity] = {} for provider in provider_rows: if not provider.enabled or provider.id is None: continue - enabled_provider_ids.add(provider.id) + provider_identities[provider.id] = ConfiguredProviderIdentity( + id=provider.id, + slug=provider.slug or f"provider-{provider.id}", + provider_type=provider.provider_type, + ) for model in provider.models: key = (model.id.lower(), provider.id) if model.enabled: @@ -284,7 +299,7 @@ async def _load_model_visibility() -> tuple[ else: disabled_model_keys.add(key) - return overrides_by_key, disabled_model_keys, enabled_provider_ids + return overrides_by_key, disabled_model_keys, provider_identities def _apply_model_visibility( @@ -338,6 +353,7 @@ def _apply_model_visibility( async def _collect_provider_paths( upstream: BaseUpstreamProvider, + provider_identity: ConfiguredProviderIdentity, overrides_by_key: dict[ModelKey, ModelRow] | None = None, disabled_model_keys: set[ModelKey] | None = None, cycle: _RefreshCycleState | None = None, @@ -350,13 +366,12 @@ async def _collect_provider_paths( """ cycle = cycle or _RefreshCycleState() models = _apply_model_visibility(upstream, overrides_by_key, disabled_model_keys) - upstream_url = normalize_upstream_url(upstream.base_url) def _base_path(model: object) -> DiscoveredPath: return DiscoveredPath( model_id=exposed_model_id(model), - path=encode_model_path(upstream_url), - upstream_url=upstream_url, + path=encode_model_path(provider_identity.id), + provider=provider_identity, ) if not is_openrouter_base_url(upstream.base_url): @@ -389,10 +404,10 @@ async def _collect_provider_paths( paths.extend( DiscoveredPath( model_id=model_id, - path=encode_model_path(upstream_url, endpoint.tag), - upstream_url=upstream_url, - provider_tag=endpoint.tag, - provider_name=endpoint.provider_name, + path=encode_model_path(provider_identity.id, endpoint.tag), + provider=provider_identity, + endpoint_tag=endpoint.tag, + endpoint_name=endpoint.provider_name, ) for endpoint in endpoints ) @@ -448,9 +463,10 @@ async def _persist_provider_paths( { "model_id": discovered.model_id, "path": discovered.path, - "upstream_url": discovered.upstream_url, - "provider_tag": discovered.provider_tag, - "provider_name": discovered.provider_name, + "provider_slug": discovered.provider.slug, + "provider_type": discovered.provider.provider_type, + "endpoint_tag": discovered.endpoint_tag, + "endpoint_name": discovered.endpoint_name, "upstream_provider_id": upstream_provider_id, "updated_at": now, } @@ -505,17 +521,18 @@ async def refresh_model_paths( ( overrides_by_key, disabled_model_keys, - enabled_provider_ids, + provider_identities, ) = await _load_model_visibility() await prune_model_paths_for_inactive_providers() cycle = _RefreshCycleState() for upstream in upstreams: - if upstream.db_id is None or upstream.db_id not in enabled_provider_ids: + if upstream.db_id is None or upstream.db_id not in provider_identities: continue try: snapshot = await _collect_provider_paths( upstream, + provider_identity=provider_identities[upstream.db_id], overrides_by_key=overrides_by_key, disabled_model_keys=disabled_model_keys, cycle=cycle, @@ -611,13 +628,17 @@ async def refresh_model_paths_periodically( def _serialize_path(row: ModelPathRow) -> dict[str, Any]: - provider = None - if row.provider_tag or row.provider_name: - provider = {"name": row.provider_name, "slug": row.provider_tag} + endpoint = None + if row.endpoint_tag or row.endpoint_name: + endpoint = {"tag": row.endpoint_tag, "name": row.endpoint_name} return { "path": row.path, - "upstream_url": row.upstream_url, - "provider": provider, + "provider": { + "id": row.upstream_provider_id, + "slug": row.provider_slug, + "type": row.provider_type, + }, + "endpoint": endpoint, } @@ -646,9 +667,7 @@ async def get_all_model_paths() -> dict: data = [ { "id": grouped_model_id, - "paths": sorted( - grouped[grouped_model_id], key=lambda item: str(item["path"]) - ), + "paths": grouped[grouped_model_id], } for grouped_model_id in sorted(grouped) ] diff --git a/tests/unit/test_model_paths.py b/tests/unit/test_model_paths.py index 7d3393fe..0538ea55 100644 --- a/tests/unit/test_model_paths.py +++ b/tests/unit/test_model_paths.py @@ -142,11 +142,18 @@ def _mock_transport( return counter -def _endpoints_response(*provider_names: str) -> httpx.Response: - return httpx.Response( - 200, - json={"data": {"endpoints": [{"provider_name": n} for n in provider_names]}}, - ) +def _endpoints_response( + *providers: str | tuple[str, str], +) -> httpx.Response: + endpoints = [] + for provider in providers: + if isinstance(provider, tuple): + provider_name, tag = provider + else: + provider_name = provider + tag = provider.lower().replace(" ", "-") + endpoints.append({"provider_name": provider_name, "tag": tag}) + return httpx.Response(200, json={"data": {"endpoints": endpoints}}) _SEEDED_PROVIDER_IDS = (1, 2, 4, 5, 7) @@ -206,6 +213,29 @@ def _ids_of(payload: dict) -> set[str]: return {entry["id"] for entry in payload["data"]} +def _path_entry( + provider_id: int, + *, + provider_slug: str | None = None, + provider_type: str | None = None, + endpoint_tag: str | None = None, + endpoint_name: str | None = None, +) -> dict[str, Any]: + endpoint = None + if endpoint_tag or endpoint_name: + endpoint = {"tag": endpoint_tag, "name": endpoint_name} + return { + "path": mp.encode_model_path(provider_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"), + }, + "endpoint": endpoint, + } + + # --------------------------------------------------------------------------- # # Predicates / pure helpers # --------------------------------------------------------------------------- # @@ -223,6 +253,13 @@ def test_native_anthropic_not_openrouter() -> None: assert mp.is_openrouter_base_url("https://api.anthropic.com/v1") is False +def test_encode_model_path_uses_provider_id_without_exposing_url() -> None: + assert mp.encode_model_path(42) == "provider=42" + assert mp.encode_model_path(42, "google-vertex/us-east5") == ( + "provider=42&endpoint=google-vertex%2Fus-east5" + ) + + def test_exposed_model_id_prefers_forwarded() -> None: assert ( mp.exposed_model_id(_model("claude-x", forwarded_model_id="fwd-claude")) @@ -300,9 +337,7 @@ async def test_direct_provider_single_path_uses_provider_type( ) await mp.refresh_model_paths([provider]) payload = await mp.get_all_model_paths() - assert payload["data"] == [ - {"id": "claude-opus-4.6", "paths": [{"path": "anthropic"}]} - ] + assert payload["data"] == [{"id": "claude-opus-4.6", "paths": [_path_entry(1)]}] assert payload["updated_at"] is not None @@ -320,6 +355,22 @@ async def test_direct_path_stores_exposed_model_id( assert _ids_of(await mp.get_all_model_paths()) == {"claude-opus-4.6"} +@pytest.mark.asyncio +async def test_forwarded_model_id_with_slash_remains_exact_and_routable( + patched_session: AsyncEngine, +) -> None: + provider = _FakeProvider( + provider_type="anthropic", + base_url="https://api.anthropic.com/v1", + models=[_model("local-alias", forwarded_model_id="anthropic/claude-opus-4.6")], + db_id=1, + ) + await mp.refresh_model_paths([provider]) + + assert _ids_of(await mp.get_all_model_paths()) == {"anthropic/claude-opus-4.6"} + assert (await mp.get_paths_for_model("anthropic/claude-opus-4.6"))["data"] + + @pytest.mark.asyncio async def test_disabled_cached_models_excluded( patched_session: AsyncEngine, @@ -361,7 +412,7 @@ async def test_disabling_model_on_one_provider_keeps_other_provider( await mp.refresh_model_paths([p1, p2]) payload = await mp.get_all_model_paths() - assert _paths_of(payload, "shared-model") == {"anthropic"} + assert _paths_of(payload, "shared-model") == {mp.encode_model_path(1)} @pytest.mark.asyncio @@ -395,8 +446,8 @@ async def test_override_alias_not_applied_across_providers( await mp.refresh_model_paths([p1, p2]) payload = await mp.get_all_model_paths() - assert _paths_of(payload, "shared-model") == {"anthropic"} - assert _paths_of(payload, "private-alias") == {"generic"} + assert _paths_of(payload, "shared-model") == {mp.encode_model_path(1)} + assert _paths_of(payload, "private-alias") == {mp.encode_model_path(2)} @pytest.mark.asyncio @@ -464,7 +515,7 @@ async def test_refresh_model_paths_uses_db_forwarded_alias( await mp.refresh_model_paths([provider]) assert (await mp.get_all_model_paths())["data"] == [ - {"id": "public-alias", "paths": [{"path": "anthropic"}]} + {"id": "public-alias", "paths": [_path_entry(1)]} ] @@ -486,7 +537,7 @@ async def test_refresh_model_paths_includes_enabled_db_override_missing_from_cac await mp.refresh_model_paths([provider]) assert (await mp.get_all_model_paths())["data"] == [ - {"id": "public-deployment", "paths": [{"path": "generic"}]} + {"id": "public-deployment", "paths": [_path_entry(1)]} ] @@ -638,24 +689,34 @@ async def test_openrouter_provider_adds_endpoint_paths( ) _mock_transport( monkeypatch, - lambda request: _endpoints_response("Anthropic", "Amazon Bedrock"), + lambda request: _endpoints_response( + ("Google", "google-vertex/eu"), + ("Google", "google-vertex/us"), + ), ) await mp.refresh_model_paths([provider]) - paths = _paths_of(await mp.get_all_model_paths(), "claude-opus-4.6") - assert "openrouter:Anthropic" in paths - assert "openrouter:Amazon Bedrock" in paths - assert "openrouter" not in paths + payload = await mp.get_paths_for_model("claude-opus-4.6") + assert {item["path"] for item in payload["data"]} == { + mp.encode_model_path(2), + mp.encode_model_path(2, "google-vertex/eu"), + mp.encode_model_path(2, "google-vertex/us"), + } + assert { + item["endpoint"]["tag"] for item in payload["data"] if item["endpoint"] + } == {"google-vertex/eu", "google-vertex/us"} + assert { + item["endpoint"]["name"] for item in payload["data"] if item["endpoint"] + } == {"Google"} + assert {item["provider"]["id"] for item in payload["data"]} == {2} @pytest.mark.asyncio -async def test_openrouter_self_echoing_subprovider_maps_to_unknown( +async def test_openrouter_uses_exact_tag_even_when_display_name_is_router( patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch ) -> None: - """Responses stamped for a sub-provider echoing "OpenRouter" say - ``unknown``; discovery must advertise the same string, never - ``openrouter:OpenRouter``.""" + """Machine-readable endpoint tags, not display names, define identity.""" provider = _FakeOpenRouterProvider( models=[_model("claude-opus-4.6", canonical_slug="anthropic/claude-opus-4.6")], db_id=2, @@ -665,17 +726,14 @@ async def test_openrouter_self_echoing_subprovider_maps_to_unknown( await mp.refresh_model_paths([provider]) paths = _paths_of(await mp.get_all_model_paths(), "claude-opus-4.6") - assert "openrouter:OpenRouter" not in paths - assert "unknown" in paths + assert paths == {mp.encode_model_path(2), mp.encode_model_path(2, "openrouter")} @pytest.mark.asyncio async def test_generic_provider_with_openrouter_base_url_discovers( patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch ) -> None: - """A generic provider pointed at OpenRouter exposes both the bare - ``generic`` path (stamped when the upstream omits its provider field) and - the ``generic:`` endpoint paths.""" + """Configured provider identity is independent from its endpoint URL.""" provider = _FakeProvider( provider_type="generic", base_url="https://openrouter.ai/api/v1", @@ -687,7 +745,35 @@ async def test_generic_provider_with_openrouter_base_url_discovers( await mp.refresh_model_paths([provider]) paths = _paths_of(await mp.get_all_model_paths(), "claude-opus-4.6") - assert paths == {"generic", "generic:Anthropic"} + assert paths == {mp.encode_model_path(1), mp.encode_model_path(1, "anthropic")} + + +@pytest.mark.asyncio +async def test_openrouter_partial_failure_keeps_failed_models_previous_rows( + patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch +) -> None: + provider = _FakeOpenRouterProvider( + models=[ + _model("good", canonical_slug="author/good"), + _model("degraded", canonical_slug="author/degraded"), + ], + db_id=2, + ) + _mock_transport(monkeypatch, lambda request: _endpoints_response("Anthropic")) + await mp.refresh_model_paths([provider]) + before = _paths_of(await mp.get_all_model_paths(), "degraded") + assert before + + def _partial_failure(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/author/degraded/endpoints"): + return httpx.Response(503) + return _endpoints_response("Google") + + _mock_transport(monkeypatch, _partial_failure) + await mp.refresh_model_paths([provider]) + + assert _paths_of(await mp.get_all_model_paths(), "degraded") == before + assert _paths_of(await mp.get_all_model_paths(), "good") != before @pytest.mark.asyncio @@ -703,7 +789,7 @@ async def test_openrouter_failure_keeps_previous_rows( _mock_transport(monkeypatch, lambda request: _endpoints_response("Anthropic")) await mp.refresh_model_paths([provider]) before = _paths_of(await mp.get_all_model_paths(), "claude-opus-4.6") - assert "openrouter:Anthropic" in before + assert mp.encode_model_path(2, "anthropic") in before def _network_down(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("network down", request=request) @@ -726,10 +812,8 @@ async def test_openrouter_rate_limit_aborts_cycle_and_keeps_rows( _mock_transport(monkeypatch, lambda request: _endpoints_response("Anthropic")) await mp.refresh_model_paths([provider]) - assert _paths_of(await mp.get_all_model_paths(), "m0") == { - "unknown", - "openrouter:Anthropic", - } + expected = {mp.encode_model_path(2), mp.encode_model_path(2, "anthropic")} + assert _paths_of(await mp.get_all_model_paths(), "m0") == expected counter = _mock_transport(monkeypatch, lambda request: httpx.Response(429)) await mp.refresh_model_paths([provider]) @@ -739,27 +823,28 @@ async def test_openrouter_rate_limit_aborts_cycle_and_keeps_rows( assert counter["requests"] <= mp._OPENROUTER_CONCURRENCY, ( "429 must abort the remaining fan-out" ) - assert _paths_of(await mp.get_all_model_paths(), "m0") == { - "unknown", - "openrouter:Anthropic", - } + assert _paths_of(await mp.get_all_model_paths(), "m0") == expected @pytest.mark.asyncio -async def test_openrouter_bad_payload_shapes_do_not_raise( +async def test_openrouter_bad_payload_shapes_preserve_previous_rows( patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch ) -> None: - """``endpoints: null`` and non-list endpoint payloads are swallowed as - documented, not raised into the generic task-errored bucket.""" + """Malformed successful responses are degraded snapshots, not empty sets.""" + provider = _FakeOpenRouterProvider( + models=[_model("m", canonical_slug="a/m")], db_id=2 + ) + _mock_transport(monkeypatch, lambda request: _endpoints_response("Anthropic")) + await mp.refresh_model_paths([provider]) + before = _paths_of(await mp.get_all_model_paths(), "m") + for payload in ( {"data": {"endpoints": None}}, {"data": {"endpoints": "none"}}, + {"data": {"endpoints": [{"provider_name": "Anthropic"}]}}, {"data": None}, {}, ): - provider = _FakeOpenRouterProvider( - models=[_model("m", canonical_slug="a/m")], db_id=2 - ) def _handler( request: httpx.Request, p: dict[str, Any] | None = payload @@ -767,8 +852,8 @@ async def test_openrouter_bad_payload_shapes_do_not_raise( return httpx.Response(200, json=p) _mock_transport(monkeypatch, _handler) - # Must not raise. await mp.refresh_model_paths([provider]) + assert _paths_of(await mp.get_all_model_paths(), "m") == before @pytest.mark.asyncio @@ -795,8 +880,12 @@ async def test_openrouter_shared_base_url_fetched_once( assert counter["requests"] == 1 paths = _paths_of(await mp.get_all_model_paths(), "claude-opus-4.6") - assert "openrouter:Anthropic" in paths - assert "generic:Anthropic" in paths + assert paths == { + mp.encode_model_path(2), + mp.encode_model_path(2, "anthropic"), + mp.encode_model_path(4), + mp.encode_model_path(4, "anthropic"), + } @pytest.mark.asyncio @@ -857,13 +946,16 @@ async def test_same_model_two_providers_two_paths( assert len(payload["data"]) == 1 entry = payload["data"][0] assert entry["id"] == "claude-opus-4.6" - assert {p["path"] for p in entry["paths"]} == {"anthropic", "generic"} + assert {p["path"] for p in entry["paths"]} == { + mp.encode_model_path(1), + mp.encode_model_path(2), + } assert "canonical_id" not in entry assert all("canonical_id" not in p for p in entry["paths"]) @pytest.mark.asyncio -async def test_get_all_model_paths_deduplicates_visible_paths( +async def test_get_all_model_paths_keeps_distinct_configured_providers( patched_session: AsyncEngine, ) -> None: p1 = _FakeProvider( @@ -881,7 +973,10 @@ async def test_get_all_model_paths_deduplicates_visible_paths( await mp.refresh_model_paths([p1, p2]) assert (await mp.get_all_model_paths())["data"] == [ - {"id": "claude-opus-4.6", "paths": [{"path": "anthropic"}]} + { + "id": "claude-opus-4.6", + "paths": [_path_entry(1), _path_entry(2)], + } ] @@ -906,14 +1001,13 @@ async def test_get_all_model_paths_is_deterministic( @pytest.mark.asyncio -async def test_get_paths_for_model_returns_only_paths( +async def test_get_paths_for_model_returns_route_identity( patched_session: AsyncEngine, ) -> None: await _seed_two_provider_shared_model(patched_session) payload = await mp.get_paths_for_model("claude-opus-4.6") - assert {p["path"] for p in payload["data"]} == {"anthropic", "generic"} - assert all(set(p.keys()) == {"path"} for p in payload["data"]) + assert payload["data"] == [_path_entry(1), _path_entry(2)] assert (await mp.get_paths_for_model("does-not-exist"))["data"] == [] @@ -929,13 +1023,11 @@ async def test_get_paths_for_model_falls_back_to_provider_prefixed_id( ) await mp.refresh_model_paths([provider]) - assert (await mp.get_paths_for_model("glm-5v-turbo"))["data"] == [ - {"path": "generic"} - ] + assert (await mp.get_paths_for_model("glm-5v-turbo"))["data"] == [_path_entry(4)] @pytest.mark.asyncio -async def test_get_paths_for_model_merges_prefixed_and_unprefixed_aliases( +async def test_get_paths_for_model_requires_exact_advertised_id( patched_session: AsyncEngine, ) -> None: p1 = _FakeProvider( @@ -955,8 +1047,8 @@ async def test_get_paths_for_model_merges_prefixed_and_unprefixed_aliases( short_paths = (await mp.get_paths_for_model("deepseek-v4-pro"))["data"] prefixed_paths = (await mp.get_paths_for_model("deepseek/deepseek-v4-pro"))["data"] - assert {p["path"] for p in short_paths} == {"generic", "anthropic"} - assert prefixed_paths == short_paths + assert short_paths == [_path_entry(4), _path_entry(7)] + assert prefixed_paths == [] @pytest.mark.asyncio @@ -975,18 +1067,40 @@ async def test_get_paths_for_model_multi_segment_id_matches_models_listing( assert _ids_of(await mp.get_all_model_paths()) == {"fireworks/models/glm-5"} assert (await mp.get_paths_for_model("fireworks/models/glm-5"))["data"] == [ - {"path": "generic"} + _path_entry(1) ] assert (await mp.get_paths_for_model("accounts/fireworks/models/glm-5"))[ "data" - ] == [{"path": "generic"}] + ] == [] # --------------------------------------------------------------------------- # -# Periodic refresh loop +# Immediate and periodic refresh # --------------------------------------------------------------------------- # +@pytest.mark.asyncio +async def test_refresh_model_paths_for_provider_selects_mutated_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import routstr.proxy as proxy + + target = SimpleNamespace(db_id=2) + other = SimpleNamespace(db_id=1) + seen: list[list[Any]] = [] + + monkeypatch.setattr(proxy, "get_upstreams", lambda: [other, target]) + + async def _fake_refresh(upstreams: list[Any]) -> None: + seen.append(upstreams) + + monkeypatch.setattr(mp, "refresh_model_paths", _fake_refresh) + + await mp.refresh_model_paths_for_provider(2) + + assert seen == [[target]] + + @pytest.mark.asyncio async def test_refresh_loop_rereads_interval_and_picks_up_providers( monkeypatch: pytest.MonkeyPatch, @@ -1093,37 +1207,88 @@ def _make_model_paths_app() -> FastAPI: def test_model_paths_endpoint_returns_all_paths( monkeypatch: pytest.MonkeyPatch, ) -> None: + expected = { + "data": [ + { + "id": "claude-opus-4.6", + "paths": [ + _path_entry(1), + _path_entry( + 2, + endpoint_tag="google-vertex/us", + endpoint_name="Google", + ), + ], + } + ], + "updated_at": 1753500000, + } + async def _fake_get_all_model_paths() -> dict[str, Any]: - return { - "data": [ - { - "id": "claude-opus-4.6", - "paths": [ - {"path": "anthropic"}, - {"path": "openrouter:Anthropic"}, - ], - } - ], - "updated_at": 1753500000, - } + return expected monkeypatch.setattr(mp, "get_all_model_paths", _fake_get_all_model_paths) response = TestClient(_make_model_paths_app()).get("/v1/models/paths") assert response.status_code == 200 - assert response.json() == { - "data": [ - { - "id": "claude-opus-4.6", - "paths": [ - {"path": "anthropic"}, - {"path": "openrouter:Anthropic"}, - ], - } - ], - "updated_at": 1753500000, - } + assert response.json() == expected + + +def test_model_paths_for_model_returns_404_for_unknown_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import routstr.proxy as proxy + + async def _fake_get_paths_for_model(model_id: str) -> dict[str, Any]: + return {"data": [], "updated_at": None} + + monkeypatch.setattr(mp, "get_paths_for_model", _fake_get_paths_for_model) + monkeypatch.setattr(proxy, "get_unique_models", lambda: []) + + response = TestClient(_make_model_paths_app()).get( + "/v1/models/paths/model", params={"model_id": "does-not-exist"} + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Model not found"} + + +def test_model_paths_for_known_model_can_return_empty_collection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import routstr.proxy as proxy + + async def _fake_get_paths_for_model(model_id: str) -> dict[str, Any]: + return {"data": [], "updated_at": None} + + monkeypatch.setattr(mp, "get_paths_for_model", _fake_get_paths_for_model) + monkeypatch.setattr(proxy, "get_unique_models", lambda: [_model("known")]) + + response = TestClient(_make_model_paths_app()).get( + "/v1/models/paths/model", params={"model_id": "known"} + ) + + assert response.status_code == 200 + assert response.json() == {"data": [], "updated_at": None} + + +def test_model_paths_for_routing_only_alias_returns_404( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import routstr.proxy as proxy + + async def _fake_get_paths_for_model(model_id: str) -> dict[str, Any]: + return {"data": [], "updated_at": None} + + monkeypatch.setattr(mp, "get_paths_for_model", _fake_get_paths_for_model) + monkeypatch.setattr(proxy, "get_unique_models", lambda: [_model("advertised")]) + + response = TestClient(_make_model_paths_app()).get( + "/v1/models/paths/model", params={"model_id": "routing-alias"} + ) + + assert response.status_code == 404 def test_model_paths_for_model_endpoint_accepts_slash_model_id( @@ -1131,9 +1296,14 @@ def test_model_paths_for_model_endpoint_accepts_slash_model_id( ) -> None: calls: list[str] = [] + expected = { + "data": [_path_entry(2, endpoint_tag="anthropic", endpoint_name="Anthropic")], + "updated_at": None, + } + async def _fake_get_paths_for_model(model_id: str) -> dict[str, Any]: calls.append(model_id) - return {"data": [{"path": "generic:Anthropic"}], "updated_at": None} + return expected monkeypatch.setattr(mp, "get_paths_for_model", _fake_get_paths_for_model) @@ -1143,8 +1313,5 @@ def test_model_paths_for_model_endpoint_accepts_slash_model_id( ) assert response.status_code == 200 - assert response.json() == { - "data": [{"path": "generic:Anthropic"}], - "updated_at": None, - } + assert response.json() == expected assert calls == ["anthropic/claude-opus-4.6"]