mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-11 11:47:50 +00:00
fix different max cost calculation
This commit is contained in:
@@ -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
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user