mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 15:26:14 +00:00
Merge branch 'main' of github.com-red:Routstr/routstr-core into ehbp-proxy-refactor
This commit is contained in:
@@ -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")
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "routstr"
|
name = "routstr"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+219
-127
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -8,6 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|||||||
from pydantic import BaseModel, RootModel
|
from pydantic import BaseModel, RootModel
|
||||||
from pydantic.v1 import ValidationError as PydanticValidationError
|
from pydantic.v1 import ValidationError as PydanticValidationError
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..payment.models import _row_to_model, list_models
|
from ..payment.models import _row_to_model, list_models
|
||||||
from ..proxy import refresh_model_maps, reinitialize_upstreams
|
from ..proxy import refresh_model_maps, reinitialize_upstreams
|
||||||
@@ -29,6 +31,7 @@ from .db import (
|
|||||||
)
|
)
|
||||||
from .log_manager import log_manager
|
from .log_manager import log_manager
|
||||||
from .logging import get_logger
|
from .logging import get_logger
|
||||||
|
from .provider_slugs import allocate_unique_provider_slug
|
||||||
from .settings import SettingsService, settings
|
from .settings import SettingsService, settings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -456,19 +459,18 @@ class ModelCreate(BaseModel):
|
|||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def upsert_provider_model(
|
async def upsert_provider_model(
|
||||||
provider_id: int, payload: ModelCreate
|
provider_id: str, payload: ModelCreate
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
print(payload)
|
print(payload)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
|
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
|
||||||
)
|
)
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
provider_pk = _provider_pk(provider)
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
# Try to get existing model
|
# 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:
|
if existing_row:
|
||||||
# Update existing model
|
# Update existing model
|
||||||
@@ -524,7 +526,7 @@ async def upsert_provider_model(
|
|||||||
alias_ids=(
|
alias_ids=(
|
||||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||||
),
|
),
|
||||||
upstream_provider_id=provider_id,
|
upstream_provider_id=provider_pk,
|
||||||
enabled=payload.enabled,
|
enabled=payload.enabled,
|
||||||
forwarded_model_id=payload.forwarded_model_id or payload.id,
|
forwarded_model_id=payload.forwarded_model_id or payload.id,
|
||||||
)
|
)
|
||||||
@@ -543,7 +545,7 @@ async def upsert_provider_model(
|
|||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def update_provider_model_legacy(
|
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]:
|
) -> dict[str, object]:
|
||||||
"""Legacy PATCH endpoint - redirects to upsert POST endpoint for backward compatibility."""
|
"""Legacy PATCH endpoint - redirects to upsert POST endpoint for backward compatibility."""
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -556,13 +558,12 @@ async def update_provider_model_legacy(
|
|||||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||||
dependencies=[Depends(require_admin_api)],
|
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:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
provider_pk = _provider_pk(provider)
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
row = await session.get(ModelRow, (model_id, provider_id))
|
row = await session.get(ModelRow, (model_id, provider_pk))
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404, detail="Model not found for this provider"
|
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}",
|
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||||
dependencies=[Depends(require_admin_api)],
|
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:
|
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:
|
if not row:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404, detail="Model not found for this provider"
|
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",
|
"/api/upstream-providers/{provider_id}/models",
|
||||||
dependencies=[Depends(require_admin_api)],
|
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:
|
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(
|
result = await session.exec(
|
||||||
select(ModelRow).where(ModelRow.upstream_provider_id == provider_id)
|
select(ModelRow).where(ModelRow.upstream_provider_id == provider_pk)
|
||||||
) # type: ignore
|
) # type: ignore
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -615,7 +620,7 @@ class BatchOverrideRequest(BaseModel):
|
|||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def batch_override_provider_models(
|
async def batch_override_provider_models(
|
||||||
provider_id: int, payload: BatchOverrideRequest
|
provider_id: str, payload: BatchOverrideRequest
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Batch override models for a specific provider."""
|
"""Batch override models for a specific provider."""
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -623,15 +628,14 @@ async def batch_override_provider_models(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
provider_pk = _provider_pk(provider)
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
overridden_count = 0
|
overridden_count = 0
|
||||||
|
|
||||||
for model_data in payload.models:
|
for model_data in payload.models:
|
||||||
# Try to get existing model regardless of whether it's enabled or not
|
# 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:
|
if existing_row:
|
||||||
# Update existing
|
# Update existing
|
||||||
@@ -685,7 +689,7 @@ async def batch_override_provider_models(
|
|||||||
if model_data.alias_ids
|
if model_data.alias_ids
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
upstream_provider_id=provider_id,
|
upstream_provider_id=provider_pk,
|
||||||
enabled=model_data.enabled,
|
enabled=model_data.enabled,
|
||||||
)
|
)
|
||||||
session.add(row)
|
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):
|
class UpstreamProviderCreate(BaseModel):
|
||||||
provider_type: str
|
provider_type: str
|
||||||
base_url: str
|
base_url: str
|
||||||
@@ -710,6 +793,7 @@ class UpstreamProviderCreate(BaseModel):
|
|||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
provider_fee: float = 1.01
|
provider_fee: float = 1.01
|
||||||
provider_settings: dict | None = None
|
provider_settings: dict | None = None
|
||||||
|
slug: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UpstreamProviderUpdate(BaseModel):
|
class UpstreamProviderUpdate(BaseModel):
|
||||||
@@ -720,6 +804,50 @@ class UpstreamProviderUpdate(BaseModel):
|
|||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
provider_fee: float | None = None
|
provider_fee: float | None = None
|
||||||
provider_settings: dict | 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)])
|
@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:
|
async with create_session() as session:
|
||||||
result = await session.exec(select(UpstreamProviderRow))
|
result = await session.exec(select(UpstreamProviderRow))
|
||||||
providers = result.all()
|
providers = result.all()
|
||||||
return [
|
return [_serialize_provider(p) for p in providers]
|
||||||
{
|
|
||||||
"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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
@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",
|
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(
|
provider = UpstreamProviderRow(
|
||||||
|
slug=slug,
|
||||||
provider_type=payload.provider_type,
|
provider_type=payload.provider_type,
|
||||||
base_url=payload.base_url,
|
base_url=payload.base_url,
|
||||||
api_key=payload.api_key,
|
api_key=payload.api_key,
|
||||||
@@ -778,99 +899,81 @@ async def create_upstream_provider(
|
|||||||
|
|
||||||
await reinitialize_upstreams()
|
await reinitialize_upstreams()
|
||||||
await refresh_model_maps()
|
await refresh_model_maps()
|
||||||
return {
|
return _serialize_provider(provider)
|
||||||
"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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get(
|
@admin_router.get(
|
||||||
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
"/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:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
return _serialize_provider(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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.patch(
|
@admin_router.patch(
|
||||||
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
||||||
)
|
)
|
||||||
async def update_upstream_provider(
|
async def update_upstream_provider(
|
||||||
provider_id: int, payload: UpstreamProviderUpdate
|
provider_id: str, payload: UpstreamProviderUpdate
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
if payload.provider_type is not None:
|
await _apply_provider_update(session, provider, payload, new_slug=payload.slug)
|
||||||
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 reinitialize_upstreams()
|
await reinitialize_upstreams()
|
||||||
await refresh_model_maps()
|
await refresh_model_maps()
|
||||||
return {
|
return _serialize_provider(provider)
|
||||||
"id": provider.id,
|
|
||||||
"provider_type": provider.provider_type,
|
|
||||||
"base_url": provider.base_url,
|
@admin_router.patch(
|
||||||
"api_key": "[REDACTED]",
|
"/api/upstream-providers", dependencies=[Depends(require_admin_api)]
|
||||||
"api_version": provider.api_version,
|
)
|
||||||
"enabled": provider.enabled,
|
async def update_upstream_provider_by_slug(
|
||||||
"provider_fee": provider.provider_fee,
|
payload: UpstreamProviderUpdateBySlug,
|
||||||
"provider_settings": json.loads(provider.provider_settings)
|
) -> dict[str, object]:
|
||||||
if provider.provider_settings
|
lookup = _validate_slug(payload.slug)
|
||||||
else None,
|
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(
|
@admin_router.delete(
|
||||||
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
"/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:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
deleted_id = _provider_pk(provider)
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
await session.delete(provider)
|
await session.delete(provider)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await reinitialize_upstreams()
|
await reinitialize_upstreams()
|
||||||
await refresh_model_maps()
|
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)])
|
@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",
|
"/api/upstream-providers/{provider_id}/models",
|
||||||
dependencies=[Depends(require_admin_api)],
|
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
|
from ..upstream.helpers import _instantiate_provider
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
provider_pk = _provider_pk(provider)
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
db_models = await list_models(
|
db_models = await list_models(
|
||||||
session=session,
|
session=session,
|
||||||
upstream_id=provider_id,
|
upstream_id=provider_pk,
|
||||||
include_disabled=True,
|
include_disabled=True,
|
||||||
apply_fees=False,
|
apply_fees=False,
|
||||||
)
|
)
|
||||||
@@ -985,13 +1087,11 @@ class TopupTokenRequest(BaseModel):
|
|||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def topup_provider_with_token(
|
async def topup_provider_with_token(
|
||||||
provider_id: int, payload: TopupTokenRequest
|
provider_id: str, payload: TopupTokenRequest
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Redeem a Cashu token for an upstream provider."""
|
"""Redeem a Cashu token for an upstream provider."""
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -1022,15 +1122,13 @@ async def topup_provider_with_token(
|
|||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def initiate_provider_topup(
|
async def initiate_provider_topup(
|
||||||
provider_id: int, payload: TopupRequest
|
provider_id: str, payload: TopupRequest
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Initiate a Lightning Network top-up for the upstream provider account."""
|
"""Initiate a Lightning Network top-up for the upstream provider account."""
|
||||||
from ..upstream.helpers import _instantiate_provider
|
from ..upstream.helpers import _instantiate_provider
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1150,15 +1248,13 @@ async def initiate_provider_topup(
|
|||||||
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
||||||
dependencies=[Depends(require_admin_api)],
|
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."""
|
"""Check the status of a Lightning Network top-up invoice."""
|
||||||
from ..upstream.helpers import _instantiate_provider
|
from ..upstream.helpers import _instantiate_provider
|
||||||
from ..upstream.ppqai import PPQAIUpstreamProvider
|
from ..upstream.ppqai import PPQAIUpstreamProvider
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
# For Routstr providers, proxy the status check
|
# For Routstr providers, proxy the status check
|
||||||
if provider.provider_type == "routstr":
|
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",
|
"/api/upstream-providers/{provider_id}/balance",
|
||||||
dependencies=[Depends(require_admin_api)],
|
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."""
|
"""Get the current balance for an upstream provider account."""
|
||||||
from ..upstream.helpers import _instantiate_provider
|
from ..upstream.helpers import _instantiate_provider
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
# For Routstr providers, proxy the balance check
|
# For Routstr providers, proxy the balance check
|
||||||
if provider.provider_type == "routstr":
|
if provider.provider_type == "routstr":
|
||||||
@@ -1591,15 +1685,13 @@ async def get_lightning_invoices_api(
|
|||||||
"/api/upstream-providers/{provider_id}/routstr/refund",
|
"/api/upstream-providers/{provider_id}/routstr/refund",
|
||||||
dependencies=[Depends(require_admin_api)],
|
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."""
|
"""Refund balance from an upstream Routstr provider back to the local wallet."""
|
||||||
from ..upstream.helpers import _instantiate_provider
|
from ..upstream.helpers import _instantiate_provider
|
||||||
from ..upstream.routstr import RoutstrUpstreamProvider
|
from ..upstream.routstr import RoutstrUpstreamProvider
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider_row = await session.get(UpstreamProviderRow, provider_id)
|
provider_row = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider_row:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
if provider_row.provider_type != "routstr":
|
if provider_row.provider_type != "routstr":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -319,6 +319,12 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
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(
|
provider_type: str = Field(
|
||||||
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -20,7 +20,7 @@ import subprocess
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
BASE_VERSION = "0.4.3"
|
BASE_VERSION = "0.4.4"
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
_GIT_TIMEOUT_SECONDS = 2.0
|
_GIT_TIMEOUT_SECONDS = 2.0
|
||||||
|
|||||||
@@ -41,6 +41,28 @@ class CostDataError(BaseModel):
|
|||||||
code: str
|
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(
|
async def calculate_cost(
|
||||||
response_data: dict,
|
response_data: dict,
|
||||||
max_cost: int,
|
max_cost: int,
|
||||||
@@ -83,19 +105,7 @@ async def calculate_cost(
|
|||||||
else None,
|
else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return MaxCostData(
|
return _empty_cost(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,
|
|
||||||
)
|
|
||||||
|
|
||||||
usage_data = response_data.get("usage") or {}
|
usage_data = response_data.get("usage") or {}
|
||||||
if not isinstance(usage_data, dict):
|
if not isinstance(usage_data, dict):
|
||||||
@@ -109,6 +119,27 @@ async def calculate_cost(
|
|||||||
# Try USD cost first
|
# Try USD cost first
|
||||||
usd_cost = _resolve_usd_cost(usage_data, response_data)
|
usd_cost = _resolve_usd_cost(usage_data, response_data)
|
||||||
if usd_cost > 0:
|
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:
|
if input_tokens == 0 and output_tokens == 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Upstream reported a USD cost but no token counts — "
|
"Upstream reported a USD cost but no token counts — "
|
||||||
|
|||||||
@@ -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 reads (DeepSeek hits are 10x cheaper) and undercharges Anthropic
|
||||||
cache writes (1.25x). litellm ships per-model USD rates keyed by the exact
|
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
|
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
|
Rates already present (e.g. provided by OpenRouter) are authoritative and
|
||||||
never overwritten. Unknown models are returned unchanged.
|
never overwritten. Unknown models are returned unchanged.
|
||||||
@@ -106,12 +109,26 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
|
|||||||
|
|
||||||
import litellm
|
import litellm
|
||||||
|
|
||||||
|
candidates = (model_id, model_id.split("/", 1)[-1])
|
||||||
info: dict | None = None
|
info: dict | None = None
|
||||||
for key in (model_id, model_id.split("/", 1)[-1]):
|
for key in candidates:
|
||||||
candidate = litellm.model_cost.get(key)
|
candidate = litellm.model_cost.get(key)
|
||||||
if isinstance(candidate, dict):
|
if isinstance(candidate, dict):
|
||||||
info = candidate
|
info = candidate
|
||||||
break
|
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:
|
if info is None:
|
||||||
return pricing
|
return pricing
|
||||||
|
|
||||||
@@ -215,13 +232,29 @@ def _row_to_model(
|
|||||||
)
|
)
|
||||||
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
|
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:
|
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
|
||||||
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
||||||
|
|
||||||
parsed_pricing = Pricing.parse_obj(pricing)
|
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(
|
model = Model(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
name=row.name,
|
name=row.name,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class AnthropicUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "AnthropicUpstreamProvider":
|
) -> "AnthropicUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
|
|||||||
|
|
||||||
# Instantiate provider and check balance
|
# Instantiate provider and check balance
|
||||||
provider = RoutstrUpstreamProvider.from_db_row(row)
|
provider = RoutstrUpstreamProvider.from_db_row(row)
|
||||||
|
if provider is None:
|
||||||
|
return
|
||||||
balance = await provider.get_balance()
|
balance = await provider.get_balance()
|
||||||
|
|
||||||
if balance is None:
|
if balance is None:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class AzureUpstreamProvider(BaseUpstreamProvider):
|
|||||||
self.api_version = api_version
|
self.api_version = api_version
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "AzureUpstreamProvider | None":
|
) -> "AzureUpstreamProvider | None":
|
||||||
if not provider_row.api_version:
|
if not provider_row.api_version:
|
||||||
|
|||||||
+31
-13
@@ -7,13 +7,12 @@ import traceback
|
|||||||
import typing
|
import typing
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncGenerator, AsyncIterator, Iterator
|
from collections.abc import AsyncGenerator, AsyncIterator, Iterator
|
||||||
from typing import Any, Mapping, cast
|
from typing import Any, Mapping, Self, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import BackgroundTasks, HTTPException, Request
|
from fastapi import BackgroundTasks, HTTPException, Request
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from pydantic.v1 import BaseModel
|
from pydantic.v1 import BaseModel
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from ..auth import adjust_payment_for_tokens
|
from ..auth import adjust_payment_for_tokens
|
||||||
from ..core import get_logger
|
from ..core import get_logger
|
||||||
@@ -96,6 +95,11 @@ class BaseUpstreamProvider:
|
|||||||
base_url: str
|
base_url: str
|
||||||
api_key: str
|
api_key: str
|
||||||
provider_fee: float = 1.05
|
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_cache: list[Model] = []
|
||||||
_models_by_id: dict[str, Model] = {}
|
_models_by_id: dict[str, Model] = {}
|
||||||
|
|
||||||
@@ -110,6 +114,7 @@ class BaseUpstreamProvider:
|
|||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.provider_fee = provider_fee
|
self.provider_fee = provider_fee
|
||||||
|
self.db_id = None
|
||||||
self._models_cache = []
|
self._models_cache = []
|
||||||
self._models_by_id = {}
|
self._models_by_id = {}
|
||||||
|
|
||||||
@@ -127,10 +132,13 @@ class BaseUpstreamProvider:
|
|||||||
return detect_litellm_prefix(self.base_url)
|
return detect_litellm_prefix(self.base_url)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "Self | None":
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
"""Instantiate a provider from a database row, carrying its identity.
|
||||||
) -> "BaseUpstreamProvider | None":
|
|
||||||
"""Factory method to instantiate provider from database row.
|
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:
|
Args:
|
||||||
provider_row: Database row containing provider configuration
|
provider_row: Database row containing provider configuration
|
||||||
@@ -138,6 +146,19 @@ class BaseUpstreamProvider:
|
|||||||
Returns:
|
Returns:
|
||||||
Instantiated provider or None if instantiation fails
|
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(
|
return cls(
|
||||||
base_url=provider_row.base_url,
|
base_url=provider_row.base_url,
|
||||||
api_key=provider_row.api_key,
|
api_key=provider_row.api_key,
|
||||||
@@ -4902,14 +4923,11 @@ class BaseUpstreamProvider:
|
|||||||
"""Refresh the in-memory models cache from upstream API."""
|
"""Refresh the in-memory models cache from upstream API."""
|
||||||
try:
|
try:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
stmt = select(UpstreamProviderRow).where(
|
provider = (
|
||||||
UpstreamProviderRow.base_url == self.base_url,
|
await session.get(UpstreamProviderRow, self.db_id)
|
||||||
UpstreamProviderRow.api_key == self.api_key,
|
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:
|
if not provider or not provider.id:
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
litellm's bundled cost map does not yet ship ``deepseek-v4-flash`` /
|
litellm's bundled cost map does not yet ship ``deepseek-v4-flash`` /
|
||||||
``deepseek-v4-pro``. Without an entry, ``backfill_cache_pricing`` cannot find a
|
``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
|
``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
|
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/pull/26380 (issue
|
||||||
https://github.com/BerriAI/litellm/issues/30430).
|
https://github.com/BerriAI/litellm/issues/30430).
|
||||||
|
|
||||||
@@ -22,21 +27,23 @@ from ..core import get_logger
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
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_RATES: dict[str, dict[str, float]] = {
|
||||||
"deepseek-v4-flash": {
|
"deepseek-v4-flash": {
|
||||||
"input_cost_per_token": 1.4e-07,
|
"input_cost_per_token": 1.4e-07,
|
||||||
"output_cost_per_token": 2.8e-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,
|
"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": {
|
"deepseek-v4-pro": {
|
||||||
"input_cost_per_token": 1.74e-06,
|
"input_cost_per_token": 4.35e-07,
|
||||||
"output_cost_per_token": 3.48e-06,
|
"output_cost_per_token": 8.7e-07,
|
||||||
"cache_read_input_token_cost": 1.4e-07,
|
"cache_read_input_token_cost": 3.625e-09,
|
||||||
"cache_creation_input_token_cost": 0.0,
|
"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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class FireworksUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "FireworksUpstreamProvider":
|
) -> "FireworksUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
|||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "GeminiUpstreamProvider":
|
) -> "GeminiUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "GenericUpstreamProvider":
|
) -> "GenericUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class GroqUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider":
|
def _build_from_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
api_key=provider_row.api_key,
|
api_key=provider_row.api_key,
|
||||||
provider_fee=provider_row.provider_fee,
|
provider_fee=provider_row.provider_fee,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlmodel import select
|
|||||||
|
|
||||||
from ..core import get_logger
|
from ..core import get_logger
|
||||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
||||||
|
from ..core.provider_slugs import allocate_unique_provider_slug
|
||||||
from ..payment.models import Model
|
from ..payment.models import Model
|
||||||
from .base import BaseUpstreamProvider
|
from .base import BaseUpstreamProvider
|
||||||
|
|
||||||
@@ -215,9 +216,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
|||||||
|
|
||||||
provider = _instantiate_provider(provider_row)
|
provider = _instantiate_provider(provider_row)
|
||||||
if provider:
|
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()
|
await provider.refresh_models_cache()
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Initialized {provider_row.provider_type} provider",
|
f"Initialized {provider_row.provider_type} provider",
|
||||||
@@ -250,6 +248,7 @@ async def _seed_providers_from_settings(
|
|||||||
|
|
||||||
providers_to_add: list[UpstreamProviderRow] = []
|
providers_to_add: list[UpstreamProviderRow] = []
|
||||||
seeded_provider_keys: set[tuple[str, str]] = set()
|
seeded_provider_keys: set[tuple[str, str]] = set()
|
||||||
|
reserved_slugs: set[str] = set()
|
||||||
|
|
||||||
provider_classes_by_type = {
|
provider_classes_by_type = {
|
||||||
cls.provider_type: cls
|
cls.provider_type: cls
|
||||||
@@ -280,8 +279,13 @@ async def _seed_providers_from_settings(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
|
slug = await allocate_unique_provider_slug(
|
||||||
|
session, provider_type, reserved_slugs
|
||||||
|
)
|
||||||
|
reserved_slugs.add(slug)
|
||||||
providers_to_add.append(
|
providers_to_add.append(
|
||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
|
slug=slug,
|
||||||
provider_type=provider_type,
|
provider_type=provider_type,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@@ -300,8 +304,13 @@ async def _seed_providers_from_settings(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
|
slug = await allocate_unique_provider_slug(
|
||||||
|
session, "ollama", reserved_slugs
|
||||||
|
)
|
||||||
|
reserved_slugs.add(slug)
|
||||||
providers_to_add.append(
|
providers_to_add.append(
|
||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
|
slug=slug,
|
||||||
provider_type="ollama",
|
provider_type="ollama",
|
||||||
base_url=ollama_base_url,
|
base_url=ollama_base_url,
|
||||||
api_key=ollama_api_key,
|
api_key=ollama_api_key,
|
||||||
@@ -321,8 +330,13 @@ async def _seed_providers_from_settings(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
|
slug = await allocate_unique_provider_slug(
|
||||||
|
session, "azure", reserved_slugs
|
||||||
|
)
|
||||||
|
reserved_slugs.add(slug)
|
||||||
providers_to_add.append(
|
providers_to_add.append(
|
||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
|
slug=slug,
|
||||||
provider_type="azure",
|
provider_type="azure",
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@@ -343,8 +357,13 @@ async def _seed_providers_from_settings(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
|
slug = await allocate_unique_provider_slug(
|
||||||
|
session, "custom", reserved_slugs
|
||||||
|
)
|
||||||
|
reserved_slugs.add(slug)
|
||||||
providers_to_add.append(
|
providers_to_add.append(
|
||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
|
slug=slug,
|
||||||
provider_type="custom",
|
provider_type="custom",
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@@ -357,7 +376,7 @@ async def _seed_providers_from_settings(
|
|||||||
session.add(provider)
|
session.add(provider)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Seeding {provider.provider_type} provider", # type: ignore[str-format]
|
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
|
return provider
|
||||||
|
|
||||||
if provider_row.provider_type == "custom":
|
if provider_row.provider_type == "custom":
|
||||||
return BaseUpstreamProvider(
|
return BaseUpstreamProvider.from_db_row(provider_row)
|
||||||
provider_row.base_url, provider_row.api_key, provider_row.provider_fee
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Unknown provider type: {provider_row.provider_type}",
|
f"Unknown provider type: {provider_row.provider_type}",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "OllamaUpstreamProvider":
|
) -> "OllamaUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class OpenAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "OpenAIUpstreamProvider":
|
) -> "OpenAIUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "OpenRouterUpstreamProvider":
|
) -> "OpenRouterUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class PerplexityUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "PerplexityUpstreamProvider":
|
) -> "PerplexityUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "PPQAIUpstreamProvider":
|
) -> "PPQAIUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
@@ -248,17 +248,14 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
f"Disabling PPQ.AI provider ({self.base_url}) due to insufficient balance",
|
f"Disabling PPQ.AI provider ({self.base_url}) due to insufficient balance",
|
||||||
extra={"error": error_message},
|
extra={"error": error_message},
|
||||||
)
|
)
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from ..core.db import UpstreamProviderRow, create_session
|
from ..core.db import UpstreamProviderRow, create_session
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
statement = select(UpstreamProviderRow).where(
|
provider = (
|
||||||
UpstreamProviderRow.base_url == self.base_url,
|
await session.get(UpstreamProviderRow, self.db_id)
|
||||||
UpstreamProviderRow.api_key == self.api_key,
|
if self.db_id is not None
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
result = await session.exec(statement)
|
|
||||||
provider = result.first()
|
|
||||||
|
|
||||||
if provider:
|
if provider:
|
||||||
provider.enabled = False
|
provider.enabled = False
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider):
|
|||||||
return path.lstrip("/")
|
return path.lstrip("/")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "RoutstrUpstreamProvider":
|
) -> "RoutstrUpstreamProvider":
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class XAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "XAIUpstreamProvider":
|
def _build_from_row(cls, provider_row: "UpstreamProviderRow") -> "XAIUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
api_key=provider_row.api_key,
|
api_key=provider_row.api_key,
|
||||||
provider_fee=provider_row.provider_fee,
|
provider_fee=provider_row.provider_fee,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -81,6 +81,21 @@ def test_backfill_strips_vendor_prefix_for_litellm_lookup() -> None:
|
|||||||
assert result.input_cache_read == expected
|
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:
|
def test_backfill_fills_cache_write_rate() -> None:
|
||||||
"""Anthropic cache writes cost more than input (1.25x); billing them at
|
"""Anthropic cache writes cost more than input (1.25x); billing them at
|
||||||
the input rate undercharges. litellm carries the write rate."""
|
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)
|
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
|
# calculate_cost — cached tokens billed at cache rates
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -404,6 +404,67 @@ async def test_deepseek_malformed_hit_tokens_coerce_to_zero() -> None:
|
|||||||
assert result.cache_read_input_tokens == 0
|
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
|
# Test 13: Missing Usage Block
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -68,6 +68,8 @@ const FormSchema = z.object({
|
|||||||
upstream_provider_id: z.string().default(''),
|
upstream_provider_id: z.string().default(''),
|
||||||
input_cost: z.coerce.number().min(0).default(0),
|
input_cost: z.coerce.number().min(0).default(0),
|
||||||
output_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),
|
request_cost: z.coerce.number().min(0).default(0),
|
||||||
image_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),
|
web_search_cost: z.coerce.number().min(0).default(0),
|
||||||
@@ -125,6 +127,8 @@ export function AddProviderModelDialog({
|
|||||||
upstream_provider_id: '',
|
upstream_provider_id: '',
|
||||||
input_cost: 0,
|
input_cost: 0,
|
||||||
output_cost: 0,
|
output_cost: 0,
|
||||||
|
cache_read_cost: 0,
|
||||||
|
cache_write_cost: 0,
|
||||||
request_cost: 0,
|
request_cost: 0,
|
||||||
image_cost: 0,
|
image_cost: 0,
|
||||||
web_search_cost: 0,
|
web_search_cost: 0,
|
||||||
@@ -190,6 +194,8 @@ export function AddProviderModelDialog({
|
|||||||
: initialData.upstream_provider_id?.toString() || '',
|
: initialData.upstream_provider_id?.toString() || '',
|
||||||
input_cost: pricing?.prompt ?? 0,
|
input_cost: pricing?.prompt ?? 0,
|
||||||
output_cost: pricing?.completion ?? 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,
|
request_cost: pricing?.request ?? 0,
|
||||||
image_cost: pricing?.image ?? 0,
|
image_cost: pricing?.image ?? 0,
|
||||||
web_search_cost: pricing?.web_search ?? 0,
|
web_search_cost: pricing?.web_search ?? 0,
|
||||||
@@ -231,6 +237,8 @@ export function AddProviderModelDialog({
|
|||||||
upstream_provider_id: '',
|
upstream_provider_id: '',
|
||||||
input_cost: 0,
|
input_cost: 0,
|
||||||
output_cost: 0,
|
output_cost: 0,
|
||||||
|
cache_read_cost: 0,
|
||||||
|
cache_write_cost: 0,
|
||||||
request_cost: 0,
|
request_cost: 0,
|
||||||
image_cost: 0,
|
image_cost: 0,
|
||||||
web_search_cost: 0,
|
web_search_cost: 0,
|
||||||
@@ -294,6 +302,8 @@ export function AddProviderModelDialog({
|
|||||||
);
|
);
|
||||||
form.setValue('input_cost', pricing?.prompt ?? 0);
|
form.setValue('input_cost', pricing?.prompt ?? 0);
|
||||||
form.setValue('output_cost', pricing?.completion ?? 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('request_cost', pricing?.request ?? 0);
|
||||||
form.setValue('image_cost', pricing?.image ?? 0);
|
form.setValue('image_cost', pricing?.image ?? 0);
|
||||||
form.setValue('web_search_cost', pricing?.web_search ?? 0);
|
form.setValue('web_search_cost', pricing?.web_search ?? 0);
|
||||||
@@ -365,6 +375,8 @@ export function AddProviderModelDialog({
|
|||||||
pricing: {
|
pricing: {
|
||||||
prompt: data.input_cost,
|
prompt: data.input_cost,
|
||||||
completion: data.output_cost,
|
completion: data.output_cost,
|
||||||
|
input_cache_read: data.cache_read_cost,
|
||||||
|
input_cache_write: data.cache_write_cost,
|
||||||
request: data.request_cost,
|
request: data.request_cost,
|
||||||
image: data.image_cost,
|
image: data.image_cost,
|
||||||
web_search: data.web_search_cost,
|
web_search: data.web_search_cost,
|
||||||
@@ -810,6 +822,38 @@ export function AddProviderModelDialog({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='cache_read_cost'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Cache Read Cost</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type='number' step='0.000001' {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Discounted cached-input read price per 1M tokens.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='cache_write_cost'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Cache Write Cost</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type='number' step='0.000001' {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Cached-input creation price per 1M tokens.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='request_cost'
|
name='request_cost'
|
||||||
|
|||||||
@@ -118,6 +118,26 @@ export function ProviderFormFields({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor={`${idPrefix}slug`}>
|
||||||
|
Slug {mode === 'create' ? '(optional, auto-generated)' : ''}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${idPrefix}slug`}
|
||||||
|
value={formData.slug || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
slug: e.target.value || undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder='e.g. openai-prod'
|
||||||
|
/>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Stable external key used to update this provider via the admin API.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label htmlFor={`${idPrefix}base_url`}>Base URL</Label>
|
<Label htmlFor={`${idPrefix}base_url`}>Base URL</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const ProviderTypeSchema = z.object({
|
|||||||
|
|
||||||
export const UpstreamProviderSchema = z.object({
|
export const UpstreamProviderSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
|
slug: z.string().nullable().optional(),
|
||||||
provider_type: z.string(),
|
provider_type: z.string(),
|
||||||
base_url: z.string(),
|
base_url: z.string(),
|
||||||
api_key: z.string().optional(),
|
api_key: z.string().optional(),
|
||||||
@@ -31,6 +32,7 @@ export const CreateUpstreamProviderSchema = z.object({
|
|||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
provider_fee: z.number().optional(),
|
provider_fee: z.number().optional(),
|
||||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
|
slug: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateUpstreamProviderSchema = z.object({
|
export const UpdateUpstreamProviderSchema = z.object({
|
||||||
@@ -41,6 +43,7 @@ export const UpdateUpstreamProviderSchema = z.object({
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
provider_fee: z.number().optional(),
|
provider_fee: z.number().optional(),
|
||||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
|
slug: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AdminModelPricingSchema = z.object({
|
export const AdminModelPricingSchema = z.object({
|
||||||
@@ -50,6 +53,8 @@ export const AdminModelPricingSchema = z.object({
|
|||||||
image: z.number().optional(),
|
image: z.number().optional(),
|
||||||
web_search: z.number().optional(),
|
web_search: z.number().optional(),
|
||||||
internal_reasoning: z.number().optional(),
|
internal_reasoning: z.number().optional(),
|
||||||
|
input_cache_read: z.number().optional(),
|
||||||
|
input_cache_write: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AdminModelArchitectureSchema = z.object({
|
export const AdminModelArchitectureSchema = z.object({
|
||||||
@@ -146,7 +151,7 @@ export class AdminService {
|
|||||||
if (!pricing) return pricing;
|
if (!pricing) return pricing;
|
||||||
const result = { ...pricing };
|
const result = { ...pricing };
|
||||||
|
|
||||||
// Only prompt and completion are per-token and need scaling to per-1M
|
// Token-priced fields are stored per-token by the API and shown per-1M in the UI.
|
||||||
const convertField = (field: string) => {
|
const convertField = (field: string) => {
|
||||||
const val = result[field];
|
const val = result[field];
|
||||||
if (val !== undefined && val !== null) {
|
if (val !== undefined && val !== null) {
|
||||||
@@ -161,6 +166,8 @@ export class AdminService {
|
|||||||
|
|
||||||
convertField('prompt');
|
convertField('prompt');
|
||||||
convertField('completion');
|
convertField('completion');
|
||||||
|
convertField('input_cache_read');
|
||||||
|
convertField('input_cache_write');
|
||||||
|
|
||||||
// Other fields (request, image, etc.) are already flat fees (per item)
|
// Other fields (request, image, etc.) are already flat fees (per item)
|
||||||
// so we do NOT scale them.
|
// so we do NOT scale them.
|
||||||
@@ -174,7 +181,7 @@ export class AdminService {
|
|||||||
if (!pricing) return pricing;
|
if (!pricing) return pricing;
|
||||||
const result = { ...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 convertField = (field: string) => {
|
||||||
const val = result[field];
|
const val = result[field];
|
||||||
if (val !== undefined && val !== null) {
|
if (val !== undefined && val !== null) {
|
||||||
@@ -187,6 +194,8 @@ export class AdminService {
|
|||||||
|
|
||||||
convertField('prompt');
|
convertField('prompt');
|
||||||
convertField('completion');
|
convertField('completion');
|
||||||
|
convertField('input_cache_read');
|
||||||
|
convertField('input_cache_write');
|
||||||
|
|
||||||
// Other fields stay as flat fees
|
// Other fields stay as flat fees
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user