diff --git a/migrations/versions/c6d7e8f9a0b1_add_slug_to_upstream_providers.py b/migrations/versions/c6d7e8f9a0b1_add_slug_to_upstream_providers.py new file mode 100644 index 00000000..c140b557 --- /dev/null +++ b/migrations/versions/c6d7e8f9a0b1_add_slug_to_upstream_providers.py @@ -0,0 +1,88 @@ +"""add slug to upstream_providers + +Revision ID: c6d7e8f9a0b1 +Revises: b5e7c9d1f3a2 +Create Date: 2026-06-29 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +from routstr.core.provider_slugs import provider_slug_base, provider_slug_candidate + +revision = "c6d7e8f9a0b1" +down_revision = "b5e7c9d1f3a2" +branch_labels = None +depends_on = None + + +def _allocate_backfill_slug(provider_type: str, reserved_slugs: set[str]) -> str: + base = provider_slug_base(provider_type) + suffix_number = 1 + while True: + candidate = provider_slug_candidate(base, suffix_number) + if candidate not in reserved_slugs: + reserved_slugs.add(candidate) + return candidate + suffix_number += 1 + + +def _backfill_provider_slugs(conn: sa.Connection) -> None: + existing_rows = conn.execute( + sa.text( + "SELECT slug FROM upstream_providers " + "WHERE slug IS NOT NULL AND slug != ''" + ) + ) + reserved_slugs = {str(row.slug).lower() for row in existing_rows} + + rows_to_backfill = conn.execute( + sa.text( + "SELECT id, provider_type FROM upstream_providers " + "WHERE slug IS NULL OR slug = '' " + "ORDER BY id" + ) + ) + for row in rows_to_backfill: + slug = _allocate_backfill_slug(str(row.provider_type), reserved_slugs) + conn.execute( + sa.text("UPDATE upstream_providers SET slug = :slug WHERE id = :id"), + {"slug": slug, "id": row.id}, + ) + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = {c["name"] for c in inspector.get_columns("upstream_providers")} + + if "slug" not in columns: + op.add_column( + "upstream_providers", + sa.Column("slug", sa.String(), nullable=True), + ) + + _backfill_provider_slugs(conn) + + existing_indexes = {idx["name"] for idx in inspector.get_indexes("upstream_providers")} + if "ix_upstream_providers_slug" not in existing_indexes: + op.create_index( + "ix_upstream_providers_slug", + "upstream_providers", + ["slug"], + unique=True, + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + existing_indexes = {idx["name"] for idx in inspector.get_indexes("upstream_providers")} + if "ix_upstream_providers_slug" in existing_indexes: + op.drop_index("ix_upstream_providers_slug", table_name="upstream_providers") + + columns = {c["name"] for c in inspector.get_columns("upstream_providers")} + if "slug" in columns: + op.drop_column("upstream_providers", "slug") diff --git a/pyproject.toml b/pyproject.toml index eed8c69d..4b048d34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routstr" -version = "0.4.3" +version = "0.4.4" description = "Payment proxy for your LLM endpoint using cashu and nostr." readme = "README.md" requires-python = ">=3.11" diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 3fffc8c7..b0fc080e 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1,5 +1,6 @@ import asyncio import json +import re import secrets from datetime import datetime, timezone from pathlib import Path @@ -8,6 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, RootModel from pydantic.v1 import ValidationError as PydanticValidationError from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession from ..payment.models import _row_to_model, list_models from ..proxy import refresh_model_maps, reinitialize_upstreams @@ -29,6 +31,7 @@ from .db import ( ) from .log_manager import log_manager from .logging import get_logger +from .provider_slugs import allocate_unique_provider_slug from .settings import SettingsService, settings logger = get_logger(__name__) @@ -456,19 +459,18 @@ class ModelCreate(BaseModel): dependencies=[Depends(require_admin_api)], ) async def upsert_provider_model( - provider_id: int, payload: ModelCreate + provider_id: str, payload: ModelCreate ) -> dict[str, object]: print(payload) logger.info( f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}" ) async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_upstream_provider_by_ref(session, provider_id) + provider_pk = _provider_pk(provider) # Try to get existing model - existing_row = await session.get(ModelRow, (payload.id, provider_id)) + existing_row = await session.get(ModelRow, (payload.id, provider_pk)) if existing_row: # Update existing model @@ -524,7 +526,7 @@ async def upsert_provider_model( alias_ids=( json.dumps(payload.alias_ids) if payload.alias_ids else None ), - upstream_provider_id=provider_id, + upstream_provider_id=provider_pk, enabled=payload.enabled, forwarded_model_id=payload.forwarded_model_id or payload.id, ) @@ -543,7 +545,7 @@ async def upsert_provider_model( dependencies=[Depends(require_admin_api)], ) async def update_provider_model_legacy( - provider_id: int, model_id: str, payload: ModelCreate + provider_id: str, model_id: str, payload: ModelCreate ) -> dict[str, object]: """Legacy PATCH endpoint - redirects to upsert POST endpoint for backward compatibility.""" logger.info( @@ -556,13 +558,12 @@ async def update_provider_model_legacy( "/api/upstream-providers/{provider_id}/models/{model_id:path}", dependencies=[Depends(require_admin_api)], ) -async def get_provider_model(provider_id: int, model_id: str) -> dict[str, object]: +async def get_provider_model(provider_id: str, model_id: str) -> dict[str, object]: async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_upstream_provider_by_ref(session, provider_id) + provider_pk = _provider_pk(provider) - row = await session.get(ModelRow, (model_id, provider_id)) + row = await session.get(ModelRow, (model_id, provider_pk)) if not row: raise HTTPException( status_code=404, detail="Model not found for this provider" @@ -576,9 +577,11 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec "/api/upstream-providers/{provider_id}/models/{model_id:path}", dependencies=[Depends(require_admin_api)], ) -async def delete_provider_model(provider_id: int, model_id: str) -> dict[str, object]: +async def delete_provider_model(provider_id: str, model_id: str) -> dict[str, object]: async with create_session() as session: - row = await session.get(ModelRow, (model_id, provider_id)) + provider = await _get_upstream_provider_by_ref(session, provider_id) + provider_pk = _provider_pk(provider) + row = await session.get(ModelRow, (model_id, provider_pk)) if not row: raise HTTPException( status_code=404, detail="Model not found for this provider" @@ -593,10 +596,12 @@ async def delete_provider_model(provider_id: int, model_id: str) -> dict[str, ob "/api/upstream-providers/{provider_id}/models", dependencies=[Depends(require_admin_api)], ) -async def delete_all_provider_models(provider_id: int) -> dict[str, object]: +async def delete_all_provider_models(provider_id: str) -> dict[str, object]: async with create_session() as session: + provider = await _get_upstream_provider_by_ref(session, provider_id) + provider_pk = _provider_pk(provider) result = await session.exec( - select(ModelRow).where(ModelRow.upstream_provider_id == provider_id) + select(ModelRow).where(ModelRow.upstream_provider_id == provider_pk) ) # type: ignore rows = result.all() for row in rows: @@ -615,7 +620,7 @@ class BatchOverrideRequest(BaseModel): dependencies=[Depends(require_admin_api)], ) async def batch_override_provider_models( - provider_id: int, payload: BatchOverrideRequest + provider_id: str, payload: BatchOverrideRequest ) -> dict[str, object]: """Batch override models for a specific provider.""" logger.info( @@ -623,15 +628,14 @@ async def batch_override_provider_models( ) async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_upstream_provider_by_ref(session, provider_id) + provider_pk = _provider_pk(provider) overridden_count = 0 for model_data in payload.models: # Try to get existing model regardless of whether it's enabled or not - existing_row = await session.get(ModelRow, (model_data.id, provider_id)) + existing_row = await session.get(ModelRow, (model_data.id, provider_pk)) if existing_row: # Update existing @@ -685,7 +689,7 @@ async def batch_override_provider_models( if model_data.alias_ids else None ), - upstream_provider_id=provider_id, + upstream_provider_id=provider_pk, enabled=model_data.enabled, ) session.add(row) @@ -702,6 +706,85 @@ async def batch_override_provider_models( } +_SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$") + + +def _validate_slug(value: str) -> str: + candidate = value.strip().lower() + if not _SLUG_PATTERN.fullmatch(candidate): + raise HTTPException( + status_code=400, + detail=( + "slug must be 3-64 chars, lowercase letters/digits/hyphens, " + "and may not start or end with a hyphen" + ), + ) + if candidate.isdigit(): + raise HTTPException( + status_code=400, + detail="slug must not be all digits", + ) + return candidate + + +async def _ensure_unique_slug( + session: AsyncSession, slug: str, exclude_id: int | None = None +) -> None: + stmt = select(UpstreamProviderRow).where(UpstreamProviderRow.slug == slug) + result = await session.exec(stmt) + existing = result.first() + if existing and existing.id != exclude_id: + raise HTTPException( + status_code=409, + detail="Provider with this slug already exists", + ) + + +async def _get_upstream_provider_by_ref( + session: AsyncSession, provider_ref: str +) -> UpstreamProviderRow: + if provider_ref.isdigit(): + provider = await session.get(UpstreamProviderRow, int(provider_ref)) + else: + slug = _validate_slug(provider_ref) + result = await session.exec( + select(UpstreamProviderRow).where(UpstreamProviderRow.slug == slug) + ) + provider = result.first() + + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + return provider + + +def _provider_pk(provider: UpstreamProviderRow) -> int: + if provider.id is None: + raise HTTPException(status_code=500, detail="Provider has no database id") + return provider.id + + +def _serialize_provider( + provider: UpstreamProviderRow, redact_api_key: bool = True +) -> dict[str, object]: + return { + "id": provider.id, + "slug": provider.slug, + "provider_type": provider.provider_type, + "base_url": provider.base_url, + "api_key": "[REDACTED]" + if (redact_api_key and provider.api_key) + else provider.api_key + if not redact_api_key + else "", + "api_version": provider.api_version, + "enabled": provider.enabled, + "provider_fee": provider.provider_fee, + "provider_settings": json.loads(provider.provider_settings) + if provider.provider_settings + else None, + } + + class UpstreamProviderCreate(BaseModel): provider_type: str base_url: str @@ -710,6 +793,7 @@ class UpstreamProviderCreate(BaseModel): enabled: bool = True provider_fee: float = 1.01 provider_settings: dict | None = None + slug: str | None = None class UpstreamProviderUpdate(BaseModel): @@ -720,6 +804,50 @@ class UpstreamProviderUpdate(BaseModel): enabled: bool | None = None provider_fee: float | None = None provider_settings: dict | None = None + slug: str | None = None + + +class UpstreamProviderUpdateBySlug(BaseModel): + slug: str + new_slug: str | None = None + provider_type: str | None = None + base_url: str | None = None + api_key: str | None = None + api_version: str | None = None + enabled: bool | None = None + provider_fee: float | None = None + provider_settings: dict | None = None + + +async def _apply_provider_update( + session: AsyncSession, + provider: UpstreamProviderRow, + payload: UpstreamProviderUpdate, + new_slug: str | None = None, +) -> None: + if new_slug is not None: + validated = _validate_slug(new_slug) + await _ensure_unique_slug(session, validated, exclude_id=provider.id) + provider.slug = validated + + if payload.provider_type is not None: + provider.provider_type = payload.provider_type + if payload.base_url is not None: + provider.base_url = payload.base_url + if payload.api_key is not None: + provider.api_key = payload.api_key + if payload.api_version is not None: + provider.api_version = payload.api_version + if payload.enabled is not None: + provider.enabled = payload.enabled + if payload.provider_fee is not None: + provider.provider_fee = payload.provider_fee + if payload.provider_settings is not None: + provider.provider_settings = json.dumps(payload.provider_settings) + + session.add(provider) + await session.commit() + await session.refresh(provider) @admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)]) @@ -727,21 +855,7 @@ async def get_upstream_providers() -> list[dict[str, object]]: async with create_session() as session: result = await session.exec(select(UpstreamProviderRow)) providers = result.all() - return [ - { - "id": p.id, - "provider_type": p.provider_type, - "base_url": p.base_url, - "api_key": "[REDACTED]" if p.api_key else "", - "api_version": p.api_version, - "enabled": p.enabled, - "provider_fee": p.provider_fee, - "provider_settings": json.loads(p.provider_settings) - if p.provider_settings - else None, - } - for p in providers - ] + return [_serialize_provider(p) for p in providers] @admin_router.post("/api/upstream-providers", dependencies=[Depends(require_admin_api)]) @@ -761,7 +875,14 @@ async def create_upstream_provider( detail="Provider with this base URL and API key already exists", ) + if payload.slug: + slug = _validate_slug(payload.slug) + await _ensure_unique_slug(session, slug) + else: + slug = await allocate_unique_provider_slug(session, payload.provider_type) + provider = UpstreamProviderRow( + slug=slug, provider_type=payload.provider_type, base_url=payload.base_url, api_key=payload.api_key, @@ -778,99 +899,81 @@ async def create_upstream_provider( await reinitialize_upstreams() await refresh_model_maps() - return { - "id": provider.id, - "provider_type": provider.provider_type, - "base_url": provider.base_url, - "api_key": "[REDACTED]", - "api_version": provider.api_version, - "enabled": provider.enabled, - "provider_fee": provider.provider_fee, - "provider_settings": payload.provider_settings, - } + return _serialize_provider(provider) @admin_router.get( "/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)] ) -async def get_upstream_provider(provider_id: int) -> dict[str, object]: +async def get_upstream_provider(provider_id: str) -> dict[str, object]: async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") - return { - "id": provider.id, - "provider_type": provider.provider_type, - "base_url": provider.base_url, - "api_key": "[REDACTED]" if provider.api_key else "", - "api_version": provider.api_version, - "enabled": provider.enabled, - "provider_fee": provider.provider_fee, - "provider_settings": json.loads(provider.provider_settings) - if provider.provider_settings - else None, - } + provider = await _get_upstream_provider_by_ref(session, provider_id) + return _serialize_provider(provider) @admin_router.patch( "/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)] ) async def update_upstream_provider( - provider_id: int, payload: UpstreamProviderUpdate + provider_id: str, payload: UpstreamProviderUpdate ) -> dict[str, object]: async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_upstream_provider_by_ref(session, provider_id) - if payload.provider_type is not None: - provider.provider_type = payload.provider_type - if payload.base_url is not None: - provider.base_url = payload.base_url - if payload.api_key is not None: - provider.api_key = payload.api_key - if payload.api_version is not None: - provider.api_version = payload.api_version - if payload.enabled is not None: - provider.enabled = payload.enabled - if payload.provider_fee is not None: - provider.provider_fee = payload.provider_fee - if payload.provider_settings is not None: - provider.provider_settings = json.dumps(payload.provider_settings) - - session.add(provider) - await session.commit() - await session.refresh(provider) + await _apply_provider_update(session, provider, payload, new_slug=payload.slug) await reinitialize_upstreams() await refresh_model_maps() - return { - "id": provider.id, - "provider_type": provider.provider_type, - "base_url": provider.base_url, - "api_key": "[REDACTED]", - "api_version": provider.api_version, - "enabled": provider.enabled, - "provider_fee": provider.provider_fee, - "provider_settings": json.loads(provider.provider_settings) - if provider.provider_settings - else None, - } + return _serialize_provider(provider) + + +@admin_router.patch( + "/api/upstream-providers", dependencies=[Depends(require_admin_api)] +) +async def update_upstream_provider_by_slug( + payload: UpstreamProviderUpdateBySlug, +) -> dict[str, object]: + lookup = _validate_slug(payload.slug) + async with create_session() as session: + result = await session.exec( + select(UpstreamProviderRow).where( + UpstreamProviderRow.slug == lookup + ) + ) + provider = result.first() + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + + update_payload = UpstreamProviderUpdate( + provider_type=payload.provider_type, + base_url=payload.base_url, + api_key=payload.api_key, + api_version=payload.api_version, + enabled=payload.enabled, + provider_fee=payload.provider_fee, + provider_settings=payload.provider_settings, + ) + await _apply_provider_update( + session, provider, update_payload, new_slug=payload.new_slug + ) + + await reinitialize_upstreams() + await refresh_model_maps() + return _serialize_provider(provider) @admin_router.delete( "/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)] ) -async def delete_upstream_provider(provider_id: int) -> dict[str, object]: +async def delete_upstream_provider(provider_id: str) -> dict[str, object]: async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_upstream_provider_by_ref(session, provider_id) + deleted_id = _provider_pk(provider) await session.delete(provider) await session.commit() await reinitialize_upstreams() await refresh_model_maps() - return {"ok": True, "deleted_id": provider_id} + return {"ok": True, "deleted_id": deleted_id} @admin_router.get("/api/provider-types", dependencies=[Depends(require_admin_api)]) @@ -885,17 +988,16 @@ async def get_provider_types() -> list[dict[str, object]]: "/api/upstream-providers/{provider_id}/models", dependencies=[Depends(require_admin_api)], ) -async def get_provider_models(provider_id: int) -> dict[str, object]: +async def get_provider_models(provider_id: str) -> dict[str, object]: from ..upstream.helpers import _instantiate_provider async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_upstream_provider_by_ref(session, provider_id) + provider_pk = _provider_pk(provider) db_models = await list_models( session=session, - upstream_id=provider_id, + upstream_id=provider_pk, include_disabled=True, apply_fees=False, ) @@ -985,13 +1087,11 @@ class TopupTokenRequest(BaseModel): dependencies=[Depends(require_admin_api)], ) async def topup_provider_with_token( - provider_id: int, payload: TopupTokenRequest + provider_id: str, payload: TopupTokenRequest ) -> dict: """Redeem a Cashu token for an upstream provider.""" async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_upstream_provider_by_ref(session, provider_id) import httpx @@ -1022,15 +1122,13 @@ async def topup_provider_with_token( dependencies=[Depends(require_admin_api)], ) async def initiate_provider_topup( - provider_id: int, payload: TopupRequest + provider_id: str, payload: TopupRequest ) -> dict[str, object]: """Initiate a Lightning Network top-up for the upstream provider account.""" from ..upstream.helpers import _instantiate_provider async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_upstream_provider_by_ref(session, provider_id) try: logger.info( @@ -1150,15 +1248,13 @@ async def initiate_provider_topup( "/api/upstream-providers/{provider_id}/topup/{invoice_id}/status", dependencies=[Depends(require_admin_api)], ) -async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, object]: +async def check_topup_status(provider_id: str, invoice_id: str) -> dict[str, object]: """Check the status of a Lightning Network top-up invoice.""" from ..upstream.helpers import _instantiate_provider from ..upstream.ppqai import PPQAIUpstreamProvider async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_upstream_provider_by_ref(session, provider_id) # For Routstr providers, proxy the status check if provider.provider_type == "routstr": @@ -1205,14 +1301,12 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj "/api/upstream-providers/{provider_id}/balance", dependencies=[Depends(require_admin_api)], ) -async def get_provider_balance(provider_id: int) -> dict[str, object]: +async def get_provider_balance(provider_id: str) -> dict[str, object]: """Get the current balance for an upstream provider account.""" from ..upstream.helpers import _instantiate_provider async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") + provider = await _get_upstream_provider_by_ref(session, provider_id) # For Routstr providers, proxy the balance check if provider.provider_type == "routstr": @@ -1591,15 +1685,13 @@ async def get_lightning_invoices_api( "/api/upstream-providers/{provider_id}/routstr/refund", dependencies=[Depends(require_admin_api)], ) -async def refund_routstr_provider_balance(provider_id: int) -> dict[str, object]: +async def refund_routstr_provider_balance(provider_id: str) -> dict[str, object]: """Refund balance from an upstream Routstr provider back to the local wallet.""" from ..upstream.helpers import _instantiate_provider from ..upstream.routstr import RoutstrUpstreamProvider async with create_session() as session: - provider_row = await session.get(UpstreamProviderRow, provider_id) - if not provider_row: - raise HTTPException(status_code=404, detail="Provider not found") + provider_row = await _get_upstream_provider_by_ref(session, provider_id) if provider_row.provider_type != "routstr": raise HTTPException( diff --git a/routstr/core/db.py b/routstr/core/db.py index 282b8b93..586f467e 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -319,6 +319,12 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore ), ) id: int | None = Field(default=None, primary_key=True) + slug: str | None = Field( + default=None, + unique=True, + index=True, + description="Stable external slug used for updates via API key.", + ) provider_type: str = Field( description="Provider type: custom, openai, anthropic, azure, openrouter, etc." ) diff --git a/routstr/core/provider_slugs.py b/routstr/core/provider_slugs.py new file mode 100644 index 00000000..0efb919f --- /dev/null +++ b/routstr/core/provider_slugs.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import re +from itertools import count +from typing import Collection + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from .db import UpstreamProviderRow + +_SLUG_BASE_PATTERN = re.compile(r"[^a-z0-9]+") +_MAX_SLUG_LENGTH = 64 + + +def provider_slug_base(provider_type: str) -> str: + """Return a deterministic slug base for a provider type.""" + base = _SLUG_BASE_PATTERN.sub("-", provider_type.lower()).strip("-") + if not base: + base = "provider" + elif base.isdigit(): + base = f"provider-{base}" + elif len(base) < 3: + base = f"{base}-provider" + + if len(base) > _MAX_SLUG_LENGTH: + base = base[:_MAX_SLUG_LENGTH].rstrip("-") or "provider" + return base + + +def provider_slug_candidate(base: str, suffix_number: int) -> str: + if suffix_number == 1: + return base + + suffix = f"-{suffix_number}" + max_base_length = _MAX_SLUG_LENGTH - len(suffix) + return f"{base[:max_base_length].rstrip('-')}{suffix}" + + +async def allocate_unique_provider_slug( + session: AsyncSession, + provider_type: str, + reserved_slugs: Collection[str] = (), +) -> str: + """Allocate a stable, deterministic provider slug. + + The first provider of a type gets ``openai``; later collisions get + ``openai-2``, ``openai-3``, etc. ``reserved_slugs`` covers rows staged in + memory but not flushed yet, such as settings/env seeding. + """ + base = provider_slug_base(provider_type) + reserved = {slug.lower() for slug in reserved_slugs} + + for suffix_number in count(1): + candidate = provider_slug_candidate(base, suffix_number) + if candidate in reserved: + continue + + result = await session.exec( + select(UpstreamProviderRow).where(UpstreamProviderRow.slug == candidate) + ) + if result.first() is None: + return candidate + + raise RuntimeError("unreachable") diff --git a/routstr/core/version.py b/routstr/core/version.py index e66e645e..30ee4c6c 100644 --- a/routstr/core/version.py +++ b/routstr/core/version.py @@ -20,7 +20,7 @@ import subprocess from functools import lru_cache from pathlib import Path -BASE_VERSION = "0.4.3" +BASE_VERSION = "0.4.4" _REPO_ROOT = Path(__file__).resolve().parents[2] _GIT_TIMEOUT_SECONDS = 2.0 diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py index 820bd174..84f3847f 100644 --- a/routstr/payment/cost_calculation.py +++ b/routstr/payment/cost_calculation.py @@ -41,6 +41,28 @@ class CostDataError(BaseModel): code: str +def _empty_cost(cls: type[CostData] = CostData) -> CostData: + """Build an all-zero cost object — a full refund for an empty response. + + Shared by the two paths that must not bill: an upstream response with no + usage data at all, and one that reports a USD cost but carries zero tokens + in every bucket. + """ + return cls( + base_msats=0, + input_msats=0, + output_msats=0, + total_msats=0, + total_usd=0.0, + input_tokens=0, + output_tokens=0, + cache_read_input_tokens=0, + cache_creation_input_tokens=0, + cache_read_msats=0, + cache_creation_msats=0, + ) + + async def calculate_cost( response_data: dict, max_cost: int, @@ -83,19 +105,7 @@ async def calculate_cost( else None, }, ) - return MaxCostData( - base_msats=0, - input_msats=0, - output_msats=0, - total_msats=0, - total_usd=0.0, - input_tokens=0, - output_tokens=0, - cache_read_input_tokens=0, - cache_creation_input_tokens=0, - cache_read_msats=0, - cache_creation_msats=0, - ) + return _empty_cost(MaxCostData) usage_data = response_data.get("usage") or {} if not isinstance(usage_data, dict): @@ -109,6 +119,27 @@ async def calculate_cost( # Try USD cost first usd_cost = _resolve_usd_cost(usage_data, response_data) if usd_cost > 0: + truly_empty = ( + input_tokens == 0 + and output_tokens == 0 + and cache_read_tokens == 0 + and cache_creation_tokens == 0 + ) + if truly_empty: + logger.warning( + "Upstream reported a USD cost but the response carries no " + "tokens at all (input, output, cache-read and cache-creation " + "are all zero) — refunding in full rather than billing the " + "USD-derived cost for an empty response.", + extra={ + "model": response_data.get("model", "unknown"), + "usd_cost": usd_cost, + "usage_keys": sorted(usage_data.keys()) + if isinstance(usage_data, dict) + else None, + }, + ) + return _empty_cost() if input_tokens == 0 and output_tokens == 0: logger.warning( "Upstream reported a USD cost but no token counts — " diff --git a/routstr/payment/models.py b/routstr/payment/models.py index e62f7875..e6ea643d 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -94,7 +94,10 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing: cache reads (DeepSeek hits are 10x cheaper) and undercharges Anthropic cache writes (1.25x). litellm ships per-model USD rates keyed by the exact OpenRouter id (deepseek/deepseek-chat) or by the bare model name - (gpt-4o, claude-sonnet-4-5), so both spellings are tried. + (gpt-4o, claude-sonnet-4-5), so both spellings are tried. litellm keys are + lowercase, but a generic upstream may report a mixed-case id + (``deepseek-ai/DeepSeek-V4-Flash``); an exact match is attempted first, then + a case-insensitive fallback so such ids still resolve. Rates already present (e.g. provided by OpenRouter) are authoritative and never overwritten. Unknown models are returned unchanged. @@ -106,12 +109,26 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing: import litellm + candidates = (model_id, model_id.split("/", 1)[-1]) info: dict | None = None - for key in (model_id, model_id.split("/", 1)[-1]): + for key in candidates: candidate = litellm.model_cost.get(key) if isinstance(candidate, dict): info = candidate break + if info is None: + # Case-insensitive fallback: a mixed-case upstream id (e.g. + # ``deepseek-ai/DeepSeek-V4-Flash``) won't match litellm's lowercase + # keys exactly. Build a lowercased index once and retry. + lowered = {c.lower() for c in candidates} + for key, candidate in litellm.model_cost.items(): + if ( + isinstance(key, str) + and key.lower() in lowered + and isinstance(candidate, dict) + ): + info = candidate + break if info is None: return pricing @@ -215,13 +232,29 @@ def _row_to_model( ) top_provider_dict = json.loads(row.top_provider) if row.top_provider else None - if apply_provider_fee and isinstance(pricing, dict): - pricing = {k: float(v) * provider_fee for k, v in pricing.items()} - if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0: pricing["request"] = max(pricing.get("request", 0.0), 0.0) parsed_pricing = Pricing.parse_obj(pricing) + + # Fill missing cache-read/write rates from litellm's cost map BEFORE applying + # the provider fee, so they carry the same markup as every other component. + # DB-stored override pricing (e.g. generic providers) omits cache rates; + # without this, ``_row_to_model`` bills cache reads at the full input rate — + # the ``_apply_provider_fee_to_model`` path backfills, but the override path + # used for admin-configured providers did not. + # + # Key on ``forwarded_model_id`` (the actual upstream model name litellm + # prices) when set: an alias row (id="local-alias", + # forwarded_model_id="deepseek-v4-flash") would otherwise look up the alias + # and miss the cache rate. + pricing_model_id = getattr(row, "forwarded_model_id", None) or row.id + parsed_pricing = backfill_cache_pricing(pricing_model_id, parsed_pricing) + + if apply_provider_fee: + parsed_pricing = Pricing.parse_obj( + {k: float(v) * provider_fee for k, v in parsed_pricing.dict().items()} + ) model = Model( id=row.id, name=row.name, diff --git a/routstr/upstream/anthropic.py b/routstr/upstream/anthropic.py index 5e48f058..7944cbd5 100644 --- a/routstr/upstream/anthropic.py +++ b/routstr/upstream/anthropic.py @@ -24,7 +24,7 @@ class AnthropicUpstreamProvider(BaseUpstreamProvider): ) @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "AnthropicUpstreamProvider": return cls( diff --git a/routstr/upstream/auto_topup.py b/routstr/upstream/auto_topup.py index 31397882..3582b3fc 100644 --- a/routstr/upstream/auto_topup.py +++ b/routstr/upstream/auto_topup.py @@ -97,6 +97,8 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None: # Instantiate provider and check balance provider = RoutstrUpstreamProvider.from_db_row(row) + if provider is None: + return balance = await provider.get_balance() if balance is None: diff --git a/routstr/upstream/azure.py b/routstr/upstream/azure.py index 11cee17c..a693b763 100644 --- a/routstr/upstream/azure.py +++ b/routstr/upstream/azure.py @@ -38,7 +38,7 @@ class AzureUpstreamProvider(BaseUpstreamProvider): self.api_version = api_version @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "AzureUpstreamProvider | None": if not provider_row.api_version: diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 0fe26962..51c600d3 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -7,13 +7,12 @@ import traceback import typing import uuid from collections.abc import AsyncGenerator, AsyncIterator, Iterator -from typing import Any, Mapping, cast +from typing import Any, Mapping, Self, cast import httpx from fastapi import BackgroundTasks, HTTPException, Request from fastapi.responses import Response, StreamingResponse from pydantic.v1 import BaseModel -from sqlmodel import select from ..auth import adjust_payment_for_tokens from ..core import get_logger @@ -96,6 +95,11 @@ class BaseUpstreamProvider: base_url: str api_key: str provider_fee: float = 1.05 + # Primary key of the ``upstream_providers`` row this instance was built + # from. Set by ``from_db_row`` so a live provider can re-find its own row by + # stable identity instead of its rotatable ``api_key``. ``None`` for + # instances not sourced from a row. + db_id: int | None = None _models_cache: list[Model] = [] _models_by_id: dict[str, Model] = {} @@ -110,6 +114,7 @@ class BaseUpstreamProvider: self.base_url = base_url self.api_key = api_key self.provider_fee = provider_fee + self.db_id = None self._models_cache = [] self._models_by_id = {} @@ -127,10 +132,13 @@ class BaseUpstreamProvider: return detect_litellm_prefix(self.base_url) @classmethod - def from_db_row( - cls, provider_row: "UpstreamProviderRow" - ) -> "BaseUpstreamProvider | None": - """Factory method to instantiate provider from database row. + def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "Self | None": + """Instantiate a provider from a database row, carrying its identity. + + Construction itself is delegated to the ``_build_from_row`` hook (which + subclasses override to match their constructor); this wrapper stamps the + row's primary key onto the instance as ``db_id`` so the provider can + later re-find its own row by identity rather than by its ``api_key``. Args: provider_row: Database row containing provider configuration @@ -138,6 +146,19 @@ class BaseUpstreamProvider: Returns: Instantiated provider or None if instantiation fails """ + provider = cls._build_from_row(provider_row) + if provider is not None: + provider.db_id = provider_row.id + return provider + + @classmethod + def _build_from_row(cls, provider_row: "UpstreamProviderRow") -> "Self | None": + """Construct the provider instance from a row (no identity stamping). + + Overridden by subclasses whose constructors differ from the base + ``(base_url, api_key, provider_fee)`` shape. Callers should use + ``from_db_row`` instead, which also attaches ``db_id``. + """ return cls( base_url=provider_row.base_url, api_key=provider_row.api_key, @@ -4902,14 +4923,11 @@ class BaseUpstreamProvider: """Refresh the in-memory models cache from upstream API.""" try: async with create_session() as session: - stmt = select(UpstreamProviderRow).where( - UpstreamProviderRow.base_url == self.base_url, - UpstreamProviderRow.api_key == self.api_key, + provider = ( + await session.get(UpstreamProviderRow, self.db_id) + if self.db_id is not None + else None ) - result = await session.exec(stmt) - - # .first() returns the object or None if not found - provider = result.first() if not provider or not provider.id: raise HTTPException(status_code=404, detail="Provider not found") diff --git a/routstr/upstream/deepseek_v4_pricing_shim.py b/routstr/upstream/deepseek_v4_pricing_shim.py index 8720fb40..ba0c392d 100644 --- a/routstr/upstream/deepseek_v4_pricing_shim.py +++ b/routstr/upstream/deepseek_v4_pricing_shim.py @@ -3,10 +3,15 @@ litellm's bundled cost map does not yet ship ``deepseek-v4-flash`` / ``deepseek-v4-pro``. Without an entry, ``backfill_cache_pricing`` cannot find a ``cache_read_input_token_cost`` and cache reads fall back to the full input -rate — a ~60% overcharge on cache hits (DeepSeek hits are ~0.2x input). +rate — a large overcharge on cache hits (DeepSeek V4 hits are ~0.008-0.02x +input, i.e. cached tokens cost 50-120x less than regular input). This module injects the missing entries into ``litellm.model_cost`` at startup -so the existing backfill path resolves them. Rates mirror the open upstream PR +so the existing backfill path resolves them. Rates mirror the canonical +``deepseek`` provider entries now in litellm's ``model_prices`` map +(``input_cost_per_token`` is the cache-*miss* rate; +``cache_read_input_token_cost`` is the cache-*hit* rate), sourced from +https://api-docs.deepseek.com/quick_start/pricing via https://github.com/BerriAI/litellm/pull/26380 (issue https://github.com/BerriAI/litellm/issues/30430). @@ -22,21 +27,23 @@ from ..core import get_logger logger = get_logger(__name__) -# USD per token. Source: BerriAI/litellm PR #26380. +# USD per token. Mirrors the canonical ``deepseek`` provider entries in +# litellm's model_prices map (source: DeepSeek API pricing docs). Keep these in +# sync with ``litellm.model_cost["deepseek/deepseek-v4-*"]``. _DEEPSEEK_V4_RATES: dict[str, dict[str, float]] = { "deepseek-v4-flash": { "input_cost_per_token": 1.4e-07, "output_cost_per_token": 2.8e-07, - "cache_read_input_token_cost": 2.8e-08, + "cache_read_input_token_cost": 2.8e-09, "cache_creation_input_token_cost": 0.0, - "input_cost_per_token_cache_hit": 2.8e-08, + "input_cost_per_token_cache_hit": 2.8e-09, }, "deepseek-v4-pro": { - "input_cost_per_token": 1.74e-06, - "output_cost_per_token": 3.48e-06, - "cache_read_input_token_cost": 1.4e-07, + "input_cost_per_token": 4.35e-07, + "output_cost_per_token": 8.7e-07, + "cache_read_input_token_cost": 3.625e-09, "cache_creation_input_token_cost": 0.0, - "input_cost_per_token_cache_hit": 1.4e-07, + "input_cost_per_token_cache_hit": 3.625e-09, }, } diff --git a/routstr/upstream/fireworks.py b/routstr/upstream/fireworks.py index e0bb1fec..92b67838 100644 --- a/routstr/upstream/fireworks.py +++ b/routstr/upstream/fireworks.py @@ -20,7 +20,7 @@ class FireworksUpstreamProvider(BaseUpstreamProvider): ) @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "FireworksUpstreamProvider": return cls( diff --git a/routstr/upstream/gemini.py b/routstr/upstream/gemini.py index 54bf849b..54de41a3 100644 --- a/routstr/upstream/gemini.py +++ b/routstr/upstream/gemini.py @@ -51,7 +51,7 @@ class GeminiUpstreamProvider(BaseUpstreamProvider): return self._client @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "GeminiUpstreamProvider": return cls( diff --git a/routstr/upstream/generic.py b/routstr/upstream/generic.py index 390c8372..83b9f565 100644 --- a/routstr/upstream/generic.py +++ b/routstr/upstream/generic.py @@ -45,7 +45,7 @@ class GenericUpstreamProvider(BaseUpstreamProvider): ) @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "GenericUpstreamProvider": return cls( diff --git a/routstr/upstream/groq.py b/routstr/upstream/groq.py index 4020b2df..17103c35 100644 --- a/routstr/upstream/groq.py +++ b/routstr/upstream/groq.py @@ -20,7 +20,7 @@ class GroqUpstreamProvider(BaseUpstreamProvider): ) @classmethod - def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider": + def _build_from_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider": return cls( api_key=provider_row.api_key, provider_fee=provider_row.provider_fee, diff --git a/routstr/upstream/helpers.py b/routstr/upstream/helpers.py index 046a58e6..fe028c8a 100644 --- a/routstr/upstream/helpers.py +++ b/routstr/upstream/helpers.py @@ -12,6 +12,7 @@ from sqlmodel import select from ..core import get_logger from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session +from ..core.provider_slugs import allocate_unique_provider_slug from ..payment.models import Model from .base import BaseUpstreamProvider @@ -215,9 +216,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]: provider = _instantiate_provider(provider_row) if provider: - # Keep provider DB id on runtime instance so model mapping can - # bind DB overrides to the correct upstream. - setattr(provider, "db_id", provider_row.id) await provider.refresh_models_cache() logger.debug( f"Initialized {provider_row.provider_type} provider", @@ -250,6 +248,7 @@ async def _seed_providers_from_settings( providers_to_add: list[UpstreamProviderRow] = [] seeded_provider_keys: set[tuple[str, str]] = set() + reserved_slugs: set[str] = set() provider_classes_by_type = { cls.provider_type: cls @@ -280,8 +279,13 @@ async def _seed_providers_from_settings( ) ) if not result.first(): + slug = await allocate_unique_provider_slug( + session, provider_type, reserved_slugs + ) + reserved_slugs.add(slug) providers_to_add.append( UpstreamProviderRow( + slug=slug, provider_type=provider_type, base_url=base_url, api_key=api_key, @@ -300,8 +304,13 @@ async def _seed_providers_from_settings( ) ) if not result.first(): + slug = await allocate_unique_provider_slug( + session, "ollama", reserved_slugs + ) + reserved_slugs.add(slug) providers_to_add.append( UpstreamProviderRow( + slug=slug, provider_type="ollama", base_url=ollama_base_url, api_key=ollama_api_key, @@ -321,8 +330,13 @@ async def _seed_providers_from_settings( ) ) if not result.first(): + slug = await allocate_unique_provider_slug( + session, "azure", reserved_slugs + ) + reserved_slugs.add(slug) providers_to_add.append( UpstreamProviderRow( + slug=slug, provider_type="azure", base_url=base_url, api_key=api_key, @@ -343,8 +357,13 @@ async def _seed_providers_from_settings( ) ) if not result.first(): + slug = await allocate_unique_provider_slug( + session, "custom", reserved_slugs + ) + reserved_slugs.add(slug) providers_to_add.append( UpstreamProviderRow( + slug=slug, provider_type="custom", base_url=base_url, api_key=api_key, @@ -357,7 +376,7 @@ async def _seed_providers_from_settings( session.add(provider) logger.info( f"Seeding {provider.provider_type} provider", # type: ignore[str-format] - extra={"base_url": provider.base_url}, + extra={"base_url": provider.base_url, "slug": provider.slug}, ) @@ -392,9 +411,7 @@ def _instantiate_provider( return provider if provider_row.provider_type == "custom": - return BaseUpstreamProvider( - provider_row.base_url, provider_row.api_key, provider_row.provider_fee - ) + return BaseUpstreamProvider.from_db_row(provider_row) logger.error( f"Unknown provider type: {provider_row.provider_type}", diff --git a/routstr/upstream/ollama.py b/routstr/upstream/ollama.py index 74327d0b..9fed0154 100644 --- a/routstr/upstream/ollama.py +++ b/routstr/upstream/ollama.py @@ -43,7 +43,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider): ) @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "OllamaUpstreamProvider": return cls( diff --git a/routstr/upstream/openai.py b/routstr/upstream/openai.py index 11cc4336..f2f03cc5 100644 --- a/routstr/upstream/openai.py +++ b/routstr/upstream/openai.py @@ -20,7 +20,7 @@ class OpenAIUpstreamProvider(BaseUpstreamProvider): ) @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "OpenAIUpstreamProvider": return cls( diff --git a/routstr/upstream/openrouter.py b/routstr/upstream/openrouter.py index 0c69d2cc..63995295 100644 --- a/routstr/upstream/openrouter.py +++ b/routstr/upstream/openrouter.py @@ -90,7 +90,7 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider): ) @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "OpenRouterUpstreamProvider": return cls( diff --git a/routstr/upstream/perplexity.py b/routstr/upstream/perplexity.py index 55a9116e..a088218f 100644 --- a/routstr/upstream/perplexity.py +++ b/routstr/upstream/perplexity.py @@ -23,7 +23,7 @@ class PerplexityUpstreamProvider(BaseUpstreamProvider): ) @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "PerplexityUpstreamProvider": return cls( diff --git a/routstr/upstream/ppqai.py b/routstr/upstream/ppqai.py index 14d44d06..6cc26cb2 100644 --- a/routstr/upstream/ppqai.py +++ b/routstr/upstream/ppqai.py @@ -51,7 +51,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): ) @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "PPQAIUpstreamProvider": return cls( @@ -248,17 +248,14 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): f"Disabling PPQ.AI provider ({self.base_url}) due to insufficient balance", extra={"error": error_message}, ) - from sqlmodel import select - from ..core.db import UpstreamProviderRow, create_session async with create_session() as session: - statement = select(UpstreamProviderRow).where( - UpstreamProviderRow.base_url == self.base_url, - UpstreamProviderRow.api_key == self.api_key, + provider = ( + await session.get(UpstreamProviderRow, self.db_id) + if self.db_id is not None + else None ) - result = await session.exec(statement) - provider = result.first() if provider: provider.enabled = False diff --git a/routstr/upstream/routstr.py b/routstr/upstream/routstr.py index c9962301..0371946a 100644 --- a/routstr/upstream/routstr.py +++ b/routstr/upstream/routstr.py @@ -55,7 +55,7 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider): return path.lstrip("/") @classmethod - def from_db_row( + def _build_from_row( cls, provider_row: "UpstreamProviderRow" ) -> "RoutstrUpstreamProvider": import json diff --git a/routstr/upstream/xai.py b/routstr/upstream/xai.py index b46676cb..58caaba0 100644 --- a/routstr/upstream/xai.py +++ b/routstr/upstream/xai.py @@ -21,7 +21,7 @@ class XAIUpstreamProvider(BaseUpstreamProvider): ) @classmethod - def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "XAIUpstreamProvider": + def _build_from_row(cls, provider_row: "UpstreamProviderRow") -> "XAIUpstreamProvider": return cls( api_key=provider_row.api_key, provider_fee=provider_row.provider_fee, diff --git a/tests/integration/test_admin_model_cache_pricing.py b/tests/integration/test_admin_model_cache_pricing.py new file mode 100644 index 00000000..4329f497 --- /dev/null +++ b/tests/integration/test_admin_model_cache_pricing.py @@ -0,0 +1,229 @@ +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest +from httpx import AsyncClient +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.core.admin import admin_sessions +from routstr.core.db import ModelRow, UpstreamProviderRow +from routstr.payment.cost_calculation import CostData, calculate_cost +from routstr.proxy import get_model_instance, reinitialize_upstreams + + +def _admin_headers() -> dict[str, str]: + token = "test-admin-cache-pricing-token" + admin_sessions[token] = int( + (datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp() + ) + return {"Authorization": f"Bearer {token}"} + + +def _model_payload( + provider_id: int, + *, + cache_read: float, + cache_write: float, +) -> dict[str, object]: + return { + "id": "custom-cache-model", + "name": "Custom Cache Model", + "description": "custom model with explicit cache pricing", + "created": 0, + "context_length": 128000, + "architecture": { + "modality": "text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "unknown", + "instruct_type": None, + }, + "pricing": { + "prompt": 1.4e-7, + "completion": 2.8e-7, + "input_cache_read": cache_read, + "input_cache_write": cache_write, + "request": 0.0, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + }, + "per_request_limits": None, + "top_provider": None, + "upstream_provider_id": provider_id, + "canonical_slug": None, + "alias_ids": [], + "enabled": True, + "forwarded_model_id": "custom-cache-model", + } + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_admin_provider_model_api_persists_cache_pricing_on_create_and_update( + integration_client: AsyncClient, + integration_session: AsyncSession, +) -> None: + provider = UpstreamProviderRow( + provider_type="generic", + base_url="https://custom-upstream.example/v1", + api_key="test-key", + provider_fee=1.0, + ) + integration_session.add(provider) + await integration_session.commit() + await integration_session.refresh(provider) + assert provider.id is not None + await reinitialize_upstreams() + + headers = _admin_headers() + create_payload = _model_payload( + provider.id, + cache_read=2.8e-9, + cache_write=3.5e-9, + ) + + with patch("routstr.payment.models.sats_usd_price", return_value=1e-6): + create_response = await integration_client.post( + f"/admin/api/upstream-providers/{provider.id}/models", + headers=headers, + json=create_payload, + ) + + assert create_response.status_code == 200 + create_body = create_response.json() + assert create_body["pricing"]["input_cache_read"] == pytest.approx(2.8e-9) + assert create_body["pricing"]["input_cache_write"] == pytest.approx(3.5e-9) + + row = await integration_session.get(ModelRow, ("custom-cache-model", provider.id)) + assert row is not None + stored_pricing = json.loads(row.pricing) + assert stored_pricing["input_cache_read"] == pytest.approx(2.8e-9) + assert stored_pricing["input_cache_write"] == pytest.approx(3.5e-9) + + update_payload = _model_payload( + provider.id, + cache_read=1.25e-9, + cache_write=4.5e-9, + ) + + with patch("routstr.payment.models.sats_usd_price", return_value=1e-6): + update_response = await integration_client.post( + f"/admin/api/upstream-providers/{provider.id}/models", + headers=headers, + json=update_payload, + ) + + assert update_response.status_code == 200 + update_body = update_response.json() + assert update_body["pricing"]["input_cache_read"] == pytest.approx(1.25e-9) + assert update_body["pricing"]["input_cache_write"] == pytest.approx(4.5e-9) + + await integration_session.refresh(row) + updated_pricing = json.loads(row.pricing) + assert updated_pricing["input_cache_read"] == pytest.approx(1.25e-9) + assert updated_pricing["input_cache_write"] == pytest.approx(4.5e-9) + + model = get_model_instance("custom-cache-model") + assert model is not None + assert model.sats_pricing is not None + assert model.sats_pricing.input_cache_read == pytest.approx(0.00125) + assert model.sats_pricing.input_cache_write == pytest.approx(0.0045) + + with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=1e-6): + cost = await calculate_cost( + { + "model": "custom-cache-model", + "usage": { + "prompt_tokens": 1000, + "completion_tokens": 100, + "prompt_tokens_details": {"cached_tokens": 800}, + }, + }, + max_cost=1_000_000, + ) + + assert isinstance(cost, CostData) + assert cost.input_tokens == 200 + assert cost.cache_read_input_tokens == 800 + assert cost.cache_read_msats == 1000 + assert cost.output_msats == 28000 + assert cost.input_msats == 29000 + assert cost.total_msats == 57000 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_upstream_response_cost_uses_model_cache_pricing( + integration_session: AsyncSession, + patched_db_engine: None, +) -> None: + """A model's configured cache price must discount upstream cached-token usage.""" + provider = UpstreamProviderRow( + provider_type="generic", + base_url="https://cache-priced-upstream.example/v1", + api_key="test-key", + provider_fee=1.0, + ) + integration_session.add(provider) + await integration_session.commit() + await integration_session.refresh(provider) + assert provider.id is not None + + row = ModelRow( + id="cache-priced-model", + name="Cache Priced Model", + description="model seeded with explicit cache pricing", + created=0, + context_length=128000, + architecture=json.dumps( + { + "modality": "text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "unknown", + "instruct_type": None, + } + ), + pricing=json.dumps( + { + "prompt": 1.4e-7, + "completion": 2.8e-7, + "input_cache_read": 1.25e-9, + "input_cache_write": 4.5e-9, + } + ), + upstream_provider_id=provider.id, + enabled=True, + forwarded_model_id="cache-priced-model", + ) + integration_session.add(row) + await integration_session.commit() + + with patch("routstr.payment.models.sats_usd_price", return_value=1e-6): + await reinitialize_upstreams() + + with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=1e-6): + cost = await calculate_cost( + { + "model": "cache-priced-model", + "usage": { + "prompt_tokens": 1000, + "completion_tokens": 100, + "prompt_tokens_details": {"cached_tokens": 800}, + }, + }, + max_cost=1_000_000, + ) + + assert isinstance(cost, CostData) + # prompt 1.4e-7 USD/token -> 0.14 sats/token -> 140 msats/token + # cache 1.25e-9 USD/token -> 0.00125 sats/token -> 1.25 msats/token + # completion 2.8e-7 USD/token -> 0.28 sats/token -> 280 msats/token + assert cost.input_tokens == 200 + assert cost.cache_read_input_tokens == 800 + assert cost.cache_read_msats == 1000 + assert cost.input_msats == 29000 + assert cost.output_msats == 28000 + assert cost.total_msats == 57000 diff --git a/tests/integration/test_provider_self_lookup.py b/tests/integration/test_provider_self_lookup.py new file mode 100644 index 00000000..5745ab65 --- /dev/null +++ b/tests/integration/test_provider_self_lookup.py @@ -0,0 +1,128 @@ +"""A live upstream provider resolves its OWN database row by stable identity +(its primary key), not by its mutable/secret ``api_key``. + +Today ``from_db_row`` drops ``provider_row.id`` and the two self-referential +paths — PPQ.AI's insufficient-balance self-disable and the base +``refresh_models_cache`` — re-find their own row with +``WHERE base_url == self.base_url AND api_key == self.api_key``. That uses a +rotatable secret as a self-handle: the moment the row's key changes underneath a +live object (a rotation racing an in-flight request), the object can no longer +find itself. These tests pin the invariant that a provider looks itself up by +identity, so the lookup survives a key change (and, later, key encryption). +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from routstr.core.db import UpstreamProviderRow +from routstr.upstream.ppqai import PPQAIUpstreamProvider + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_provider_object_carries_its_persistent_identity( + integration_session: object, + patched_db_engine: None, +) -> None: + """``from_db_row`` gives the in-memory object its row's identity (``db_id``).""" + row = UpstreamProviderRow( + provider_type="ppqai", + base_url="https://api.ppq.ai", + api_key="sk-original", + enabled=True, + provider_fee=1.0, + ) + integration_session.add(row) # type: ignore[attr-defined] + await integration_session.commit() # type: ignore[attr-defined] + await integration_session.refresh(row) # type: ignore[attr-defined] + + provider = PPQAIUpstreamProvider.from_db_row(row) + assert provider is not None + + assert provider.db_id == row.id + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_self_disable_targets_own_row_after_key_rotation( + integration_session: object, + patched_db_engine: None, +) -> None: + """PPQ.AI self-disable must disable *its* row even after the key rotated. + + RED (current): the object holds the pre-rotation key, so the + ``(base_url, api_key)`` lookup misses the row → the provider is never + disabled. GREEN: lookup by ``id`` finds it and disables it. + """ + row = UpstreamProviderRow( + provider_type="ppqai", + base_url="https://api.ppq.ai", + api_key="sk-original", + enabled=True, + provider_fee=1.0, + ) + integration_session.add(row) # type: ignore[attr-defined] + await integration_session.commit() # type: ignore[attr-defined] + await integration_session.refresh(row) # type: ignore[attr-defined] + + provider = PPQAIUpstreamProvider.from_db_row(row) # captures sk-original + assert provider is not None + + # Key is rotated in the DB while `provider` is still live. + row.api_key = "sk-rotated" + integration_session.add(row) # type: ignore[attr-defined] + await integration_session.commit() # type: ignore[attr-defined] + + with patch("routstr.proxy.reinitialize_upstreams", new=AsyncMock()): + await provider.on_upstream_error_redirect(402, "Insufficient balance") + + await integration_session.refresh(row) # type: ignore[attr-defined] + assert row.enabled is False + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_refresh_models_cache_finds_own_row_after_key_rotation( + integration_session: object, + patched_db_engine: None, +) -> None: + """``refresh_models_cache`` must resolve its own row after a key rotation. + + ``refresh_models_cache`` swallows every exception (it only logs), so the + observable proof it found its row is that it reaches ``list_models`` — which + is called with the row's ``id`` only *after* the row is resolved. RED + (current): the stale-key ``(base_url, api_key)`` lookup returns nothing, the + method raises ``404`` internally and returns before ``list_models`` is ever + called. GREEN: lookup by ``id`` finds the row and ``list_models`` runs for + that ``id``. + """ + row = UpstreamProviderRow( + provider_type="ppqai", + base_url="https://api.ppq.ai", + api_key="sk-original", + enabled=True, + provider_fee=1.0, + ) + integration_session.add(row) # type: ignore[attr-defined] + await integration_session.commit() # type: ignore[attr-defined] + await integration_session.refresh(row) # type: ignore[attr-defined] + row_id = row.id + + provider = PPQAIUpstreamProvider.from_db_row(row) # captures sk-original + assert provider is not None + + row.api_key = "sk-rotated" + integration_session.add(row) # type: ignore[attr-defined] + await integration_session.commit() # type: ignore[attr-defined] + + list_models_mock = AsyncMock(return_value=[]) + with ( + patch.object(provider, "fetch_models", new=AsyncMock(return_value=[])), + patch("routstr.upstream.base.list_models", new=list_models_mock), + ): + await provider.refresh_models_cache() + + list_models_mock.assert_awaited_once() + assert list_models_mock.await_args is not None + assert list_models_mock.await_args.kwargs["upstream_id"] == row_id diff --git a/tests/unit/test_cache_pricing.py b/tests/unit/test_cache_pricing.py index c9af6e2d..cfb7e9a6 100644 --- a/tests/unit/test_cache_pricing.py +++ b/tests/unit/test_cache_pricing.py @@ -81,6 +81,21 @@ def test_backfill_strips_vendor_prefix_for_litellm_lookup() -> None: assert result.input_cache_read == expected +def test_backfill_case_insensitive_lookup() -> None: + """A generic upstream may report a mixed-case id + (deepseek-ai/DeepSeek-V4-Flash); litellm keys are lowercase. The + case-insensitive fallback still resolves the cache rate.""" + pricing = Pricing(prompt=1.4e-07, completion=2.8e-07) + + result = backfill_cache_pricing("deepseek-ai/DeepSeek-V4-Flash", pricing) + + expected = litellm.model_cost["deepseek-v4-flash"][ + "cache_read_input_token_cost" + ] + assert result.input_cache_read == expected + assert result.input_cache_read < pricing.prompt # sanity: it's a discount + + def test_backfill_fills_cache_write_rate() -> None: """Anthropic cache writes cost more than input (1.25x); billing them at the input rate undercharges. litellm carries the write rate.""" @@ -136,6 +151,93 @@ def test_provider_fee_applies_to_backfilled_cache_rates() -> None: assert adjusted.pricing.prompt == pytest.approx(2.8e-07 * 2.0) +def test_row_to_model_backfills_cache_rate() -> None: + """The DB-override path (admin-configured providers, e.g. a generic + upstream) stores pricing without cache rates. ``_row_to_model`` must + backfill them from litellm just like ``_apply_provider_fee_to_model``, + otherwise cache reads bill at the full input rate.""" + import json + + from routstr.core.db import ModelRow + from routstr.payment.models import _row_to_model + + row = ModelRow( + id="deepseek-v4-flash", + name="deepseek-v4-flash", + created=0, + description="", + context_length=1000000, + architecture=json.dumps( + { + "modality": "text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "unknown", + "instruct_type": None, + } + ), + # Stored pricing omits input_cache_read (generic provider never sets it). + pricing=json.dumps({"prompt": 1.4e-07, "completion": 2.8e-07}), + enabled=True, + upstream_provider_id=1, + ) + + with patch( + "routstr.payment.models.sats_usd_price", return_value=5.0e-5 + ): + model = _row_to_model(row, apply_provider_fee=True, provider_fee=1.0) + + litellm_read = litellm.model_cost["deepseek-v4-flash"][ + "cache_read_input_token_cost" + ] + assert model.pricing.input_cache_read == pytest.approx(litellm_read) + assert model.pricing.input_cache_read < model.pricing.prompt # a discount + assert model.sats_pricing is not None + assert model.sats_pricing.input_cache_read > 0 + + +def test_row_to_model_backfills_via_forwarded_model_id() -> None: + """An alias row (id != forwarded_model_id) must backfill cache rates from + the *forwarded* model name — the real upstream model litellm prices — + not the alias id, which litellm doesn't know.""" + import json + + from routstr.core.db import ModelRow + from routstr.payment.models import _row_to_model + + row = ModelRow( + id="local-alias", # litellm has no such key + name="local-alias", + created=0, + description="", + context_length=1000000, + architecture=json.dumps( + { + "modality": "text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "unknown", + "instruct_type": None, + } + ), + pricing=json.dumps({"prompt": 1.4e-07, "completion": 2.8e-07}), + enabled=True, + upstream_provider_id=1, + forwarded_model_id="deepseek-v4-flash", + ) + + with patch( + "routstr.payment.models.sats_usd_price", return_value=5.0e-5 + ): + model = _row_to_model(row, apply_provider_fee=True, provider_fee=1.0) + + litellm_read = litellm.model_cost["deepseek-v4-flash"][ + "cache_read_input_token_cost" + ] + assert model.pricing.input_cache_read == pytest.approx(litellm_read) + assert model.pricing.input_cache_read < model.pricing.prompt + + # ============================================================================ # calculate_cost — cached tokens billed at cache rates # ============================================================================ diff --git a/tests/unit/test_cost_calculation_caching.py b/tests/unit/test_cost_calculation_caching.py index 41d85385..93c68877 100644 --- a/tests/unit/test_cost_calculation_caching.py +++ b/tests/unit/test_cost_calculation_caching.py @@ -404,6 +404,67 @@ async def test_deepseek_malformed_hit_tokens_coerce_to_zero() -> None: assert result.cache_read_input_tokens == 0 +# ============================================================================ +# Truly-empty response with a non-zero USD cost → full refund +# +# When an upstream reports a USD cost but the response carries NO tokens at all +# (input, output, cache-read and cache-creation all zero), billing the +# USD-derived cost charges the user for nothing. Refund in full. The gate is +# tightened relative to PR #489: a cache-read/-creation-only turn legitimately +# reports zero prompt/completion tokens with a real cost and must still bill. +# ============================================================================ +@pytest.mark.asyncio +async def test_truly_empty_usd_cost_response_is_refunded( + mock_fixed_pricing: None, +) -> None: + """0 input + 0 output + 0 cache tokens with a non-zero USD cost → refund.""" + response = { + "model": "gpt-4", + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_cost": 0.01, # non-zero USD cost despite no tokens + }, + } + result = await calculate_cost(response, max_cost=100000) + + assert isinstance(result, CostData) + assert result.total_msats == 0 # full refund + assert result.input_msats == 0 + assert result.output_msats == 0 + assert result.total_usd == 0.0 + assert result.input_tokens == 0 + assert result.output_tokens == 0 + assert result.cache_read_input_tokens == 0 + assert result.cache_creation_input_tokens == 0 + + +@pytest.mark.asyncio +async def test_cache_read_only_usd_cost_response_is_billed( + mock_fixed_pricing: None, +) -> None: + """Cache-read-only turn (0 prompt/completion, non-zero cost) still bills.""" + response = { + "model": "claude-3-5-sonnet", + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 1000, # real cached usage + "cache_creation_input_tokens": 0, + "total_cost": 0.01, # non-zero USD cost + }, + } + result = await calculate_cost(response, max_cost=100000) + + assert isinstance(result, CostData) + # NOT refunded — the USD cost is billed in full. Pinning the exact value + # guards against any future regression that would over-refund a cache-only + # turn (the bug in PR #489, which refunded whenever prompt+completion == 0). + assert result.total_msats == 200000 + assert result.total_usd == 0.01 + assert result.cache_read_input_tokens == 1000 + + # ============================================================================ # Test 13: Missing Usage Block # ============================================================================ diff --git a/tests/unit/test_provider_slug_migration.py b/tests/unit/test_provider_slug_migration.py new file mode 100644 index 00000000..c44faefb --- /dev/null +++ b/tests/unit/test_provider_slug_migration.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import sqlalchemy as sa + +_MIGRATION_PATH = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "versions" + / "c6d7e8f9a0b1_add_slug_to_upstream_providers.py" +) +_spec = importlib.util.spec_from_file_location("provider_slug_migration", _MIGRATION_PATH) +assert _spec is not None and _spec.loader is not None +migration = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(migration) + + +def test_slug_migration_backfill_uses_api_safe_deterministic_slugs() -> None: + engine = sa.create_engine("sqlite:///:memory:") + with engine.begin() as conn: + conn.execute( + sa.text( + "CREATE TABLE upstream_providers (" + "id INTEGER PRIMARY KEY, " + "provider_type VARCHAR NOT NULL, " + "slug VARCHAR NULL" + ")" + ) + ) + conn.execute( + sa.text( + "INSERT INTO upstream_providers (id, provider_type, slug) VALUES " + "(1, 'OpenAI Compatible', NULL), " + "(2, 'OpenAI Compatible', ''), " + "(3, '123', NULL), " + "(4, 'x', NULL), " + "(5, 'anthropic', 'anthropic')" + ) + ) + + migration._backfill_provider_slugs(conn) + + rows = conn.execute( + sa.text("SELECT id, slug FROM upstream_providers ORDER BY id") + ).all() + + assert rows == [ + (1, "openai-compatible"), + (2, "openai-compatible-2"), + (3, "provider-123"), + (4, "x-provider"), + (5, "anthropic"), + ] diff --git a/tests/unit/test_provider_slugs.py b/tests/unit/test_provider_slugs.py new file mode 100644 index 00000000..0d446fc1 --- /dev/null +++ b/tests/unit/test_provider_slugs.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine +from sqlmodel import SQLModel, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.core.admin import _get_upstream_provider_by_ref +from routstr.core.db import UpstreamProviderRow +from routstr.core.provider_slugs import ( + allocate_unique_provider_slug, + provider_slug_base, +) +from routstr.upstream.helpers import _seed_providers_from_settings + + +@pytest.mark.asyncio +async def test_allocate_unique_provider_slug_is_deterministic_with_suffixes() -> None: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + async with AsyncSession(engine) as session: + session.add( + UpstreamProviderRow( + slug="openai", + provider_type="openai", + base_url="https://api.openai.com/v1", + api_key="key-1", + ) + ) + await session.commit() + + assert await allocate_unique_provider_slug(session, "openai") == "openai-2" + assert ( + await allocate_unique_provider_slug(session, "openai", {"openai-2"}) + == "openai-3" + ) + + await engine.dispose() + + +def test_provider_slug_base_sanitizes_provider_type() -> None: + assert provider_slug_base("OpenAI Compatible") == "openai-compatible" + assert provider_slug_base("!!!") == "provider" + assert provider_slug_base("AI") == "ai-provider" + assert provider_slug_base("123") == "provider-123" + + +@pytest.mark.asyncio +async def test_provider_ref_lookup_accepts_existing_numeric_ids_and_slugs() -> None: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + async with AsyncSession(engine) as session: + provider = UpstreamProviderRow( + slug="openai", + provider_type="openai", + base_url="https://api.openai.com/v1", + api_key="key-1", + ) + session.add(provider) + await session.commit() + await session.refresh(provider) + + by_id = await _get_upstream_provider_by_ref(session, str(provider.id)) + by_slug = await _get_upstream_provider_by_ref(session, "openai") + + assert by_id.id == provider.id + assert by_slug.id == provider.id + + await engine.dispose() + + +@pytest.mark.asyncio +async def test_seed_providers_from_settings_sets_deterministic_slug( + monkeypatch: pytest.MonkeyPatch, +) -> None: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + monkeypatch.setenv("OPENAI_API_KEY", "seeded-openai-key") + + class SettingsStub: + chat_completions_api_version: str | None = None + upstream_base_url: str | None = None + upstream_api_key: str = "" + + async with AsyncSession(engine) as session: + await _seed_providers_from_settings(session, SettingsStub()) # type: ignore[arg-type] + await session.commit() + + result = await session.exec(select(UpstreamProviderRow)) + providers: list[UpstreamProviderRow] = list(result.all()) + + assert [(p.provider_type, p.slug) for p in providers] == [("openai", "openai")] + + await engine.dispose() + + +@pytest.mark.asyncio +async def test_seed_providers_from_settings_keeps_slug_stable_on_reseed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + monkeypatch.setenv("OPENAI_API_KEY", "seeded-openai-key") + + class SettingsStub: + chat_completions_api_version: str | None = None + upstream_base_url: str | None = None + upstream_api_key: str = "" + + async with AsyncSession(engine) as session: + session.add( + UpstreamProviderRow( + slug="openai", + provider_type="openai", + base_url="https://example.invalid/v1", + api_key="other-key", + ) + ) + await session.commit() + + await _seed_providers_from_settings(session, SettingsStub()) # type: ignore[arg-type] + await session.commit() + await _seed_providers_from_settings(session, SettingsStub()) # type: ignore[arg-type] + await session.commit() + + result = await session.exec( + select(UpstreamProviderRow).order_by(UpstreamProviderRow.slug) + ) + providers: list[UpstreamProviderRow] = list(result.all()) + + assert [(p.provider_type, p.slug) for p in providers] == [ + ("openai", "openai"), + ("openai", "openai-2"), + ] + + await engine.dispose() diff --git a/ui/components/add-provider-model-dialog.tsx b/ui/components/add-provider-model-dialog.tsx index c9f6984d..ddb0a210 100644 --- a/ui/components/add-provider-model-dialog.tsx +++ b/ui/components/add-provider-model-dialog.tsx @@ -68,6 +68,8 @@ const FormSchema = z.object({ upstream_provider_id: z.string().default(''), input_cost: z.coerce.number().min(0).default(0), output_cost: z.coerce.number().min(0).default(0), + cache_read_cost: z.coerce.number().min(0).default(0), + cache_write_cost: z.coerce.number().min(0).default(0), request_cost: z.coerce.number().min(0).default(0), image_cost: z.coerce.number().min(0).default(0), web_search_cost: z.coerce.number().min(0).default(0), @@ -125,6 +127,8 @@ export function AddProviderModelDialog({ upstream_provider_id: '', input_cost: 0, output_cost: 0, + cache_read_cost: 0, + cache_write_cost: 0, request_cost: 0, image_cost: 0, web_search_cost: 0, @@ -190,6 +194,8 @@ export function AddProviderModelDialog({ : initialData.upstream_provider_id?.toString() || '', input_cost: pricing?.prompt ?? 0, output_cost: pricing?.completion ?? 0, + cache_read_cost: pricing?.input_cache_read ?? 0, + cache_write_cost: pricing?.input_cache_write ?? 0, request_cost: pricing?.request ?? 0, image_cost: pricing?.image ?? 0, web_search_cost: pricing?.web_search ?? 0, @@ -231,6 +237,8 @@ export function AddProviderModelDialog({ upstream_provider_id: '', input_cost: 0, output_cost: 0, + cache_read_cost: 0, + cache_write_cost: 0, request_cost: 0, image_cost: 0, web_search_cost: 0, @@ -294,6 +302,8 @@ export function AddProviderModelDialog({ ); form.setValue('input_cost', pricing?.prompt ?? 0); form.setValue('output_cost', pricing?.completion ?? 0); + form.setValue('cache_read_cost', pricing?.input_cache_read ?? 0); + form.setValue('cache_write_cost', pricing?.input_cache_write ?? 0); form.setValue('request_cost', pricing?.request ?? 0); form.setValue('image_cost', pricing?.image ?? 0); form.setValue('web_search_cost', pricing?.web_search ?? 0); @@ -365,6 +375,8 @@ export function AddProviderModelDialog({ pricing: { prompt: data.input_cost, completion: data.output_cost, + input_cache_read: data.cache_read_cost, + input_cache_write: data.cache_write_cost, request: data.request_cost, image: data.image_cost, web_search: data.web_search_cost, @@ -810,6 +822,38 @@ export function AddProviderModelDialog({ )} /> + ( + + Cache Read Cost + + + + + Discounted cached-input read price per 1M tokens. + + + + )} + /> + ( + + Cache Write Cost + + + + + Cached-input creation price per 1M tokens. + + + + )} + /> )} +
+ + + setFormData((prev) => ({ + ...prev, + slug: e.target.value || undefined, + })) + } + placeholder='e.g. openai-prod' + /> +

+ Stable external key used to update this provider via the admin API. +

+
+
{ const val = result[field]; if (val !== undefined && val !== null) { @@ -161,6 +166,8 @@ export class AdminService { convertField('prompt'); convertField('completion'); + convertField('input_cache_read'); + convertField('input_cache_write'); // Other fields (request, image, etc.) are already flat fees (per item) // so we do NOT scale them. @@ -174,7 +181,7 @@ export class AdminService { if (!pricing) return pricing; const result = { ...pricing }; - // Only prompt and completion are per-1M in UI and need scaling down to per-token + // Token-priced fields are per-1M in the UI and need scaling down to per-token. const convertField = (field: string) => { const val = result[field]; if (val !== undefined && val !== null) { @@ -187,6 +194,8 @@ export class AdminService { convertField('prompt'); convertField('completion'); + convertField('input_cache_read'); + convertField('input_cache_write'); // Other fields stay as flat fees