feat(payment): keep the upstream reported USD cost beside the billed one

_calculate_from_usd_cost multiplies the provider fee into the same local that
holds the upstream's reported USD cost, destroying it in place. total_usd is
post-fee and nothing kept the pre-fee figure, so a later cost check could not
compare what the upstream said against what we charged. Dividing the total back
out does not recover it: on a token-priced request the total is the node's own
arithmetic, so the division compares that number to itself.

Add upstream_usd to CostData, captured before the multiply, with
Field(default=0.0, exclude=True) so it never reaches a client -- published
beside total_usd it would spell out the node's margin as a ratio. It lives on
the object and does not survive .dict(), which is what the outbound cost
metadata and response headers are built from.

Token-priced requests leave it at 0.0, which doubles as the discriminator for
whether an upstream reported a cost at all. No persistence, no migration, no
charged-amount change.
This commit is contained in:
Jeroen Ubbink
2026-09-02 07:43:24 +02:00
parent 1715ff38fe
commit b8c5a6e0f4
2 changed files with 127 additions and 1 deletions
+4 -1
View File
@@ -1,7 +1,7 @@
import math
from typing import TYPE_CHECKING
from pydantic.v1 import BaseModel
from pydantic.v1 import BaseModel, Field
from ..core import get_logger
from ..core.settings import settings
@@ -37,6 +37,7 @@ class CostData(BaseModel):
cache_creation_msats: int = 0
# Actual debit after finalization; None means settlement has not run yet.
charged_msats: int | None = None
upstream_usd: float = Field(default=0.0, exclude=True)
class MaxCostData(CostData):
@@ -491,6 +492,7 @@ def _calculate_from_usd_cost(
"""Calculate cost from USD figures, deriving input/output split from tokens."""
if provider_fee is None:
provider_fee = _resolve_provider_fee(response_data.get("model", ""))
reported_usd = usd_cost
usd_cost = usd_cost * provider_fee
input_usd = input_usd * provider_fee
output_usd = output_usd * provider_fee
@@ -576,6 +578,7 @@ def _calculate_from_usd_cost(
cache_creation_input_tokens=cache_creation_tokens,
cache_read_msats=cache_read_msats,
cache_creation_msats=cache_creation_msats,
upstream_usd=reported_usd,
)
+123
View File
@@ -0,0 +1,123 @@
"""Tests that the upstream's own USD figure survives the provider-fee multiply.
``_calculate_from_usd_cost`` multiplies the fee into the same local that holds
the upstream's reported cost, so by the time a ``CostData`` exists the raw
figure is gone and only the marked-up one remains. Dividing the total back out
does not recover it: when the request falls through to token pricing the total
is the node's own arithmetic, and dividing it compares that number to itself.
These tests cover ``upstream_usd`` — the pre-fee figure carried alongside the
billed one, and the discriminator for whether an upstream reported a cost at
all. They also cover the boundary it must not cross: the pair of numbers spells
out the node's margin, so it stays internal and is never serialised to a client.
"""
from __future__ import annotations
import math
from collections.abc import Iterator
from typing import Any
from unittest.mock import patch
import pytest
from routstr.payment.cost_calculation import CostData, calculate_cost
from routstr.payment.models import Architecture, Model, Pricing
@pytest.fixture(autouse=True)
def patch_sats_usd_price() -> Iterator[None]:
"""Pin the exchange rate; these tests are about the USD figure, not the feed."""
with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=5.0e-5):
yield
def _model() -> Model:
return Model(
id="m",
name="m",
created=0,
description="d",
context_length=8192,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="unknown",
instruct_type=None,
),
pricing=Pricing(prompt=1e-06, completion=2e-06),
sats_pricing=Pricing(prompt=1e-06, completion=2e-06),
)
def _response(usage: dict[str, Any]) -> dict[str, Any]:
return {"model": "m", "usage": usage}
@pytest.mark.asyncio
async def test_reported_cost_is_kept_alongside_the_billed_one() -> None:
"""The billed total carries the fee; ``upstream_usd`` must not."""
response = _response(
{"prompt_tokens": 1000, "completion_tokens": 500, "cost": 0.01}
)
cost = await calculate_cost(
response, max_cost=999999, model_obj=_model(), provider_fee=1.05
)
assert isinstance(cost, CostData)
assert cost.total_usd == pytest.approx(0.0105)
assert cost.upstream_usd == pytest.approx(0.01)
@pytest.mark.asyncio
async def test_token_priced_request_reports_no_upstream_cost() -> None:
"""Nothing was reported, so there is nothing to carry — not our own total."""
response = _response({"prompt_tokens": 1000, "completion_tokens": 500})
cost = await calculate_cost(
response, max_cost=999999, model_obj=_model(), provider_fee=1.05
)
assert isinstance(cost, CostData)
assert cost.total_msats > 0
assert cost.upstream_usd == 0.0
@pytest.mark.asyncio
async def test_reported_cost_is_never_serialised_to_a_client() -> None:
"""Publishing it beside the billed total would spell out the node's margin."""
response = _response(
{"prompt_tokens": 1000, "completion_tokens": 500, "cost": 0.01}
)
cost = await calculate_cost(
response, max_cost=999999, model_obj=_model(), provider_fee=1.05
)
assert isinstance(cost, CostData)
assert cost.upstream_usd == pytest.approx(0.01)
assert "upstream_usd" not in cost.dict()
assert "upstream_usd" not in cost.json()
@pytest.mark.asyncio
async def test_billed_total_is_reproducible_from_the_reported_cost() -> None:
"""Fee, rate and rounding must carry the reported figure to the billed one.
The identity a report can restate: whatever the upstream said, times the
provider fee, converted at the current rate and rounded up, is what the node
charged. A figure chosen for its awkward remainder keeps the ceiling honest.
"""
response = _response(
{"prompt_tokens": 1000, "completion_tokens": 500, "cost": 0.000123}
)
cost = await calculate_cost(
response, max_cost=999999, model_obj=_model(), provider_fee=1.03
)
assert isinstance(cost, CostData)
assert cost.total_msats == math.ceil(cost.upstream_usd * 1.03 / 5.0e-5 * 1000)
assert cost.total_msats == 2534