diff --git a/routstr/auth.py b/routstr/auth.py index 23ad6920..b534027f 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -35,6 +35,25 @@ ROUTSTR_LN_ADDRESS: str = "npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qq ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS: int = 900 ROUTSTR_FEE_DEFAULT_PAYOUT: int = 200 + +def _format_msat_amount(amount: int) -> str: + sats = f"{amount / 1000:.3f}".rstrip("0").rstrip(".") + return f"{sats} sats ({amount} msats)" + + +def _model_balance_error(required: int, available: int) -> dict[str, dict[str, str]]: + return { + "error": { + "message": ( + f"Insufficient balance: {_format_msat_amount(required)} required " + f"for this model; {_format_msat_amount(available)} available." + ), + "type": "insufficient_quota", + "code": "insufficient_balance", + } + } + + # TODO: implement prepaid api key (not like it was before) # PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None) # PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats @@ -206,13 +225,7 @@ async def validate_bearer_key( ) raise HTTPException( status_code=402, - detail={ - "error": { - "message": f"Insufficient balance: {min_cost} mSats required for this model. {billing_key.total_balance} available.", - "type": "insufficient_quota", - "code": "insufficient_balance", - } - }, + detail=_model_balance_error(min_cost, billing_key.total_balance), ) # Early check: Spending limit check (Child key limit) @@ -302,13 +315,7 @@ async def validate_bearer_key( if min_cost > 0 and existing_key.total_balance < min_cost: raise HTTPException( status_code=402, - detail={ - "error": { - "message": f"Insufficient balance: {min_cost} mSats required for this model. {existing_key.total_balance} available.", - "type": "insufficient_quota", - "code": "insufficient_balance", - } - }, + detail=_model_balance_error(min_cost, existing_key.total_balance), ) return existing_key diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py index afd71219..592bda33 100644 --- a/routstr/payment/helpers.py +++ b/routstr/payment/helpers.py @@ -18,6 +18,14 @@ from ..wallet import deserialize_token_from_string logger = get_logger(__name__) +_MINT_FEE_ALLOWANCE = 0.10 + + +def apply_mint_fee_allowance(cost_msat: int) -> int: + """Reduce the admission reservation to account for mint fallback fees.""" + adjusted = math.ceil(cost_msat * (1 - _MINT_FEE_ALLOWANCE)) + return max(settings.min_request_msat, adjusted) + def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None: if x_cashu := headers.get("x-cashu", None): diff --git a/routstr/proxy.py b/routstr/proxy.py index 1bfb23e0..ba805334 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -19,8 +19,8 @@ from .core.db import ( ) from .core.exceptions import UpstreamError from .core.not_found import build_not_found_response -from .core.settings import settings from .payment.helpers import ( + apply_mint_fee_allowance, calculate_discounted_max_cost, check_token_balance, create_error_response, @@ -250,8 +250,7 @@ async def proxy( max_cost_for_model = await calculate_discounted_max_cost( _max_cost_for_model, request_body_dict, model_obj=model_obj ) - # Ensure max_cost_for_model is at least the minimum allowed request cost - max_cost_for_model = max(max_cost_for_model, settings.min_request_msat) + max_cost_for_model = apply_mint_fee_allowance(max_cost_for_model) check_token_balance(headers, request_body_dict, max_cost_for_model) @@ -606,17 +605,29 @@ async def get_bearer_token_key( }, ) return key - except Exception as e: - key_preview = bearer_key[:20] + "..." if len(bearer_key) > 20 else bearer_key - logger.error( - f"Bearer token validation failed: {type(e).__name__}: {e} path={path} model={model_id!r} min_cost={min_cost} key={key_preview!r}", + except HTTPException as error: + detail: dict[str, Any] = error.detail if isinstance(error.detail, dict) else {} + raw_error = detail.get("error") + error_info = raw_error if isinstance(raw_error, dict) else {} + logger.warning( + "Bearer token rejected", extra={ - "error": str(e), - "error_type": type(e).__name__, + "status_code": error.status_code, + "error_code": error_info.get("code"), "path": path, "model_id": model_id, - "min_cost_msat": min_cost, - "bearer_key_preview": key_preview, + "required_msat": min_cost, + }, + ) + raise + except Exception as error: + logger.exception( + "Bearer token validation failed", + extra={ + "error_type": type(error).__name__, + "path": path, + "model_id": model_id, + "required_msat": min_cost, }, ) raise diff --git a/tests/integration/test_insufficient_balance.py b/tests/integration/test_insufficient_balance.py index 11860327..546f1771 100644 --- a/tests/integration/test_insufficient_balance.py +++ b/tests/integration/test_insufficient_balance.py @@ -207,8 +207,30 @@ async def test_pay_for_request_succeeds_when_balance_equals_cost( assert key.balance == model_cost # balance unchanged, only reserved goes up +@pytest.mark.asyncio +async def test_ten_percent_mint_fee_shortfall_is_admitted_and_reserved( + integration_session: AsyncSession, +) -> None: + from routstr.auth import pay_for_request, validate_bearer_key + from routstr.payment.helpers import apply_mint_fee_allowance + + key = _key(balance=90_000) + integration_session.add(key) + await integration_session.commit() + + admission_cost = apply_mint_fee_allowance(100_000) + validated = await validate_bearer_key( + f"sk-{key.hashed_key}", integration_session, min_cost=admission_cost + ) + await pay_for_request(validated, admission_cost, integration_session) + + await integration_session.refresh(key) + assert admission_cost == 90_000 + assert key.reserved_balance == 90_000 + + # --------------------------------------------------------------------------- -# Test 6 — HTTP layer returns 402 JSON with the right shape +# HTTP layer returns 402 JSON with the right shape # --------------------------------------------------------------------------- @pytest.mark.asyncio @@ -264,8 +286,8 @@ async def test_http_402_response_shape_on_insufficient_balance( error = body["detail"]["error"] assert error["code"] == "insufficient_balance" assert error["type"] == "insufficient_quota" - assert str(model_cost) in error["message"] - assert str(user_balance) in error["message"] + assert "560.6 sats (560600 msats) required" in error["message"] + assert "20.32 sats (20320 msats) available" in error["message"] # Balance must be completely untouched await integration_session.refresh(key) diff --git a/tests/unit/test_payment_helpers.py b/tests/unit/test_payment_helpers.py index ef8dde63..a91991f9 100644 --- a/tests/unit/test_payment_helpers.py +++ b/tests/unit/test_payment_helpers.py @@ -7,7 +7,19 @@ 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 + apply_mint_fee_allowance, + get_max_cost_for_model, +) + + +def test_mint_fee_allowance_reduces_admission_cost_by_ten_percent() -> None: + assert apply_mint_fee_allowance(124_886) == 112_398 + + +def test_mint_fee_allowance_never_drops_below_minimum() -> None: + with patch.object(settings, "min_request_msat", 100): + assert apply_mint_fee_allowance(50) == 100 async def test_get_max_cost_for_model_known() -> None: diff --git a/tests/unit/test_stale_reservations.py b/tests/unit/test_stale_reservations.py index 8c7d7c8f..3cdd22d0 100644 --- a/tests/unit/test_stale_reservations.py +++ b/tests/unit/test_stale_reservations.py @@ -375,4 +375,4 @@ async def test_proxy_reverts_reservation_on_client_disconnect() -> None: with pytest.raises(asyncio.CancelledError): await proxy_module.proxy(request, "v1/chat/completions", session=session) - revert_mock.assert_awaited_once_with(key, session, 1_000) + revert_mock.assert_awaited_once_with(key, session, 900) diff --git a/tests/unit/test_upstream_rate_limit.py b/tests/unit/test_upstream_rate_limit.py index ea336c14..b4e05972 100644 --- a/tests/unit/test_upstream_rate_limit.py +++ b/tests/unit/test_upstream_rate_limit.py @@ -393,4 +393,4 @@ async def test_proxy_loop_surfaces_rate_limit_and_reverts_once() -> None: assert RAW_ORG_ID not in serialized assert "org-[REDACTED]" in serialized # Single upstream failed -> reservation reverted exactly once (no double-charge). - revert_mock.assert_awaited_once_with(key, session, 1_000) + revert_mock.assert_awaited_once_with(key, session, 900)