fix different max cost calculation

This commit is contained in:
9qeklajc
2025-10-14 00:02:04 +02:00
parent c56a06f3df
commit 9911ce9dcd
4 changed files with 146 additions and 12 deletions
+1
View File
@@ -53,6 +53,7 @@ class Settings(BaseSettings):
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
# Minimum per-request charge in millisatoshis when model pricing is free/zero
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
cashu_mint_fee_msat: int = Field(default=60, env="CASHU_MINT_FEE_MSAT")
# Network
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
+15 -11
View File
@@ -11,7 +11,7 @@ from ..core import get_logger
from ..core.db import ModelRow
from ..core.settings import settings
from ..wallet import deserialize_token_from_string
from .models import Pricing
from .models import Pricing, compute_effective_max_cost_msats
logger = get_logger(__name__)
@@ -72,7 +72,8 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000
)
if max_cost_for_model > amount_msat:
fee_buffer = getattr(settings, "cashu_mint_fee_msat", 60)
if max_cost_for_model > amount_msat + fee_buffer:
raise HTTPException(
status_code=413,
detail={
@@ -133,16 +134,17 @@ async def get_max_cost_for_model(
row = await session.get(ModelRow, model)
if row and row.sats_pricing:
try:
sats = Pricing(**json.loads(row.sats_pricing)) # type: ignore
max_cost = sats.max_cost * 1000 * (1 - settings.tolerance_percentage / 100)
logger.debug(
"Found model-specific max cost",
extra={"model": model, "max_cost_msats": max_cost},
)
calculated_msats = int(max_cost)
return max(settings.min_request_msat, calculated_msats)
sats_dict = json.loads(row.sats_pricing)
except Exception:
pass
sats_dict = None
if isinstance(sats_dict, dict):
effective_msats = compute_effective_max_cost_msats(sats_dict)
if effective_msats > 0:
logger.debug(
"Found model-specific max cost",
extra={"model": model, "max_cost_msats": effective_msats},
)
return effective_msats
logger.warning(
"Model pricing not found, using fixed cost",
@@ -201,6 +203,8 @@ async def calculate_discounted_max_cost(
else:
adjusted = adjusted + math.ceil(-estimated_completion_delta_sats * 1000)
adjusted = min(max_cost_for_model, adjusted)
logger.debug(
"Discounted max cost computed",
extra={
+37
View File
@@ -58,6 +58,39 @@ class Model(BaseModel):
top_provider: TopProvider | None = None
def compute_effective_max_cost_msats(
pricing: Pricing | dict | None,
) -> int:
if pricing is None:
try:
return max(1, int(settings.min_request_msat))
except Exception:
return 1
pricing_obj = (
pricing if isinstance(pricing, Pricing) else Pricing.parse_obj(pricing) # type: ignore[arg-type]
)
try:
tolerance = float(getattr(settings, "tolerance_percentage", 0.0))
except Exception:
tolerance = 0.0
tolerance_factor = max(0.0, 1.0 - tolerance / 100.0)
try:
min_request_msat = max(1, int(settings.min_request_msat))
except Exception:
min_request_msat = 1
base_msats = int(float(pricing_obj.max_cost or 0.0) * 1000.0 * tolerance_factor)
if base_msats <= 0:
return min_request_msat
return max(min_request_msat, base_msats)
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"""Fetches model information from OpenRouter API."""
base_url = "https://openrouter.ai/api/v1"
@@ -177,6 +210,10 @@ def _row_to_model(row: ModelRow) -> Model:
except Exception:
pass
if isinstance(sats_pricing, dict):
effective_msats = compute_effective_max_cost_msats(sats_pricing)
sats_pricing["max_cost"] = effective_msats / 1000.0
return Model(
id=row.id,
name=row.name,
+93 -1
View File
@@ -1,12 +1,20 @@
import os
from unittest.mock import AsyncMock, Mock, patch
import pytest
from fastapi import HTTPException
# Set required env vars before importing
os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.core.settings import settings # noqa: E402
from routstr.payment.helpers import get_max_cost_for_model # noqa: E402
from routstr.payment.helpers import ( # noqa: E402
calculate_discounted_max_cost,
check_token_balance,
get_max_cost_for_model,
)
from routstr.payment.models import Pricing # noqa: E402
async def test_get_max_cost_for_model_known() -> None:
@@ -73,3 +81,87 @@ async def test_get_max_cost_for_model_tolerance() -> None:
with patch.object(settings, "tolerance_percentage", 10):
cost = await get_max_cost_for_model("gpt-4", session=mock_session)
assert cost == 450000 # 500 sats * 1000 * 0.9 = 450000
async def test_calculate_discounted_max_cost_no_session() -> None:
with patch.object(settings, "fixed_pricing", False):
result = await calculate_discounted_max_cost(123, {}, session=None)
assert result == 123
async def test_calculate_discounted_max_cost_clamped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mock_session = AsyncMock()
mock_exec_result = Mock()
mock_exec_result.all = Mock(return_value=[("gpt-4",)])
mock_session.exec.return_value = mock_exec_result
pricing = {
"prompt": 0.0,
"completion": 0.0,
"request": 0.0,
"image": 0.0,
"web_search": 0.0,
"internal_reasoning": 0.0,
"max_prompt_cost": 100.0,
"max_completion_cost": 200.0,
"max_cost": 300.0,
}
async def mock_get_model_cost_info(*args, **kwargs): # type: ignore
return Pricing(**pricing)
monkeypatch.setattr(
"routstr.payment.helpers.get_model_cost_info", mock_get_model_cost_info
)
with patch.object(settings, "fixed_pricing", False):
with patch.object(settings, "tolerance_percentage", 0):
result = await calculate_discounted_max_cost(
320000,
{"model": "gpt-4", "max_tokens": 10, "messages": ["one"]},
session=mock_session,
)
assert 0 <= result <= 320000
async def test_check_token_balance_with_fee_buffer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class Token:
unit = "msat"
amount = 320400
def mock_deserialize(_value: str) -> Token:
return Token()
monkeypatch.setattr(
"routstr.payment.helpers.deserialize_token_from_string", mock_deserialize
)
with patch.object(settings, "cashu_mint_fee_msat", 100):
headers = {"x-cashu": "token"}
body = {"model": "gpt-4"}
check_token_balance(headers, body, max_cost_for_model=320450)
def test_check_token_balance_insufficient(monkeypatch: pytest.MonkeyPatch) -> None:
class Token:
unit = "msat"
amount = 320200
def mock_deserialize(_value: str) -> Token:
return Token()
monkeypatch.setattr(
"routstr.payment.helpers.deserialize_token_from_string", mock_deserialize
)
with patch.object(settings, "cashu_mint_fee_msat", 100):
headers = {"x-cashu": "token"}
body = {"model": "gpt-4"}
with pytest.raises(HTTPException) as exc:
check_token_balance(headers, body, max_cost_for_model=320450)
assert exc.value.status_code == 413
detail = exc.value.detail # type: ignore[assignment]
assert isinstance(detail, dict)
assert detail.get("type") == "minimum_balance_required"