diff --git a/tests/unit/test_model_paths.py b/tests/unit/test_model_paths.py index 32542e89..5250c05a 100644 --- a/tests/unit/test_model_paths.py +++ b/tests/unit/test_model_paths.py @@ -28,6 +28,7 @@ os.environ.setdefault("UPSTREAM_BASE_URL", "http://test") os.environ.setdefault("UPSTREAM_API_KEY", "test") from routstr.core.db import ModelRow, UpstreamProviderRow # noqa: E402 +from routstr.payment import price as price_module # noqa: E402 from routstr.payment.models import models_router # noqa: E402 from routstr.upstream import model_paths as mp # noqa: E402 from routstr.upstream.base import BaseUpstreamProvider # noqa: E402 @@ -202,6 +203,75 @@ async def patched_session( await engine.dispose() +# 1 sat = $0.00005, the quote the path pricing converts with in these tests. +_QUOTE = 5.0e-5 +_DEFAULT_FEE = 1.01 + + +@pytest.fixture +def sats_quote(monkeypatch: pytest.MonkeyPatch) -> float: + monkeypatch.setattr(price_module, "sats_usd_price", lambda: _QUOTE) + return _QUOTE + + +def _priced_endpoints_response() -> httpx.Response: + """Two endpoints for one model, priced and sized differently.""" + return httpx.Response( + 200, + json={ + "data": { + "id": "anthropic/claude-opus-4.6", + "name": "Claude Opus 4.6", + "description": "Anthropic's most capable model", + "architecture": { + "input_modalities": ["text", "image"], + "output_modalities": ["text"], + "tokenizer": "Claude", + "instruct_type": None, + }, + "endpoints": [ + { + "provider_name": "Anthropic", + "tag": "anthropic", + "context_length": 200_000, + "pricing": { + "prompt": "0.000005", + "completion": "0.000025", + }, + }, + { + "provider_name": "Google", + "tag": "google-vertex/us", + "context_length": 128_000, + "pricing": { + "prompt": "0.000003", + "completion": "0.000015", + }, + }, + ], + } + }, + ) + + +def _models_by_endpoint(payload: dict, model_id: str) -> dict[str, dict]: + entry = next(item for item in payload["data"] if item["id"] == model_id) + return { + path["endpoint"]["tag"]: path["model"] + for path in entry["paths"] + if path["endpoint"] is not None + } + + +async def _set_provider_fee(engine: AsyncEngine, provider_id: int, fee: float) -> None: + async with AsyncSession(engine) as session: + provider = await session.get(UpstreamProviderRow, provider_id) + assert provider is not None + provider.provider_fee = fee + 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: @@ -809,74 +879,144 @@ async def test_openrouter_provider_adds_endpoint_paths( @pytest.mark.asyncio async def test_openrouter_paths_include_endpoint_specific_model_prices( - patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch + patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch, sats_quote: float ) -> None: provider = _FakeOpenRouterProvider( models=[_model("claude-opus-4.6", canonical_slug="anthropic/claude-opus-4.6")], db_id=2, ) - endpoint_response = httpx.Response( - 200, - json={ - "data": { - "id": "anthropic/claude-opus-4.6", - "name": "Claude Opus 4.6", - "description": "Anthropic's most capable model", - "architecture": { - "input_modalities": ["text", "image"], - "output_modalities": ["text"], - "tokenizer": "Claude", - "instruct_type": None, - }, - "endpoints": [ - { - "provider_name": "Anthropic", - "tag": "anthropic", - "context_length": 200_000, - "pricing": { - "prompt": "0.000005", - "completion": "0.000025", - }, - }, - { - "provider_name": "Google", - "tag": "google-vertex/us", - "context_length": 128_000, - "pricing": { - "prompt": "0.000003", - "completion": "0.000015", - }, - }, - ], - } - }, - ) - _mock_transport(monkeypatch, lambda request: endpoint_response) + _mock_transport(monkeypatch, lambda request: _priced_endpoints_response()) await mp.refresh_model_paths([provider]) payload = await mp.get_all_model_paths() assert payload["data"][0]["id"] == "claude-opus-4.6" - paths_by_endpoint = { - item["endpoint"]["tag"]: item - for item in payload["data"][0]["paths"] - if item["endpoint"] is not None - } + models = _models_by_endpoint(payload, "claude-opus-4.6") - anthropic = paths_by_endpoint["anthropic"]["model"] - google = paths_by_endpoint["google-vertex/us"]["model"] + anthropic = models["anthropic"] + google = models["google-vertex/us"] assert anthropic["description"] == "Anthropic's most capable model" assert google["description"] == "Anthropic's most capable model" + assert anthropic["pricing"]["prompt"] == pytest.approx(0.000005 * _DEFAULT_FEE) + assert anthropic["pricing"]["completion"] == pytest.approx(0.000025 * _DEFAULT_FEE) + assert google["pricing"]["prompt"] == pytest.approx(0.000003 * _DEFAULT_FEE) + assert google["pricing"]["completion"] == pytest.approx(0.000015 * _DEFAULT_FEE) + assert anthropic["context_length"] == 200_000 + assert google["context_length"] == 128_000 + + +@pytest.mark.asyncio +async def test_endpoint_paths_are_priced_in_sats_from_their_own_rates( + patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch, sats_quote: float +) -> None: + provider = _FakeOpenRouterProvider( + models=[_model("claude-opus-4.6", canonical_slug="anthropic/claude-opus-4.6")], + db_id=2, + ) + _mock_transport(monkeypatch, lambda request: _priced_endpoints_response()) + + await mp.refresh_model_paths([provider]) + + models = _models_by_endpoint(await mp.get_all_model_paths(), "claude-opus-4.6") + anthropic = models["anthropic"]["sats_pricing"] + google = models["google-vertex/us"]["sats_pricing"] + + assert anthropic["prompt"] == pytest.approx(0.000005 * _DEFAULT_FEE / sats_quote) + assert anthropic["completion"] == pytest.approx( + 0.000025 * _DEFAULT_FEE / sats_quote + ) + assert google["prompt"] == pytest.approx(0.000003 * _DEFAULT_FEE / sats_quote) + assert google["completion"] == pytest.approx(0.000015 * _DEFAULT_FEE / sats_quote) + + +@pytest.mark.asyncio +async def test_endpoint_max_costs_use_that_endpoint_context_length( + patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch, sats_quote: float +) -> None: + provider = _FakeOpenRouterProvider( + models=[_model("claude-opus-4.6", canonical_slug="anthropic/claude-opus-4.6")], + db_id=2, + ) + _mock_transport(monkeypatch, lambda request: _priced_endpoints_response()) + + await mp.refresh_model_paths([provider]) + + models = _models_by_endpoint(await mp.get_all_model_paths(), "claude-opus-4.6") + # Max cost is the context window billed at the dearer of the two rates. + assert models["anthropic"]["sats_pricing"]["max_cost"] == pytest.approx( + 200_000 * 0.000025 * _DEFAULT_FEE / sats_quote + ) + assert models["google-vertex/us"]["sats_pricing"]["max_cost"] == pytest.approx( + 128_000 * 0.000015 * _DEFAULT_FEE / sats_quote + ) + + +@pytest.mark.asyncio +async def test_path_pricing_uses_the_provider_fee_of_its_own_provider( + patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch, sats_quote: float +) -> None: + await _set_provider_fee(patched_session, 2, 1.5) + provider = _FakeOpenRouterProvider( + models=[_model("claude-opus-4.6", canonical_slug="anthropic/claude-opus-4.6")], + db_id=2, + ) + _mock_transport(monkeypatch, lambda request: _priced_endpoints_response()) + + await mp.refresh_model_paths([provider]) + + models = _models_by_endpoint(await mp.get_all_model_paths(), "claude-opus-4.6") + anthropic = models["anthropic"] + assert anthropic["pricing"]["prompt"] == pytest.approx(0.000005 * 1.5) + assert anthropic["sats_pricing"]["prompt"] == pytest.approx( + 0.000005 * 1.5 / sats_quote + ) + + +@pytest.mark.asyncio +async def test_paths_keep_upstream_pricing_when_the_quote_is_unavailable( + patched_session: AsyncEngine, monkeypatch: pytest.MonkeyPatch +) -> None: + def _no_quote() -> float: + raise ValueError("SATS price not initialized") + + monkeypatch.setattr(price_module, "sats_usd_price", _no_quote) + provider = _FakeOpenRouterProvider( + models=[_model("claude-opus-4.6", canonical_slug="anthropic/claude-opus-4.6")], + db_id=2, + ) + _mock_transport(monkeypatch, lambda request: _priced_endpoints_response()) + + await mp.refresh_model_paths([provider]) + + models = _models_by_endpoint(await mp.get_all_model_paths(), "claude-opus-4.6") + anthropic = models["anthropic"] + assert "sats_pricing" not in anthropic assert anthropic["pricing"] == { "prompt": "0.000005", "completion": "0.000025", } - assert google["pricing"] == { - "prompt": "0.000003", - "completion": "0.000015", - } - assert anthropic["context_length"] == 200_000 - assert google["context_length"] == 128_000 + + +@pytest.mark.asyncio +async def test_already_priced_metadata_is_not_priced_again( + patched_session: AsyncEngine, sats_quote: float +) -> None: + model = _model("claude-opus-4.6") + model.pricing = {"prompt": 0.000001, "completion": 0.000002} + model.sats_pricing = {"prompt": 0.02, "completion": 0.04} + provider = _FakeProvider( + provider_type="anthropic", + base_url="https://api.anthropic.com/v1", + models=[model], + db_id=1, + ) + + await mp.refresh_model_paths([provider]) + + payload = await mp.get_all_model_paths() + priced = payload["data"][0]["paths"][0]["model"] + assert priced["sats_pricing"] == {"prompt": 0.02, "completion": 0.04} + assert priced["pricing"] == {"prompt": 0.000001, "completion": 0.000002} @pytest.mark.asyncio