diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py index 25e94993..06272d3a 100644 --- a/routstr/payment/cost_calculation.py +++ b/routstr/payment/cost_calculation.py @@ -264,7 +264,17 @@ def _coerce_usd(value: object) -> float: def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float: """Resolve USD cost with clear priority order. - Priority: cost_details.total_cost → total_cost → cost (in both usage and response). + Priority: + + 1. ``cost_details.total_cost`` + 2. ``cost_details.upstream_inference_cost`` (BYOK — see below) + 3. ``total_cost`` → ``cost`` (in both usage and response) + + **BYOK path (PPQ.AI):** when ``is_byok`` is true the ``usage.cost`` field + is only a small (~5 %) routing fee, not the inference cost. The real cost + lives in ``cost_details.upstream_inference_cost`` and the provider's + balance is debited by ``upstream_inference_cost + byok_fee``. Billing just + the fee under-charges by ~20×. """ cost_details = usage_data.get("cost_details") if isinstance(cost_details, dict): @@ -272,6 +282,18 @@ def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float: if cost > 0: return cost + # PPQ.AI BYOK: upstream_inference_cost is the real inference cost; + # usage.cost is only a ~5 % BYOK routing fee. Bill the sum — what PPQ + # actually deducts from the balance. For non-BYOK providers (e.g. + # OpenRouter) usage.cost already equals upstream_inference_cost, so we + # fall through to the normal ``cost`` lookup below. + upstream_cost = _coerce_usd( + cost_details.get("upstream_inference_cost") + ) + if upstream_cost > 0 and usage_data.get("is_byok"): + byok_fee = _coerce_usd(usage_data.get("cost")) + return upstream_cost + byok_fee + for source in [usage_data, response_data]: if not isinstance(source, dict): continue diff --git a/tests/unit/test_cost_calculation_caching.py b/tests/unit/test_cost_calculation_caching.py index 6ff7170b..ba31366a 100644 --- a/tests/unit/test_cost_calculation_caching.py +++ b/tests/unit/test_cost_calculation_caching.py @@ -532,6 +532,72 @@ async def test_openrouter_upstream_inference_cost_components_are_used() -> None: assert result.input_msats + result.output_msats == result.total_msats == 4471 +# ============================================================================ +# PPQ.AI BYOK: upstream_inference_cost + BYOK fee billing +# +# PPQ.AI (bring-your-own-key) returns a small ~5 % BYOK routing fee in +# ``usage.cost`` and the real inference cost in +# ``cost_details.upstream_inference_cost``. The old code billed only the fee, +# under-charging by ~20×. The fix bills ``upstream_inference_cost + byok_fee`` +# — what PPQ actually deducts from the balance. +# Payload numbers are from a live ``glm-5.2-fast`` request (GitHub issue #615). +# ============================================================================ +@pytest.mark.asyncio +async def test_ppq_byok_bills_upstream_inference_cost_plus_fee() -> None: + """PPQ.AI BYOK must bill upstream_inference_cost + byok_fee, not just the + fee. Mirrors the live request from GitHub issue #615.""" + response = { + "model": "glm-5.2-fast", + "usage": { + "prompt_tokens": 164371, # includes 159301 cached + "completion_tokens": 99, + "cost": 0.002260057305, # ~5% BYOK routing fee + "is_byok": True, + "prompt_tokens_details": {"cached_tokens": 159301}, + "cost_details": { + "upstream_inference_cost": 0.04475361, + "upstream_inference_prompt_cost": 0.04410021, + "upstream_inference_completions_cost": 0.0006534, + }, + }, + } + result = await calculate_cost(response, max_cost=100000) + + assert isinstance(result, CostData) + # The fix bills upstream_inference_cost + byok_fee (~0.047 USD → ~940k + # msats), not the fee alone (~0.0023 USD → ~45k msats). ~20× correction. + assert result.total_msats == 940274 + assert result.input_msats + result.output_msats == result.total_msats + assert result.input_msats == 926546 + assert result.output_msats == 13728 + assert result.total_usd == pytest.approx(0.047013667305) + # Token normalisation (OpenAI dialect: cached included in prompt_tokens) + assert result.input_tokens == 5070 # 164371 - 159301 + assert result.cache_read_input_tokens == 159301 + assert result.output_tokens == 99 + + +@pytest.mark.asyncio +async def test_ppq_byok_fee_only_would_undercharge() -> None: + """Sanity check: billing only usage.cost (the BYOK fee) under-charges by + ~20×. This documents the regression the fix prevents.""" + response = { + "model": "glm-5.2-fast", + "usage": { + "prompt_tokens": 164371, + "completion_tokens": 99, + "cost": 0.002260057305, # BYOK fee only — no upstream_inference_cost + "is_byok": True, + "prompt_tokens_details": {"cached_tokens": 159301}, + }, + } + result = await calculate_cost(response, max_cost=100000) + + assert isinstance(result, CostData) + # Without upstream_inference_cost, only the fee is billed — the old bug. + assert result.total_msats == 45202 + + # ============================================================================ # Test 13: Missing Usage Block # ============================================================================