diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py index b27b0f17..fc20a12e 100644 --- a/routstr/payment/cost_calculation.py +++ b/routstr/payment/cost_calculation.py @@ -374,16 +374,38 @@ def _calculate_from_usd_cost( input_msats = math.floor(cost_in_msats * input_usd / component_usd) output_msats = cost_in_msats - input_msats else: - effective_input_tokens = ( - input_tokens + cache_read_tokens + cache_creation_tokens + # Providers often report only a total USD cost. Derive the visible + # input/output split from the model's relative token prices; raw token + # counts alone are misleading when completion tokens cost more. + try: + pricing_rates = _get_pricing_rates(response_data) + except ValueError: + pricing_rates = None + + if pricing_rates is None: + input_rate = float(settings.fixed_per_1k_input_tokens) * 1000.0 + output_rate = float(settings.fixed_per_1k_output_tokens) * 1000.0 + cache_read_rate = input_rate + cache_creation_rate = input_rate + else: + input_rate, output_rate, cache_read_rate, cache_creation_rate = ( + pricing_rates + ) + + input_weight = ( + input_tokens * input_rate + + cache_read_tokens * cache_read_rate + + cache_creation_tokens * cache_creation_rate ) - total_tokens = effective_input_tokens + output_tokens - input_msats = ( - int(cost_in_msats * effective_input_tokens / total_tokens) - if total_tokens > 0 - else 0 - ) - output_msats = cost_in_msats - input_msats + output_weight = output_tokens * output_rate + total_weight = input_weight + output_weight + + if total_weight > 0: + input_msats = math.floor(cost_in_msats * input_weight / total_weight) + output_msats = cost_in_msats - input_msats + else: + input_msats = 0 + output_msats = cost_in_msats logger.info( "Using cost from usage data/details", diff --git a/tests/unit/test_cost_calculation_caching.py b/tests/unit/test_cost_calculation_caching.py index bd53e122..f9f53f58 100644 --- a/tests/unit/test_cost_calculation_caching.py +++ b/tests/unit/test_cost_calculation_caching.py @@ -5,7 +5,7 @@ edge cases, and billing accuracy. """ import os -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -500,6 +500,38 @@ async def test_small_usd_cost_components_sum_to_rounded_total( assert result.input_msats + result.output_msats == result.total_msats +@pytest.mark.asyncio +async def test_total_only_usd_cost_uses_model_prices_for_component_split( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A reported total is split by priced tokens, not raw token counts.""" + monkeypatch.setattr(settings, "fixed_pricing", False) + response = { + "model": "z-ai/glm-5.2-20260616", + "usage": { + "prompt_tokens": 375, + "completion_tokens": 218, + "total_cost": 0.00039088, + }, + } + pricing = Mock( + prompt=0.0001, + completion=0.001, + input_cache_read=0.0001, + input_cache_write=0.0001, + ) + model = Mock(sats_pricing=pricing) + + with patch("routstr.proxy.get_model_instance", return_value=model): + result = await calculate_cost(response, max_cost=100000) + + assert isinstance(result, CostData) + assert result.total_msats == 7818 + assert result.input_msats + result.output_msats == result.total_msats + assert result.input_msats == 1147 + assert result.output_msats == 6671 + + # ============================================================================ # Test 13: Missing Usage Block # ============================================================================