diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 5a9ea68d..efe57a68 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -327,6 +327,58 @@ 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. + +```http +GET /v1/models/paths +``` + +**Response:** + +```json +{ + "data": [ + { + "id": "claude-sonnet-4", + "paths": [ + {"path": "anthropic"}, + {"path": "openrouter:Anthropic"} + ] + } + ] +} +``` + +### 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. + +```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 unqualified display IDs: provider prefixes such as +`z-ai/` or `openai/` are stripped. Path values match the provider string stamped +on chat-completion responses, such as `anthropic`, `generic:my-upstream`, or +`openrouter:Anthropic`. + ## Wallet Management ### Create Wallet (Coming Soon) diff --git a/docs/api/overview.md b/docs/api/overview.md index 92fedd7e..c82e4e22 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -100,6 +100,7 @@ All errors follow a consistent format: Standard OpenAI-compatible endpoints: - **Models**: `/v1/models` +- **Model paths**: `/v1/models/paths`, `/v1/models/paths/model?model_id=...` - **Responses**: `/v1/responses` - **Chat Completions**: `/v1/chat/completions` - **Embeddings**: `/v1/embeddings` @@ -302,7 +303,7 @@ Get node metadata: GET /v1/info ``` -Supported models and pricing are available at `/v1/models`. +Supported models and pricing are available at `/v1/models`. Upstream provider path discovery is available at `/v1/models/paths` and `/v1/models/paths/model?model_id=...`. ## Next Steps diff --git a/docs/provider/configuration.md b/docs/provider/configuration.md index 40930676..45387fb4 100644 --- a/docs/provider/configuration.md +++ b/docs/provider/configuration.md @@ -137,6 +137,7 @@ Use environment variables for: | `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` | | `CORS_ORIGINS` | Allowed CORS origins | `*` | | `RELAYS` | Nostr relays (comma-separated) | (default set) | +| `MODEL_PATHS_REFRESH_INTERVAL_SECONDS` | How often to refresh `/v1/models/paths` discovery data; set `0` to disable | `600` | ### Priority @@ -156,3 +157,8 @@ Manage which AI models you offer: - **Create aliases** — friendly names for models See [Pricing](pricing.md) for per-model pricing strategies. + +Model path discovery is refreshed in the background and exposed through +`/v1/models/paths`. The response groups each client-visible model ID with the +provider paths that may appear in chat-completion response metadata. Tune the +refresh cadence with `MODEL_PATHS_REFRESH_INTERVAL_SECONDS`. diff --git a/migrations/versions/d7e8f9a0b1c2_add_model_paths_table.py b/migrations/versions/d7e8f9a0b1c2_add_model_paths_table.py new file mode 100644 index 00000000..e5ea85f4 --- /dev/null +++ b/migrations/versions/d7e8f9a0b1c2_add_model_paths_table.py @@ -0,0 +1,66 @@ +"""add model_paths table + +Revision ID: d7e8f9a0b1c2 +Revises: c6d7e8f9a0b1 +Create Date: 2026-07-05 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "d7e8f9a0b1c2" +down_revision = "c6d7e8f9a0b1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if "model_paths" in inspector.get_table_names(): + return + + op.create_table( + "model_paths", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("model_id", sa.String(), nullable=False), + sa.Column("path", sa.String(), nullable=False), + sa.Column("upstream_provider_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["upstream_provider_id"], + ["upstream_providers.id"], + ondelete="CASCADE", + ), + sa.UniqueConstraint( + "model_id", + "path", + "upstream_provider_id", + name="uq_model_paths_model_path_provider", + ), + ) + op.create_index( + "ix_model_paths_model_id", + "model_paths", + ["model_id"], + ) + op.create_index( + "ix_model_paths_upstream_provider_id", + "model_paths", + ["upstream_provider_id"], + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if "model_paths" not in inspector.get_table_names(): + return + + existing_indexes = {idx["name"] for idx in inspector.get_indexes("model_paths")} + if "ix_model_paths_upstream_provider_id" in existing_indexes: + op.drop_index("ix_model_paths_upstream_provider_id", table_name="model_paths") + if "ix_model_paths_model_id" in existing_indexes: + op.drop_index("ix_model_paths_model_id", table_name="model_paths") + op.drop_table("model_paths") diff --git a/routstr/core/db.py b/routstr/core/db.py index 586f467e..4023c0c9 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -210,6 +210,41 @@ class ModelRow(SQLModel, table=True): # type: ignore upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models") +class ModelPathRow(SQLModel, table=True): # type: ignore + """Upstream provider path a model is reachable through. + + Discovery/visibility data only. ``model_id`` is intentionally NOT globally + unique: it is the client-visible ``/v1/models`` id (``forwarded_model_id or + id``) grouped across every provider that exposes the model. A single model + can therefore have several rows — one per direct provider path plus one per + OpenRouter sub-provider endpoint. + """ + + __tablename__ = "model_paths" + __table_args__ = ( + UniqueConstraint( + "model_id", + "path", + "upstream_provider_id", + name="uq_model_paths_model_path_provider", + ), + ) + id: int | None = Field(default=None, primary_key=True) + model_id: str = Field( + index=True, 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'" + ) + upstream_provider_id: int = Field( + index=True, + foreign_key="upstream_providers.id", + ondelete="CASCADE", + description="upstream_providers.id this path was discovered from", + ) + + class LightningInvoice(SQLModel, table=True): # type: ignore __tablename__ = "lightning_invoices" diff --git a/routstr/core/main.py b/routstr/core/main.py index 903d6d1a..d105331a 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -58,6 +58,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: providers_task = None models_refresh_task = None model_maps_refresh_task = None + model_paths_refresh_task = None key_reset_task = None stale_reservation_task = None dead_key_prune_task = None @@ -127,6 +128,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: refresh_upstreams_models_periodically(get_upstreams) ) model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically()) + if global_settings.model_paths_refresh_interval_seconds > 0: + from ..upstream.model_paths import refresh_model_paths_periodically + + model_paths_refresh_task = asyncio.create_task( + refresh_model_paths_periodically(get_upstreams) + ) payout_task = asyncio.create_task(periodic_payout()) if global_settings.nsec: nip91_task = asyncio.create_task(announce_provider()) @@ -173,6 +180,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: models_refresh_task.cancel() if model_maps_refresh_task is not None: model_maps_refresh_task.cancel() + if model_paths_refresh_task is not None: + model_paths_refresh_task.cancel() if key_reset_task is not None: key_reset_task.cancel() if stale_reservation_task is not None: @@ -206,6 +215,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: tasks_to_wait.append(models_refresh_task) if model_maps_refresh_task is not None: tasks_to_wait.append(model_maps_refresh_task) + if model_paths_refresh_task is not None: + tasks_to_wait.append(model_paths_refresh_task) if key_reset_task is not None: tasks_to_wait.append(key_reset_task) if stale_reservation_task is not None: diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 3a144e10..5fce93af 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -94,6 +94,9 @@ class Settings(BaseSettings): models_refresh_interval_seconds: int = Field( default=360, env="MODELS_REFRESH_INTERVAL_SECONDS" ) + model_paths_refresh_interval_seconds: int = Field( + default=600, env="MODEL_PATHS_REFRESH_INTERVAL_SECONDS" + ) enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH") enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH") refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS") diff --git a/routstr/payment/models.py b/routstr/payment/models.py index e6ea643d..b21669a8 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -596,6 +596,29 @@ async def test_model( } +@models_router.get("/v1/models/paths") +@models_router.get("/v1/models/paths/", include_in_schema=False) +async def model_paths() -> dict: + """All models with every upstream provider path they are reachable through.""" + from ..upstream.model_paths import get_all_model_paths + + return {"data": await get_all_model_paths()} + + +@models_router.get("/v1/models/paths/model") +@models_router.get("/v1/models/paths/model/", include_in_schema=False) +async def model_paths_for_model(model_id: str) -> dict: + """Paths for a single model. + + Uses a query parameter (``?model_id=...``) under a fully static route so + model ids containing ``/`` (e.g. ``anthropic/claude-opus-4.6``) need no URL + encoding and there is no dynamic-route ambiguity. + """ + from ..upstream.model_paths import get_paths_for_model + + return {"data": await get_paths_for_model(model_id)} + + @models_router.get("/v1/models") @models_router.get("/v1/models/", include_in_schema=False) @models_router.get("/models") diff --git a/routstr/upstream/model_paths.py b/routstr/upstream/model_paths.py new file mode 100644 index 00000000..5ce1f527 --- /dev/null +++ b/routstr/upstream/model_paths.py @@ -0,0 +1,348 @@ +"""Model-path discovery service. + +Exposes every upstream provider path a Routstr model is reachable through. +This is discovery/visibility data only — routing still selects the cheapest or +best provider separately. + +A *path* is the provider string that may appear in Routstr chat completion +responses (see ``BaseUpstreamProvider._apply_provider_field``): + +- Direct upstream -> ```` e.g. ``anthropic`` +- Generic/custom OpenRouter-compatible upstream -> ``generic:`` +- Native OpenRouter routing to a sub-provider -> ``openrouter:`` + +Native OpenRouter does not emit a useful bare ``openrouter`` path when no +sub-provider is present; it reports ``unknown`` instead. +""" + +from __future__ import annotations + +import asyncio +import random +from typing import Callable + +import httpx +from sqlmodel import col, delete, select + +from ..core.db import ModelPathRow, create_session +from ..core.logging import get_logger +from .base import BaseUpstreamProvider + +logger = get_logger(__name__) + +# Bound the per-model OpenRouter /endpoints fan-out so a provider with hundreds +# of models does not open hundreds of concurrent requests every refresh. +_OPENROUTER_CONCURRENCY = 5 +_OPENROUTER_TIMEOUT_SECONDS = 10.0 + + +def is_openrouter_base_url(base_url: str | None) -> bool: + """True when ``base_url`` points at OpenRouter. + + Deliberately separate from ``BaseUpstreamProvider._upstream_accepts_cache_control``: + that predicate also returns True for native Anthropic (correct for + cache-control, wrong for OpenRouter endpoint discovery). This one keys only + on the URL so a ``GenericUpstreamProvider`` aimed at OpenRouter is matched + while native Anthropic is not. + """ + return "openrouter.ai" in (base_url or "") + + +def exposed_model_id(model: object) -> str: + """Client-visible ``/v1/models`` id for a cached model.""" + forwarded = getattr(model, "forwarded_model_id", None) + return forwarded or getattr(model, "id") + + +def public_model_id(model_id: str) -> str: + """Model id exposed by model-path API responses. + + Provider-prefixed ids such as ``z-ai/glm-5v-turbo`` are returned as + ``glm-5v-turbo`` so clients can search and display the same unqualified id + they pass to ``/v1/models/paths/model``. + """ + return model_id.rsplit("/", 1)[-1] + + +def openrouter_author_slug(model: object) -> str | None: + """Return a canonical ``author/slug`` for the OpenRouter endpoints API. + + OpenRouter requires the canonical id, never ``forwarded_model_id``. Prefer + ``canonical_slug``, then a slash-containing ``id``; otherwise there is no + usable form and endpoint discovery is skipped for this model. + """ + canonical = getattr(model, "canonical_slug", None) + if canonical and "/" in canonical: + return canonical + model_id = getattr(model, "id", None) + if model_id and "/" in model_id: + return model_id + return None + + +async def _fetch_openrouter_endpoint_paths( + client: httpx.AsyncClient, + base_url: str, + api_key: str, + author_slug: str, + path_prefix: str, + semaphore: asyncio.Semaphore, +) -> list[str]: + """Return ``:`` paths for one model, or ``[]``. + + Failures (network, rate limit, bad payload) are logged and swallowed so one + model never breaks the whole refresh. + """ + url = f"{base_url.rstrip('/')}/models/{author_slug}/endpoints" + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + async with semaphore: + try: + resp = await client.get( + url, headers=headers, timeout=_OPENROUTER_TIMEOUT_SECONDS + ) + except Exception as e: # noqa: BLE001 - isolate per-model failures + logger.warning( + "OpenRouter endpoint discovery request failed", + extra={"author_slug": author_slug, "error": str(e)}, + ) + return [] + + if resp.status_code == 429: + logger.warning( + "OpenRouter endpoint discovery rate-limited", + extra={"author_slug": author_slug}, + ) + return [] + if resp.status_code != 200: + logger.warning( + "OpenRouter endpoint discovery non-200", + extra={"author_slug": author_slug, "status_code": resp.status_code}, + ) + return [] + + try: + endpoints = resp.json().get("data", {}).get("endpoints", []) + except Exception as e: # noqa: BLE001 + logger.warning( + "OpenRouter endpoint discovery bad payload", + extra={"author_slug": author_slug, "error": str(e)}, + ) + return [] + + paths: list[str] = [] + for endpoint in endpoints: + provider_name = (endpoint or {}).get("provider_name") + if provider_name: + paths.append(f"{path_prefix}:{provider_name}") + # De-duplicate while preserving order. + return list(dict.fromkeys(paths)) + + +async def _collect_provider_paths( + upstream: BaseUpstreamProvider, +) -> list[tuple[str, str]]: + """Collect ``(model_id, path)`` pairs for one provider instance. + + Emits the direct ```` path for normal upstreams. For + OpenRouter-compatible providers, emits one path per OpenRouter sub-provider + endpoint, prefixed the same way response stamping prefixes it. + """ + provider_type = (upstream.provider_type or "").strip() + models = [m for m in upstream.get_cached_models() if getattr(m, "enabled", True)] + is_openrouter = is_openrouter_base_url(upstream.base_url) + + pairs: list[tuple[str, str]] = [] + if not is_openrouter: + for model in models: + if provider_type: + pairs.append((exposed_model_id(model), provider_type)) + return pairs + + if not provider_type: + return pairs + + semaphore = asyncio.Semaphore(_OPENROUTER_CONCURRENCY) + async with httpx.AsyncClient() as client: + + async def _for_model(model: object) -> list[tuple[str, str]]: + author_slug = openrouter_author_slug(model) + if not author_slug: + return [] + paths = await _fetch_openrouter_endpoint_paths( + client, + upstream.base_url, + upstream.api_key, + author_slug, + provider_type, + semaphore, + ) + model_id = exposed_model_id(model) + return [(model_id, path) for path in paths] + + results = await asyncio.gather( + *(_for_model(m) for m in models), return_exceptions=True + ) + + for result in results: + if isinstance(result, BaseException): + logger.warning( + "OpenRouter endpoint discovery task errored", + extra={"provider": provider_type, "error": str(result)}, + ) + continue + pairs.extend(result) + + return pairs + + +async def _persist_provider_paths( + upstream_provider_id: int, pairs: list[tuple[str, str]] +) -> None: + """Replace all rows for ``upstream_provider_id`` with ``pairs``. + + Replacement (not upsert) so stale paths disappear when provider config or + upstream availability changes. + """ + unique_pairs = list(dict.fromkeys(pairs)) + async with create_session() as session: + await session.exec( # type: ignore[call-overload] + delete(ModelPathRow).where( + col(ModelPathRow.upstream_provider_id) == upstream_provider_id + ) + ) + for model_id, path in unique_pairs: + session.add( + ModelPathRow( + model_id=model_id, + path=path, + upstream_provider_id=upstream_provider_id, + ) + ) + await session.commit() + + +async def _prune_inactive_provider_paths(active_provider_ids: set[int]) -> None: + """Delete paths for providers no longer present in the live upstream set.""" + async with create_session() as session: + stmt = delete(ModelPathRow) + if active_provider_ids: + stmt = stmt.where( + col(ModelPathRow.upstream_provider_id).not_in(active_provider_ids) + ) + await session.exec(stmt) # type: ignore[call-overload] + await session.commit() + + +async def refresh_model_paths( + upstreams: list[BaseUpstreamProvider], +) -> None: + """Recompute and persist model paths for every enabled provider. + + One provider's failure is logged and isolated; it must not break the rest. + """ + active_provider_ids = { + upstream.db_id for upstream in upstreams if upstream.db_id is not None + } + await _prune_inactive_provider_paths(active_provider_ids) + + for upstream in upstreams: + if upstream.db_id is None: + continue + try: + pairs = await _collect_provider_paths(upstream) + await _persist_provider_paths(upstream.db_id, pairs) + except Exception as e: # noqa: BLE001 - isolate per-provider failures + logger.error( + "Failed to refresh model paths for provider", + extra={ + "provider": upstream.provider_type or upstream.base_url, + "db_id": upstream.db_id, + "error": str(e), + "error_type": type(e).__name__, + }, + ) + + +async def refresh_model_paths_periodically( + upstreams_provider: ( + Callable[[], list[BaseUpstreamProvider]] | list[BaseUpstreamProvider] + ), +) -> None: + """Background task mirroring ``refresh_upstreams_models_periodically``.""" + from ..core.settings import settings + + interval = getattr(settings, "model_paths_refresh_interval_seconds", 0) + if not interval or interval <= 0: + logger.info("Model paths refresh disabled (interval <= 0)") + return + + def _resolve_upstreams() -> list[BaseUpstreamProvider]: + if callable(upstreams_provider): + return upstreams_provider() + return upstreams_provider + + while True: + try: + await refresh_model_paths(_resolve_upstreams()) + except asyncio.CancelledError: + break + except Exception as e: # noqa: BLE001 + logger.error( + "Error in model paths refresh loop", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + + try: + jitter = max(0.0, float(interval) * 0.1) + await asyncio.sleep(interval + random.uniform(0, jitter)) + except asyncio.CancelledError: + break + + +async def get_all_model_paths() -> list[dict]: + """All models with their paths, shaped for ``GET /v1/models/paths``.""" + async with create_session() as session: + rows = ( + await session.exec(select(ModelPathRow).order_by(ModelPathRow.model_id)) + ).all() + + grouped: dict[str, list[dict]] = {} + seen_paths: dict[str, set[str]] = {} + for row in rows: + model_id = public_model_id(row.model_id) + if row.path in seen_paths.setdefault(model_id, set()): + continue + seen_paths[model_id].add(row.path) + grouped.setdefault(model_id, []).append({"path": row.path}) + return [{"id": model_id, "paths": paths} for model_id, paths in grouped.items()] + + +async def get_paths_for_model(model_id: str) -> list[dict]: + """Paths for a single model, shaped for ``GET /v1/models/paths/model``. + + Match by the public, unqualified model id, mirroring the model cache alias + behavior. Both ``deepseek-v4-pro`` and ``deepseek/deepseek-v4-pro`` resolve + every row whose stored id has the same base model id. + """ + requested_id = public_model_id(model_id) + async with create_session() as session: + rows = ( + await session.exec( + select(ModelPathRow).order_by( + ModelPathRow.path, + col(ModelPathRow.upstream_provider_id), + ModelPathRow.model_id, + ) + ) + ).all() + + seen: set[str] = set() + paths: list[dict] = [] + for row in rows: + if public_model_id(row.model_id) != requested_id: + continue + if row.path in seen: + continue + seen.add(row.path) + paths.append({"path": row.path}) + return paths diff --git a/tests/unit/test_model_paths.py b/tests/unit/test_model_paths.py new file mode 100644 index 00000000..75468383 --- /dev/null +++ b/tests/unit/test_model_paths.py @@ -0,0 +1,591 @@ +"""Tests for the model-path discovery service and endpoints.""" + +from __future__ import annotations + +import asyncio +import os +from contextlib import asynccontextmanager +from types import SimpleNamespace +from typing import Any, AsyncGenerator + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession + +os.environ.setdefault("UPSTREAM_BASE_URL", "http://test") +os.environ.setdefault("UPSTREAM_API_KEY", "test") + +from routstr.core.db import UpstreamProviderRow # noqa: E402 +from routstr.payment.models import models_router # noqa: E402 +from routstr.upstream import model_paths as mp # noqa: E402 + +# --------------------------------------------------------------------------- # +# Fakes +# --------------------------------------------------------------------------- # + + +def _model( + id: str, + *, + forwarded_model_id: str | None = None, + canonical_slug: str | None = None, + enabled: bool = True, +) -> SimpleNamespace: + return SimpleNamespace( + id=id, + forwarded_model_id=forwarded_model_id, + canonical_slug=canonical_slug, + enabled=enabled, + ) + + +class _FakeProvider: + def __init__( + self, + *, + provider_type: str, + base_url: str, + models: list[SimpleNamespace], + db_id: int | None = 1, + api_key: str = "sk-test", + ) -> None: + self.provider_type = provider_type + self.base_url = base_url + self.api_key = api_key + self.db_id = db_id + self._models = models + + def get_cached_models(self) -> list[SimpleNamespace]: + return self._models + + +class _FakeResponse: + def __init__(self, status_code: int, payload: Any) -> None: + self.status_code = status_code + self._payload = payload + + def json(self) -> Any: + return self._payload + + +@pytest.fixture +async def patched_session( + monkeypatch: pytest.MonkeyPatch, +) -> AsyncGenerator[AsyncEngine, None]: + """Bind the service's ``create_session`` to a fresh in-memory engine.""" + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + # Seed the FK target so ModelPathRow inserts satisfy the constraint. + async with AsyncSession(engine) as session: + for pid in (1, 2): + session.add( + UpstreamProviderRow( + id=pid, + slug=f"p{pid}", + provider_type="anthropic" if pid == 1 else "openrouter", + base_url=f"https://provider-{pid}", + api_key=f"k{pid}", + ) + ) + await session.commit() + + @asynccontextmanager + async def _factory() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSession(engine, expire_on_commit=False) as session: + yield session + + monkeypatch.setattr(mp, "create_session", _factory) + yield engine + await engine.dispose() + + +# --------------------------------------------------------------------------- # +# Predicates / pure helpers +# --------------------------------------------------------------------------- # + + +def test_is_openrouter_base_url() -> None: + assert mp.is_openrouter_base_url("https://openrouter.ai/api/v1") is True + assert mp.is_openrouter_base_url("https://api.anthropic.com") is False + assert mp.is_openrouter_base_url(None) is False + + +def test_native_anthropic_not_openrouter() -> None: + """Native Anthropic must not be treated as OpenRouter-compatible even though + ``_upstream_accepts_cache_control`` returns True for it.""" + assert mp.is_openrouter_base_url("https://api.anthropic.com/v1") is False + + +def test_exposed_model_id_prefers_forwarded() -> None: + assert ( + mp.exposed_model_id(_model("claude-x", forwarded_model_id="fwd-claude")) + == "fwd-claude" + ) + assert mp.exposed_model_id(_model("claude-x")) == "claude-x" + + +def test_public_model_id_strips_provider_prefix() -> None: + assert mp.public_model_id("z-ai/glm-5v-turbo") == "glm-5v-turbo" + assert mp.public_model_id("gpt-4o-mini") == "gpt-4o-mini" + + +def test_openrouter_author_slug_uses_canonical_not_forwarded() -> None: + m = _model( + "claude-opus-4.6", + forwarded_model_id="forwarded-only", + canonical_slug="anthropic/claude-opus-4.6", + ) + assert mp.openrouter_author_slug(m) == "anthropic/claude-opus-4.6" + + +def test_openrouter_author_slug_falls_back_to_slash_id() -> None: + m = _model("anthropic/claude-opus-4.6", canonical_slug="claude-opus-4.6") + assert mp.openrouter_author_slug(m) == "anthropic/claude-opus-4.6" + + +def test_openrouter_author_slug_none_when_no_slash() -> None: + m = _model("claude-opus-4.6", canonical_slug="claude-opus-4.6") + assert mp.openrouter_author_slug(m) is None + + +# --------------------------------------------------------------------------- # +# Collection +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_direct_provider_single_path_uses_provider_type() -> None: + provider = _FakeProvider( + provider_type="anthropic", + base_url="https://api.anthropic.com/v1", + models=[_model("claude-opus-4.6")], + ) + pairs = await mp._collect_provider_paths(provider) # type: ignore[arg-type] + assert pairs == [("claude-opus-4.6", "anthropic")] + + +@pytest.mark.asyncio +async def test_direct_path_stores_exposed_model_id() -> None: + provider = _FakeProvider( + provider_type="anthropic", + base_url="https://api.anthropic.com/v1", + models=[_model("internal-id", forwarded_model_id="claude-opus-4.6")], + ) + pairs = await mp._collect_provider_paths(provider) # type: ignore[arg-type] + assert pairs == [("claude-opus-4.6", "anthropic")] + + +@pytest.mark.asyncio +async def test_disabled_models_excluded() -> None: + provider = _FakeProvider( + provider_type="anthropic", + base_url="https://api.anthropic.com/v1", + models=[ + _model("enabled-model"), + _model("disabled-model", enabled=False), + ], + ) + pairs = await mp._collect_provider_paths(provider) # type: ignore[arg-type] + assert pairs == [("enabled-model", "anthropic")] + + +@pytest.mark.asyncio +async def test_openrouter_provider_adds_endpoint_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = _FakeProvider( + provider_type="openrouter", + base_url="https://openrouter.ai/api/v1", + models=[_model("claude-opus-4.6", canonical_slug="anthropic/claude-opus-4.6")], + ) + + async def _fake_get( + self: object, + url: str, + headers: object = None, + timeout: object = None, + ) -> _FakeResponse: + return _FakeResponse( + 200, + { + "data": { + "endpoints": [ + {"provider_name": "Anthropic"}, + {"provider_name": "Amazon Bedrock"}, + ] + } + }, + ) + + monkeypatch.setattr("httpx.AsyncClient.get", _fake_get) + + pairs = await mp._collect_provider_paths(provider) # type: ignore[arg-type] + assert ("claude-opus-4.6", "openrouter:Anthropic") in pairs + assert ("claude-opus-4.6", "openrouter:Amazon Bedrock") in pairs + assert ("claude-opus-4.6", "openrouter") not in pairs + assert len(pairs) == 2 + + +@pytest.mark.asyncio +async def test_generic_provider_with_openrouter_base_url_discovers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A generic provider pointed at OpenRouter exposes the response-stamped + ``generic:`` path, not a native ``openrouter:`` path.""" + provider = _FakeProvider( + provider_type="generic", + base_url="https://openrouter.ai/api/v1", + models=[_model("claude-opus-4.6", canonical_slug="anthropic/claude-opus-4.6")], + ) + + async def _fake_get( + self: object, + url: str, + headers: object = None, + timeout: object = None, + ) -> _FakeResponse: + return _FakeResponse( + 200, {"data": {"endpoints": [{"provider_name": "Anthropic"}]}} + ) + + monkeypatch.setattr("httpx.AsyncClient.get", _fake_get) + + pairs = await mp._collect_provider_paths(provider) # type: ignore[arg-type] + assert pairs == [("claude-opus-4.6", "generic:Anthropic")] + + +@pytest.mark.asyncio +async def test_openrouter_failure_degrades_gracefully( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = _FakeProvider( + provider_type="openrouter", + base_url="https://openrouter.ai/api/v1", + models=[_model("claude-opus-4.6", canonical_slug="anthropic/claude-opus-4.6")], + ) + + async def _boom( + self: object, + url: str, + headers: object = None, + timeout: object = None, + ) -> _FakeResponse: + raise RuntimeError("network down") + + monkeypatch.setattr("httpx.AsyncClient.get", _boom) + + pairs = await mp._collect_provider_paths(provider) # type: ignore[arg-type] + assert pairs == [] + + +@pytest.mark.asyncio +async def test_openrouter_rate_limit_skips_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = _FakeProvider( + provider_type="openrouter", + base_url="https://openrouter.ai/api/v1", + models=[_model("claude-opus-4.6", canonical_slug="anthropic/claude-opus-4.6")], + ) + + async def _rate_limited( + self: object, + url: str, + headers: object = None, + timeout: object = None, + ) -> _FakeResponse: + return _FakeResponse(429, {}) + + monkeypatch.setattr("httpx.AsyncClient.get", _rate_limited) + + pairs = await mp._collect_provider_paths(provider) # type: ignore[arg-type] + assert pairs == [] + + +@pytest.mark.asyncio +async def test_openrouter_fanout_is_bounded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(mp, "_OPENROUTER_CONCURRENCY", 3) + models = [ + _model(f"m{i}", canonical_slug=f"author/m{i}") for i in range(20) + ] + provider = _FakeProvider( + provider_type="openrouter", + base_url="https://openrouter.ai/api/v1", + models=models, + ) + + state = {"current": 0, "max": 0} + + async def _slow_get( + self: object, + url: str, + headers: object = None, + timeout: object = None, + ) -> _FakeResponse: + state["current"] += 1 + state["max"] = max(state["max"], state["current"]) + await asyncio.sleep(0.02) + state["current"] -= 1 + return _FakeResponse(200, {"data": {"endpoints": [{"provider_name": "X"}]}}) + + monkeypatch.setattr("httpx.AsyncClient.get", _slow_get) + + await mp._collect_provider_paths(provider) # type: ignore[arg-type] + assert state["max"] <= 3, f"concurrency exceeded bound: {state['max']}" + + +# --------------------------------------------------------------------------- # +# Persistence + query +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_refresh_replaces_stale_rows( + patched_session: AsyncEngine, +) -> None: + await mp._persist_provider_paths(1, [("m1", "anthropic"), ("m2", "anthropic")]) + first = await mp.get_all_model_paths() + assert {row["id"] for row in first} == {"m1", "m2"} + + # Second refresh with a different set — stale m2 must disappear. + await mp._persist_provider_paths(1, [("m1", "anthropic")]) + second = await mp.get_all_model_paths() + assert {row["id"] for row in second} == {"m1"} + + +@pytest.mark.asyncio +async def test_refresh_model_paths_prunes_inactive_provider_rows( + patched_session: AsyncEngine, +) -> None: + await mp._persist_provider_paths(1, [("m1", "anthropic")]) + await mp._persist_provider_paths(2, [("m2", "openrouter:Anthropic")]) + + provider = _FakeProvider( + provider_type="anthropic", + base_url="https://api.anthropic.com/v1", + models=[_model("m1")], + db_id=1, + ) + + await mp.refresh_model_paths([provider]) # type: ignore[list-item] + active_only = await mp.get_all_model_paths() + assert active_only == [{"id": "m1", "paths": [{"path": "anthropic"}]}] + + await mp.refresh_model_paths([]) + assert await mp.get_all_model_paths() == [] + + +@pytest.mark.asyncio +async def test_same_model_two_providers_two_paths( + patched_session: AsyncEngine, +) -> None: + await mp._persist_provider_paths(1, [("claude-opus-4.6", "anthropic")]) + await mp._persist_provider_paths(2, [("claude-opus-4.6", "openrouter:Anthropic")]) + + data = await mp.get_all_model_paths() + assert len(data) == 1 + entry = data[0] + assert entry["id"] == "claude-opus-4.6" + paths = {p["path"] for p in entry["paths"]} + assert paths == {"anthropic", "openrouter:Anthropic"} + # No canonical_id anywhere. + 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( + patched_session: AsyncEngine, +) -> None: + await mp._persist_provider_paths(1, [("anthropic/claude-opus-4.6", "anthropic")]) + await mp._persist_provider_paths(2, [("claude-opus-4.6", "anthropic")]) + + assert await mp.get_all_model_paths() == [ + {"id": "claude-opus-4.6", "paths": [{"path": "anthropic"}]} + ] + + +@pytest.mark.asyncio +async def test_get_all_model_paths_returns_unqualified_model_ids( + patched_session: AsyncEngine, +) -> None: + await mp._persist_provider_paths(4, [("z-ai/glm-5v-turbo", "openrouter:Z.AI")]) + await mp._persist_provider_paths(5, [("openai/gpt-4o-mini", "openrouter:OpenAI")]) + + data = await mp.get_all_model_paths() + + assert {row["id"] for row in data} == {"glm-5v-turbo", "gpt-4o-mini"} + + +@pytest.mark.asyncio +async def test_get_paths_for_model_returns_only_paths( + patched_session: AsyncEngine, +) -> None: + await mp._persist_provider_paths(1, [("claude-opus-4.6", "anthropic")]) + await mp._persist_provider_paths(2, [("claude-opus-4.6", "openrouter:Anthropic")]) + + paths = await mp.get_paths_for_model("claude-opus-4.6") + assert {p["path"] for p in paths} == {"anthropic", "openrouter:Anthropic"} + assert all(set(p.keys()) == {"path"} for p in paths) + assert await mp.get_paths_for_model("does-not-exist") == [] + + +@pytest.mark.asyncio +async def test_get_paths_for_model_falls_back_to_provider_prefixed_id( + patched_session: AsyncEngine, +) -> None: + await mp._persist_provider_paths(4, [("z-ai/glm-5v-turbo", "openrouter:Z.AI")]) + + paths = await mp.get_paths_for_model("glm-5v-turbo") + + assert paths == [{"path": "openrouter:Z.AI"}] + + +@pytest.mark.asyncio +async def test_get_paths_for_model_deduplicates_visible_paths( + patched_session: AsyncEngine, +) -> None: + await mp._persist_provider_paths(1, [("anthropic/claude-opus-4.6", "anthropic")]) + await mp._persist_provider_paths(2, [("claude-opus-4.6", "anthropic")]) + + assert await mp.get_paths_for_model("claude-opus-4.6") == [ + {"path": "anthropic"} + ] + assert await mp.get_paths_for_model("anthropic/claude-opus-4.6") == [ + {"path": "anthropic"} + ] + + +@pytest.mark.asyncio +async def test_get_paths_for_model_merges_prefixed_and_unprefixed_aliases( + patched_session: AsyncEngine, +) -> None: + await mp._persist_provider_paths(7, [("deepseek-v4-pro", "generic")]) + await mp._persist_provider_paths( + 4, [("deepseek/deepseek-v4-pro", "openrouter:DeepSeek")] + ) + + short_paths = await mp.get_paths_for_model("deepseek-v4-pro") + prefixed_paths = await mp.get_paths_for_model("deepseek/deepseek-v4-pro") + + assert short_paths == [ + {"path": "generic"}, + {"path": "openrouter:DeepSeek"}, + ] + assert prefixed_paths == short_paths + + +@pytest.mark.asyncio +async def test_refresh_model_paths_skips_provider_without_db_id( + patched_session: AsyncEngine, +) -> None: + provider = _FakeProvider( + provider_type="anthropic", + base_url="https://api.anthropic.com/v1", + models=[_model("claude-opus-4.6")], + db_id=None, + ) + await mp.refresh_model_paths([provider]) # type: ignore[list-item] + assert await mp.get_all_model_paths() == [] + + +@pytest.mark.asyncio +async def test_refresh_model_paths_isolates_provider_failure( + patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch +) -> None: + good = _FakeProvider( + provider_type="anthropic", + base_url="https://api.anthropic.com/v1", + models=[_model("claude-opus-4.6")], + db_id=1, + ) + bad = _FakeProvider( + provider_type="openrouter", + base_url="https://openrouter.ai/api/v1", + models=[_model("m", canonical_slug="a/m")], + db_id=2, + ) + + original = mp._collect_provider_paths + + async def _maybe_fail(upstream: Any) -> list[tuple[str, str]]: + if upstream is bad: + raise RuntimeError("boom") + return await original(upstream) # type: ignore[arg-type] + + monkeypatch.setattr(mp, "_collect_provider_paths", _maybe_fail) + + await mp.refresh_model_paths([good, bad]) # type: ignore[list-item] + data = await mp.get_all_model_paths() + assert {row["id"] for row in data} == {"claude-opus-4.6"} + + +# --------------------------------------------------------------------------- # +# Endpoints +# --------------------------------------------------------------------------- # + + +def _make_model_paths_app() -> FastAPI: + app = FastAPI() + app.include_router(models_router) + return app + + +def test_model_paths_endpoint_returns_all_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_get_all_model_paths() -> list[dict[str, Any]]: + return [ + { + "id": "claude-opus-4.6", + "paths": [ + {"path": "anthropic"}, + {"path": "openrouter:Anthropic"}, + ], + } + ] + + 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"}, + ], + } + ] + } + + +def test_model_paths_for_model_endpoint_accepts_slash_model_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + async def _fake_get_paths_for_model(model_id: str) -> list[dict[str, Any]]: + calls.append(model_id) + return [{"path": "generic:Anthropic"}] + + monkeypatch.setattr(mp, "get_paths_for_model", _fake_get_paths_for_model) + + response = TestClient(_make_model_paths_app()).get( + "/v1/models/paths/model", + params={"model_id": "anthropic/claude-opus-4.6"}, + ) + + assert response.status_code == 200 + assert response.json() == {"data": [{"path": "generic:Anthropic"}]} + assert calls == ["anthropic/claude-opus-4.6"]