Compare commits

..
Author SHA1 Message Date
9qeklajc e3ac06342f bump version 2026-07-06 23:00:41 +02:00
9qeklajcandGitHub c81de0de0a Merge pull request #583 from Routstr/fix/cache-pricing-ui
make cache prices configurable
2026-07-04 21:54:30 +02:00
9qeklajcandGitHub 81658d0ba6 Merge pull request #561 from Routstr/fix/refund-truly-empty-responses
fix(payment): refund truly-empty upstream responses billed a non-zero USD cost
2026-07-04 21:42:46 +02:00
9qeklajc ee5ced10c7 refactor(payment): extract shared _empty_cost helper; drop .gitignore change
Address PR #561 review:
- Extract the all-zero refund cost object into _empty_cost(), reused by
  both the no-usage-data path and the truly-empty USD-cost path.
- Revert the .gitignore additions; those belong in a separate PR.
2026-07-04 20:13:01 +02:00
ShroominicandClaude Opus 4.8 e972b62758 fix(payment): refund truly-empty responses with a non-zero USD cost
When an upstream reports a non-zero USD cost but the response carries no
tokens at all (input, output, cache-read and cache-creation all zero), the
USD path billed the full USD-derived amount for an empty response. Return an
all-zero cost (full refund) for that case only.

The gate is tightened relative to the superseded PR #489: a cache-read- or
cache-creation-only turn legitimately reports zero prompt/completion tokens
with a real cost and must still be billed, so the refund only fires when every
token bucket is zero.

Adds unit tests covering both the refund and the still-billed cache-only path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:10:24 +08:00
4 changed files with 107 additions and 15 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.4.3"
version = "0.4.4"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -20,7 +20,7 @@ import subprocess
from functools import lru_cache
from pathlib import Path
BASE_VERSION = "0.4.3"
BASE_VERSION = "0.4.4"
_REPO_ROOT = Path(__file__).resolve().parents[2]
_GIT_TIMEOUT_SECONDS = 2.0
+44 -13
View File
@@ -41,6 +41,28 @@ class CostDataError(BaseModel):
code: str
def _empty_cost(cls: type[CostData] = CostData) -> CostData:
"""Build an all-zero cost object — a full refund for an empty response.
Shared by the two paths that must not bill: an upstream response with no
usage data at all, and one that reports a USD cost but carries zero tokens
in every bucket.
"""
return cls(
base_msats=0,
input_msats=0,
output_msats=0,
total_msats=0,
total_usd=0.0,
input_tokens=0,
output_tokens=0,
cache_read_input_tokens=0,
cache_creation_input_tokens=0,
cache_read_msats=0,
cache_creation_msats=0,
)
async def calculate_cost(
response_data: dict,
max_cost: int,
@@ -83,19 +105,7 @@ async def calculate_cost(
else None,
},
)
return MaxCostData(
base_msats=0,
input_msats=0,
output_msats=0,
total_msats=0,
total_usd=0.0,
input_tokens=0,
output_tokens=0,
cache_read_input_tokens=0,
cache_creation_input_tokens=0,
cache_read_msats=0,
cache_creation_msats=0,
)
return _empty_cost(MaxCostData)
usage_data = response_data.get("usage") or {}
if not isinstance(usage_data, dict):
@@ -109,6 +119,27 @@ async def calculate_cost(
# Try USD cost first
usd_cost = _resolve_usd_cost(usage_data, response_data)
if usd_cost > 0:
truly_empty = (
input_tokens == 0
and output_tokens == 0
and cache_read_tokens == 0
and cache_creation_tokens == 0
)
if truly_empty:
logger.warning(
"Upstream reported a USD cost but the response carries no "
"tokens at all (input, output, cache-read and cache-creation "
"are all zero) — refunding in full rather than billing the "
"USD-derived cost for an empty response.",
extra={
"model": response_data.get("model", "unknown"),
"usd_cost": usd_cost,
"usage_keys": sorted(usage_data.keys())
if isinstance(usage_data, dict)
else None,
},
)
return _empty_cost()
if input_tokens == 0 and output_tokens == 0:
logger.warning(
"Upstream reported a USD cost but no token counts — "
@@ -404,6 +404,67 @@ async def test_deepseek_malformed_hit_tokens_coerce_to_zero() -> None:
assert result.cache_read_input_tokens == 0
# ============================================================================
# Truly-empty response with a non-zero USD cost → full refund
#
# When an upstream reports a USD cost but the response carries NO tokens at all
# (input, output, cache-read and cache-creation all zero), billing the
# USD-derived cost charges the user for nothing. Refund in full. The gate is
# tightened relative to PR #489: a cache-read/-creation-only turn legitimately
# reports zero prompt/completion tokens with a real cost and must still bill.
# ============================================================================
@pytest.mark.asyncio
async def test_truly_empty_usd_cost_response_is_refunded(
mock_fixed_pricing: None,
) -> None:
"""0 input + 0 output + 0 cache tokens with a non-zero USD cost → refund."""
response = {
"model": "gpt-4",
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_cost": 0.01, # non-zero USD cost despite no tokens
},
}
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.total_msats == 0 # full refund
assert result.input_msats == 0
assert result.output_msats == 0
assert result.total_usd == 0.0
assert result.input_tokens == 0
assert result.output_tokens == 0
assert result.cache_read_input_tokens == 0
assert result.cache_creation_input_tokens == 0
@pytest.mark.asyncio
async def test_cache_read_only_usd_cost_response_is_billed(
mock_fixed_pricing: None,
) -> None:
"""Cache-read-only turn (0 prompt/completion, non-zero cost) still bills."""
response = {
"model": "claude-3-5-sonnet",
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"cache_read_input_tokens": 1000, # real cached usage
"cache_creation_input_tokens": 0,
"total_cost": 0.01, # non-zero USD cost
},
}
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
# NOT refunded — the USD cost is billed in full. Pinning the exact value
# guards against any future regression that would over-refund a cache-only
# turn (the bug in PR #489, which refunded whenever prompt+completion == 0).
assert result.total_msats == 200000
assert result.total_usd == 0.01
assert result.cache_read_input_tokens == 1000
# ============================================================================
# Test 13: Missing Usage Block
# ============================================================================