better price estimation

This commit is contained in:
9qeklajc
2026-08-27 00:41:26 +02:00
parent dd2885ca5d
commit 5f31c49eef
15 changed files with 456 additions and 226 deletions
+3 -2
View File
@@ -38,8 +38,9 @@ Every time you make a request to `/v1/chat/completions` (or others), the cost is
`Cost = (Input_Tokens * Price_Input) + (Output_Tokens * Price_Output) + Request_Fee` `Cost = (Input_Tokens * Price_Input) + (Output_Tokens * Price_Output) + Request_Fee`
- Prices are defined per model (see `/v1/models`). - Prices are defined per model (see `/v1/models`).
- If you stream the response, the balance is deducted incrementally or finalized at the end of the stream. - Routstr reserves an authorization ceiling before forwarding, then finalizes the request at measured token cost.
- If your balance hits 0 mid-stream, the connection is closed. - If a successful upstream omits usage, Routstr estimates input tokens from the provider-bound request and output tokens from the returned body or streamed deltas, then applies normal model pricing.
- A reservation is only a temporary hold. Missing usage or unusable prices must never turn the full reservation into the charge; if no auditable estimate can be priced, the reservation is released without charge.
### Headers ### Headers
+3 -3
View File
@@ -58,10 +58,10 @@ Contains the shared opaque EHBP transport and billing helpers:
- `EHBPForwardingTarget` — provider-specific target URL plus extra headers - `EHBPForwardingTarget` — provider-specific target URL plus extra headers
- `forward_ehbp_request()` — forwards the encrypted body, captures Tinfoil - `forward_ehbp_request()` — forwards the encrypted body, captures Tinfoil
usage from a response header or streaming HTTP trailer, and finalizes bearer usage from a response header or streaming HTTP trailer, and finalizes bearer
billing at actual cost (falling back to max cost when usage is unavailable) billing at actual cost (releasing the reservation when usage is unavailable)
- `forward_ehbp_x_cashu_request()` — redeems the Cashu token, refunds the full - `forward_ehbp_x_cashu_request()` — redeems the Cashu token, refunds the full
token on upstream failure, and refunds the difference between the redeemed token on upstream failure, and refunds the difference between the redeemed
amount and actual cost (or max cost when usage is unavailable) amount and actual cost (or the full amount when usage is unavailable)
### Provider support ### Provider support
@@ -84,7 +84,7 @@ The proxy is a **blind relay** for EHBP requests. It cannot decrypt the body
Cost tracking happens at the proxy level. Routstr reserves or redeems up to Cost tracking happens at the proxy level. Routstr reserves or redeems up to
`max_cost_for_model`, then Tinfoil's out-of-band usage header/trailer allows it `max_cost_for_model`, then Tinfoil's out-of-band usage header/trailer allows it
to finalize at actual token cost. If trusted usage is missing or invalid, the to finalize at actual token cost. If trusted usage is missing or invalid, the
proxy safely falls back to max-cost billing. proxy releases/refunds rather than treating the authorization ceiling as usage.
## End-to-end flow ## End-to-end flow
+8 -7
View File
@@ -197,9 +197,9 @@ Properties:
This is the only architecture that preserves end-to-end encryption from the user to the PPQ/Tinfoil enclave while still letting Routstr mediate payment. The key requirement is that usage/cost metadata must be returned outside the encrypted body, ideally as a response header available before body streaming begins. This is the only architecture that preserves end-to-end encryption from the user to the PPQ/Tinfoil enclave while still letting Routstr mediate payment. The key requirement is that usage/cost metadata must be returned outside the encrypted body, ideally as a response header available before body streaming begins.
## Current Routstr problem ## Original Routstr problem
The current EHBP implementation charges successful EHBP requests at `max_cost_for_model` because Routstr cannot decrypt the response body: The original EHBP implementation charged successful EHBP requests at `max_cost_for_model` because Routstr could not decrypt the response body:
```text ```text
successful EHBP request -> charge full reserved max cost successful EHBP request -> charge full reserved max cost
@@ -369,7 +369,7 @@ Possible approaches:
- PPQ private models are billed per actual input/output tokens. - PPQ private models are billed per actual input/output tokens.
- Private model rates are available from `GET /v1/models?type=all`. - Private model rates are available from `GET /v1/models?type=all`.
- Current Routstr EHBP billing at max cost is wrong for PPQ private models. - Max-cost EHBP fallback is wrong for PPQ private models; current code releases/refunds when trusted usage metadata is absent.
- Direct Tinfoil integration inside Routstr would enable exact usage billing but would make Routstr see plaintext. - Direct Tinfoil integration inside Routstr would enable exact usage billing but would make Routstr see plaintext.
- A blind EHBP relay preserves privacy but requires PPQ/Tinfoil to expose usage/cost in plaintext headers/trailers. - A blind EHBP relay preserves privacy but requires PPQ/Tinfoil to expose usage/cost in plaintext headers/trailers.
- The preferred solution is to keep Routstr blind and have PPQ return billing metadata outside the encrypted body. - The preferred solution is to keep Routstr blind and have PPQ return billing metadata outside the encrypted body.
@@ -410,8 +410,9 @@ and `routstr/upstream/ehbp.py`.
actual served model's pricing is used for cost calculation. actual served model's pricing is used for cost calculation.
- `forward_ehbp_request()` (bearer auth): if `X-Tinfoil-Usage-Metrics` is - `forward_ehbp_request()` (bearer auth): if `X-Tinfoil-Usage-Metrics` is
present in the response header, finalizes with `adjust_payment_for_tokens()` present in the response header, finalizes with `adjust_payment_for_tokens()`
for exact billing; otherwise falls back to max-cost. Billing uses the for exact billing; otherwise releases the reservation. The encrypted body
actual served model when it differs from the requested one. cannot be estimated locally, and the authorization ceiling is not billed.
Billing uses the actual served model when it differs from the requested one.
- `forward_ehbp_x_cashu_request()`: if usage is available, computes the - `forward_ehbp_x_cashu_request()`: if usage is available, computes the
refund from actual cost instead of max cost, using the actual served refund from actual cost instead of max cost, using the actual served
model's pricing when applicable. model's pricing when applicable.
@@ -425,10 +426,10 @@ and `routstr/upstream/ehbp.py`.
|---|---|---| |---|---|---|
| Bearer, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Exact token cost via `adjust_payment_for_tokens` | | Bearer, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Exact token cost via `adjust_payment_for_tokens` |
| Bearer, streaming | `X-Tinfoil-Usage-Metrics` HTTP trailer | Exact token cost (h11 captures trailers) | | Bearer, streaming | `X-Tinfoil-Usage-Metrics` HTTP trailer | Exact token cost (h11 captures trailers) |
| Bearer, no usage header/trailer | N/A | Max-cost fallback | | Bearer, no usage header/trailer | N/A | Release reservation; zero charge |
| X-Cashu, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Refund = `redeemed - actual_cost` | | X-Cashu, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Refund = `redeemed - actual_cost` |
| X-Cashu, streaming | `X-Tinfoil-Usage-Metrics` HTTP trailer | Refund = `redeemed - actual_cost` (h11 captures trailers) | | X-Cashu, streaming | `X-Tinfoil-Usage-Metrics` HTTP trailer | Refund = `redeemed - actual_cost` (h11 captures trailers) |
| X-Cashu, no usage header/trailer | N/A | Refund = `redeemed - max_cost` | | X-Cashu, no usage header/trailer | N/A | Full refund |
### Cost response headers ### Cost response headers
+1 -1
View File
@@ -16,7 +16,7 @@ DO NOT modify or remove these messages without updating the usage tracking logic
- The 'token_cost', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics - The 'token_cost', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
3. "Max cost payment finalized" (INFO) - routstr/auth.py 3. "Max cost payment finalized" (INFO) - routstr/auth.py
- Used as the successful completion fallback when token usage is unavailable - Used for explicit flat-price/MaxCostData settlements; missing usage alone must not create this charge
- The 'charged_amount', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics - The 'charged_amount', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
4. "Payment processed successfully" (INFO) - routstr/auth.py 4. "Payment processed successfully" (INFO) - routstr/auth.py
+12 -36
View File
@@ -70,30 +70,6 @@ def _empty_cost(cls: type[CostData] = CostData) -> CostData:
) )
def _unmeasured_cost(max_cost: int) -> MaxCostData:
"""Build the bounded fallback for a response whose usage cannot be measured.
Missing usage must NOT settle at zero — that hands out free inference. The
request was authorized up to ``max_cost`` (the reservation), so the safe,
bounded settlement is to charge exactly that. Token components stay zero
because they are genuinely unknown; ``total_msats`` carries the authorized
max so max-cost finalization debits the reservation instead of nothing.
"""
return MaxCostData(
base_msats=0,
input_msats=0,
output_msats=0,
total_msats=max(0, max_cost),
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( async def calculate_cost(
response_data: dict, response_data: dict,
max_cost: int, max_cost: int,
@@ -134,11 +110,11 @@ async def calculate_cost(
if usage is None: if usage is None:
logger.warning( logger.warning(
"No usage data in response — settling at the reserved max cost " "No usage data or local estimate in response — releasing the "
"(bounded fallback), not zero. Dashboard will show this request " "reservation without charging it as usage. Dashboard will show "
"as `(0+0)` tokens. Most common cause: upstream stream did not " "this request as `(0+0)` tokens. Most common cause: upstream "
"include a final usage chunk (OpenAI-compat backends require " "stream did not include a final usage chunk (OpenAI-compat "
"`stream_options.include_usage=true`).", "backends require `stream_options.include_usage=true`).",
extra={ extra={
"max_cost_msats": max_cost, "max_cost_msats": max_cost,
"model": response_data.get("model", "unknown"), "model": response_data.get("model", "unknown"),
@@ -147,7 +123,7 @@ async def calculate_cost(
else None, else None,
}, },
) )
return _unmeasured_cost(max_cost) return _empty_cost(MaxCostData)
usage_data = response_data.get("usage") or {} usage_data = response_data.get("usage") or {}
if not isinstance(usage_data, dict): if not isinstance(usage_data, dict):
@@ -276,10 +252,10 @@ async def calculate_cost(
rates = (input_rate, output_rate, cache_read_rate, cache_creation_rate) rates = (input_rate, output_rate, cache_read_rate, cache_creation_rate)
if not all(is_usable_rate(rate) for rate in rates): if not all(is_usable_rate(rate) for rate in rates):
logger.warning( logger.warning(
"No usable token pricing — billing at flat MaxCostData. " "No usable token pricing — releasing the reservation instead of "
"Token counts %s in the upstream response but cannot be " "treating its ceiling as the charge. Token counts %s in the "
"priced; the request will appear in dashboards with the " "upstream response but cannot be converted to money; the request "
"raw counts and a fixed max-cost charge.", "will appear in dashboards with raw counts and a zero charge.",
"are present" if (input_tokens > 0 or output_tokens > 0) else "are zero", "are present" if (input_tokens > 0 or output_tokens > 0) else "are zero",
extra={ extra={
"base_cost_msats": max_cost, "base_cost_msats": max_cost,
@@ -291,10 +267,10 @@ async def calculate_cost(
}, },
) )
return MaxCostData( return MaxCostData(
base_msats=max_cost, base_msats=0,
input_msats=0, input_msats=0,
output_msats=0, output_msats=0,
total_msats=max_cost, total_msats=0,
input_tokens=input_tokens, input_tokens=input_tokens,
output_tokens=output_tokens, output_tokens=output_tokens,
cache_read_input_tokens=cache_read_tokens, cache_read_input_tokens=cache_read_tokens,
+77 -42
View File
@@ -61,7 +61,7 @@ from .cache_breakpoints import (
inject_anthropic_cache_breakpoints, inject_anthropic_cache_breakpoints,
is_explicit_cache_model, is_explicit_cache_model,
) )
from .count_tokens import count_tokens_locally from .count_tokens import MissingUsageEstimator, count_tokens_locally
from .litellm_routing import detect_litellm_prefix from .litellm_routing import detect_litellm_prefix
from .rate_limit import UPSTREAM_RATE_LIMIT, classify_rate_limit from .rate_limit import UPSTREAM_RATE_LIMIT, classify_rate_limit
@@ -705,8 +705,8 @@ class BaseUpstreamProvider:
# OpenAI-compatible streaming responses omit ``usage`` unless the # OpenAI-compatible streaming responses omit ``usage`` unless the
# request sets ``stream_options.include_usage = true``. Without it # request sets ``stream_options.include_usage = true``. Without it
# we can't reconcile token counts at end of stream and the # we can't reconcile token counts at end of stream and must use
# request gets billed at max-cost with zero tokens. Discriminate # the local request/response estimator. Discriminate
# chat-completions-shaped requests by the ``messages`` field so we # chat-completions-shaped requests by the ``messages`` field so we
# don't poke unrelated endpoints. # don't poke unrelated endpoints.
if ( if (
@@ -1021,6 +1021,7 @@ class BaseUpstreamProvider:
model_obj: Model | None = None, model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None, reservation_snapshot: ReservationSnapshot | None = None,
client: httpx.AsyncClient | None = None, client: httpx.AsyncClient | None = None,
request_body: bytes | None = None,
) -> StreamingResponse: ) -> StreamingResponse:
"""Handle streaming chat completion responses with token usage tracking and cost adjustment. """Handle streaming chat completion responses with token usage tracking and cost adjustment.
@@ -1041,6 +1042,8 @@ class BaseUpstreamProvider:
snapshot_key, snapshot_session snapshot_key, snapshot_session
) )
usage_estimator = MissingUsageEstimator(request_body, model_obj)
logger.debug( logger.debug(
"Processing streaming chat completion", "Processing streaming chat completion",
extra={ extra={
@@ -1070,7 +1073,7 @@ class BaseUpstreamProvider:
try: try:
await adjust_payment_for_tokens( await adjust_payment_for_tokens(
fresh_key, fresh_key,
{"model": last_model_seen or "unknown", "usage": None}, usage_estimator.response_data(last_model_seen),
new_session, new_session,
max_cost_for_model, max_cost_for_model,
model_obj, model_obj,
@@ -1157,6 +1160,7 @@ class BaseUpstreamProvider:
obj = None obj = None
if isinstance(obj, dict): if isinstance(obj, dict):
usage_estimator.observe(obj)
self._apply_provider_field(obj) self._apply_provider_field(obj)
if obj.get("model"): if obj.get("model"):
last_model_seen = str(obj.get("model")) last_model_seen = str(obj.get("model"))
@@ -1246,13 +1250,8 @@ class BaseUpstreamProvider:
if fresh_key: if fresh_key:
cost_data: dict cost_data: dict
try: try:
adjustment_input = ( adjustment_input = usage_estimator.billing_data(
usage_chunk_data usage_chunk_data, last_model_seen
if usage_chunk_data is not None
else {
"model": last_model_seen or "unknown",
"usage": None,
}
) )
cost_data = await adjust_payment_for_tokens( cost_data = await adjust_payment_for_tokens(
fresh_key, fresh_key,
@@ -1361,6 +1360,7 @@ class BaseUpstreamProvider:
requested_model: str | None = None, requested_model: str | None = None,
model_obj: Model | None = None, model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None, reservation_snapshot: ReservationSnapshot | None = None,
request_body: bytes | None = None,
) -> Response: ) -> Response:
"""Handle non-streaming chat completion responses with token usage tracking and cost adjustment. """Handle non-streaming chat completion responses with token usage tracking and cost adjustment.
@@ -1402,6 +1402,13 @@ class BaseUpstreamProvider:
if "id" not in response_json or not isinstance(response_json["id"], str): if "id" not in response_json or not isinstance(response_json["id"], str):
response_json["id"] = f"chatcmpl-{uuid.uuid4()}" response_json["id"] = f"chatcmpl-{uuid.uuid4()}"
if not isinstance(response_json.get("usage"), dict):
usage_estimator = MissingUsageEstimator(request_body, model_obj)
usage_estimator.observe(response_json)
response_json["usage"] = usage_estimator.openai_response_data(
response_json.get("model")
)["usage"]
cost_data = await adjust_payment_for_tokens( cost_data = await adjust_payment_for_tokens(
key, key,
response_json, response_json,
@@ -1500,6 +1507,7 @@ class BaseUpstreamProvider:
model_obj: Model | None = None, model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None, reservation_snapshot: ReservationSnapshot | None = None,
client: httpx.AsyncClient | None = None, client: httpx.AsyncClient | None = None,
request_body: bytes | None = None,
) -> StreamingResponse: ) -> StreamingResponse:
"""Handle streaming Responses API responses with token usage tracking and cost adjustment. """Handle streaming Responses API responses with token usage tracking and cost adjustment.
@@ -1511,6 +1519,8 @@ class BaseUpstreamProvider:
Returns: Returns:
StreamingResponse with cost data injected at the end StreamingResponse with cost data injected at the end
""" """
usage_estimator = MissingUsageEstimator(request_body, model_obj)
logger.debug( logger.debug(
"Processing streaming Responses API completion", "Processing streaming Responses API completion",
extra={ extra={
@@ -1541,7 +1551,7 @@ class BaseUpstreamProvider:
try: try:
await adjust_payment_for_tokens( await adjust_payment_for_tokens(
fresh_key, fresh_key,
{"model": last_model_seen or "unknown", "usage": None}, usage_estimator.response_data(last_model_seen),
new_session, new_session,
max_cost_for_model, max_cost_for_model,
model_obj, model_obj,
@@ -1633,8 +1643,11 @@ class BaseUpstreamProvider:
"response.incomplete", "response.incomplete",
): ):
usage_chunk_data = obj usage_chunk_data = obj
if not usage_estimator.output_text:
usage_estimator.observe(obj)
return return
usage_estimator.observe(obj)
yield prefix + b"data: " + json.dumps(obj).encode() + b"\n\n" yield prefix + b"data: " + json.dumps(obj).encode() + b"\n\n"
else: else:
if final: if final:
@@ -1674,13 +1687,8 @@ class BaseUpstreamProvider:
if fresh_key: if fresh_key:
cost_data: dict cost_data: dict
try: try:
adjustment_input = ( adjustment_input = usage_estimator.billing_data(
usage_chunk_data usage_chunk_data, last_model_seen
if usage_chunk_data is not None
else {
"model": last_model_seen or "unknown",
"usage": None,
}
) )
cost_data = await adjust_payment_for_tokens( cost_data = await adjust_payment_for_tokens(
fresh_key, fresh_key,
@@ -1792,6 +1800,7 @@ class BaseUpstreamProvider:
requested_model: str | None = None, requested_model: str | None = None,
model_obj: Model | None = None, model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None, reservation_snapshot: ReservationSnapshot | None = None,
request_body: bytes | None = None,
) -> Response: ) -> Response:
"""Handle non-streaming Responses API responses with token usage tracking and cost adjustment. """Handle non-streaming Responses API responses with token usage tracking and cost adjustment.
@@ -1831,6 +1840,13 @@ class BaseUpstreamProvider:
}, },
) )
if not isinstance(response_json.get("usage"), dict):
usage_estimator = MissingUsageEstimator(request_body, model_obj)
usage_estimator.observe(response_json)
response_json["usage"] = usage_estimator.response_data(
response_json.get("model")
)["usage"]
if requested_model: if requested_model:
response_json["model"] = requested_model response_json["model"] = requested_model
if "id" not in response_json or not isinstance(response_json["id"], str): if "id" not in response_json or not isinstance(response_json["id"], str):
@@ -1945,9 +1961,9 @@ class BaseUpstreamProvider:
return return
try: try:
# Finalize with "unknown" model and no usage to release reservation/charge max cost # Generic opaque streams have no request/response token seam.
# (no routed identity here by design: the None usage settles at # Missing usage therefore releases the reservation; the hold is
# MaxCostData before any pricing lookup can happen). # never treated as evidence of consumption.
await adjust_payment_for_tokens( await adjust_payment_for_tokens(
key, key,
{"model": "unknown", "usage": None}, {"model": "unknown", "usage": None},
@@ -1982,7 +1998,10 @@ class BaseUpstreamProvider:
requested_model: str | None = None, requested_model: str | None = None,
model_obj: Model | None = None, model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None, reservation_snapshot: ReservationSnapshot | None = None,
request_body: bytes | None = None,
) -> StreamingResponse: ) -> StreamingResponse:
usage_estimator = MissingUsageEstimator(request_body, model_obj)
async def stream_with_cost( async def stream_with_cost(
max_cost_for_model: int, max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]: ) -> AsyncGenerator[bytes, None]:
@@ -2036,13 +2055,9 @@ class BaseUpstreamProvider:
usage_finalized = True usage_finalized = True
return None return None
try: try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens( cost_data = await adjust_payment_for_tokens(
fresh_key, fresh_key,
fallback, usage_estimator.response_data(last_model_seen),
new_session, new_session,
max_cost_for_model, max_cost_for_model,
model_obj, model_obj,
@@ -2081,6 +2096,7 @@ class BaseUpstreamProvider:
try: try:
data = json.loads(line[6:]) data = json.loads(line[6:])
if isinstance(data, dict): if isinstance(data, dict):
usage_estimator.observe(data)
msg = data.get("message", {}) msg = data.get("message", {})
if msg and msg.get("model"): if msg and msg.get("model"):
last_model_seen = str(msg.get("model")) last_model_seen = str(msg.get("model"))
@@ -2278,6 +2294,7 @@ class BaseUpstreamProvider:
requested_model: str | None = None, requested_model: str | None = None,
model_obj: Model | None = None, model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None, reservation_snapshot: ReservationSnapshot | None = None,
request_body: bytes | None = None,
) -> Response: ) -> Response:
try: try:
content = await response.aread() content = await response.aread()
@@ -2296,6 +2313,12 @@ class BaseUpstreamProvider:
if path.endswith("count_tokens") and "usage" not in response_json: if path.endswith("count_tokens") and "usage" not in response_json:
input_tokens = response_json.get("input_tokens", 0) input_tokens = response_json.get("input_tokens", 0)
response_json["usage"] = {"input_tokens": input_tokens} response_json["usage"] = {"input_tokens": input_tokens}
elif not isinstance(response_json.get("usage"), dict):
usage_estimator = MissingUsageEstimator(request_body, model_obj)
usage_estimator.observe(response_json)
response_json["usage"] = usage_estimator.response_data(
response_json.get("model")
)["usage"]
cost_data = await adjust_payment_for_tokens( cost_data = await adjust_payment_for_tokens(
key, key,
@@ -2403,11 +2426,18 @@ class BaseUpstreamProvider:
requested_model, requested_model,
model_obj, model_obj,
reservation_snapshot, reservation_snapshot,
request_body,
) )
response_json = messages_dispatch.coerce_litellm_payload(result) response_json = messages_dispatch.coerce_litellm_payload(result)
if requested_model and "model" in response_json: if requested_model and "model" in response_json:
response_json["model"] = requested_model response_json["model"] = requested_model
if not isinstance(response_json.get("usage"), dict):
usage_estimator = MissingUsageEstimator(request_body, model_obj)
usage_estimator.observe(response_json)
response_json["usage"] = usage_estimator.response_data(
response_json.get("model")
)["usage"]
cost_data = await adjust_payment_for_tokens( cost_data = await adjust_payment_for_tokens(
key, key,
@@ -2521,10 +2551,13 @@ class BaseUpstreamProvider:
requested_model: str | None, requested_model: str | None,
model_obj: Model | None = None, model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None, reservation_snapshot: ReservationSnapshot | None = None,
request_body: bytes | None = None,
) -> StreamingResponse: ) -> StreamingResponse:
"""Re-emit a litellm Anthropic-event iterator as live SSE bytes """Re-emit a litellm Anthropic-event iterator as live SSE bytes
with cost reconciliation appended at end of stream.""" with cost reconciliation appended at end of stream."""
usage_estimator = MissingUsageEstimator(request_body, model_obj)
async def stream_with_cost() -> AsyncGenerator[bytes, None]: async def stream_with_cost() -> AsyncGenerator[bytes, None]:
usage_finalized = False usage_finalized = False
last_model_seen: str | None = None last_model_seen: str | None = None
@@ -2541,12 +2574,10 @@ class BaseUpstreamProvider:
if usage_finalized: if usage_finalized:
return None return None
logger.warning( logger.warning(
"Finalizing /v1/messages stream with no usage data — " "Finalizing /v1/messages stream with locally estimated "
"client will be billed at max-cost with zero tokens. " "usage because the upstream omitted `usage` from SSE. "
"Likely cause: upstream omitted `usage` from the SSE " "Check that the upstream emits a final usage chunk; the "
"stream (check that the request includes " "reservation ceiling will not be used as the charge.",
"`stream_options.include_usage=true` and that the "
"upstream actually emits a final usage chunk).",
extra={ extra={
"key_hash": key.hashed_key[:8] + "...", "key_hash": key.hashed_key[:8] + "...",
"model": last_model_seen or "unknown", "model": last_model_seen or "unknown",
@@ -2560,13 +2591,9 @@ class BaseUpstreamProvider:
usage_finalized = True usage_finalized = True
return None return None
try: try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens( cost_data = await adjust_payment_for_tokens(
fresh_key, fresh_key,
fallback, usage_estimator.response_data(last_model_seen),
new_session, new_session,
max_cost_for_model, max_cost_for_model,
model_obj, model_obj,
@@ -2599,6 +2626,7 @@ class BaseUpstreamProvider:
async for annotated in messages_dispatch.stream_annotated_events( async for annotated in messages_dispatch.stream_annotated_events(
iterator, requested_model iterator, requested_model
): ):
usage_estimator.observe(annotated.event)
if annotated.model: if annotated.model:
last_model_seen = annotated.model last_model_seen = annotated.model
# Anthropic SSE reports usage cumulatively across # Anthropic SSE reports usage cumulatively across
@@ -3046,6 +3074,7 @@ class BaseUpstreamProvider:
requested_model=original_model_id, requested_model=original_model_id,
model_obj=model_obj, model_obj=model_obj,
reservation_snapshot=reservation_snapshot, reservation_snapshot=reservation_snapshot,
request_body=request_body,
) )
background_tasks = BackgroundTasks() background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose) background_tasks.add_task(response.aclose)
@@ -3064,6 +3093,7 @@ class BaseUpstreamProvider:
requested_model=original_model_id, requested_model=original_model_id,
model_obj=model_obj, model_obj=model_obj,
reservation_snapshot=reservation_snapshot, reservation_snapshot=reservation_snapshot,
request_body=request_body,
) )
finally: finally:
await response.aclose() await response.aclose()
@@ -3081,6 +3111,7 @@ class BaseUpstreamProvider:
requested_model=original_model_id, requested_model=original_model_id,
model_obj=model_obj, model_obj=model_obj,
reservation_snapshot=reservation_snapshot, reservation_snapshot=reservation_snapshot,
request_body=request_body,
) )
finally: finally:
await response.aclose() await response.aclose()
@@ -3131,6 +3162,7 @@ class BaseUpstreamProvider:
model_obj=model_obj, model_obj=model_obj,
reservation_snapshot=reservation_snapshot, reservation_snapshot=reservation_snapshot,
client=client, client=client,
request_body=request_body,
) )
# Handle both non-streaming chat completions and embeddings # Handle both non-streaming chat completions and embeddings
@@ -3144,6 +3176,7 @@ class BaseUpstreamProvider:
requested_model=original_model_id, requested_model=original_model_id,
model_obj=model_obj, model_obj=model_obj,
reservation_snapshot=reservation_snapshot, reservation_snapshot=reservation_snapshot,
request_body=request_body,
) )
finally: finally:
await response.aclose() await response.aclose()
@@ -3408,6 +3441,7 @@ class BaseUpstreamProvider:
model_obj=model_obj, model_obj=model_obj,
reservation_snapshot=reservation_snapshot, reservation_snapshot=reservation_snapshot,
client=client, client=client,
request_body=transformed_body,
) )
if response.status_code == 200: if response.status_code == 200:
@@ -3420,6 +3454,7 @@ class BaseUpstreamProvider:
requested_model=original_model_id, requested_model=original_model_id,
model_obj=model_obj, model_obj=model_obj,
reservation_snapshot=reservation_snapshot, reservation_snapshot=reservation_snapshot,
request_body=transformed_body,
) )
finally: finally:
await response.aclose() await response.aclose()
@@ -4799,11 +4834,11 @@ class BaseUpstreamProvider:
model = payload["model"] model = payload["model"]
if usage_data is None: if usage_data is None:
# Settlement invariant: a terminal request is never silently # No request body is available at this X-Cashu settlement seam, so
# zero-billed and never silently keeps the whole token. Unmeasured # an auditable input/output estimate cannot be built. Refund the
# usage settles at the authorization ceiling and refunds the rest. # token rather than treating the authorization ceiling as usage.
logger.warning( logger.warning(
"No usage in streaming Responses API response — settling at authorized max", "No usage in streaming Responses API response — refunding instead of charging the authorized max",
extra={ extra={
"model": model, "model": model,
"amount": amount, "amount": amount,
+160 -8
View File
@@ -23,7 +23,7 @@ import litellm
from fastapi.responses import Response from fastapi.responses import Response
from ..core import get_logger from ..core import get_logger
from ..payment.helpers import estimate_tokens from ..payment.helpers import estimate_prompt_tokens, estimate_tokens
from ..payment.models import Model from ..payment.models import Model
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -39,6 +39,13 @@ def _parse_request_body(request_body: bytes | None) -> dict[str, Any]:
return parsed if isinstance(parsed, dict) else {} return parsed if isinstance(parsed, dict) else {}
def _model_name(model_obj: Model | None, body: dict[str, Any]) -> str:
if model_obj is not None:
return model_obj.forwarded_model_id or model_obj.id or ""
body_model = body.get("model")
return body_model if isinstance(body_model, str) else ""
def _count_with_litellm(model: str, body: dict[str, Any]) -> int: def _count_with_litellm(model: str, body: dict[str, Any]) -> int:
messages = body.get("messages") messages = body.get("messages")
if not isinstance(messages, list): if not isinstance(messages, list):
@@ -67,6 +74,157 @@ def _count_with_litellm(model: str, body: dict[str, Any]) -> int:
) )
def _count_text_with_litellm(model: str, text: str) -> int:
return int(
litellm.token_counter(
model=model,
text=text,
count_response_tokens=True,
)
)
def _generated_text(value: object) -> list[str]:
"""Extract generated text/tool arguments without counting response metadata."""
generated_keys = {
"arguments",
"content",
"delta",
"output_text",
"partial_json",
"reasoning",
"reasoning_content",
"text",
"thinking",
}
parts: list[str] = []
def walk(item: object, key: str | None = None) -> None:
if isinstance(item, str):
if key in generated_keys:
parts.append(item)
return
if isinstance(item, list):
for child in item:
walk(child, key)
return
if isinstance(item, dict):
for child_key, child in item.items():
walk(child, child_key)
walk(value)
return parts
class MissingUsageEstimator:
"""Estimate billable usage when an upstream omits its usage trailer.
The reservation is deliberately absent from this class: it is an
authorization ceiling, not an input to usage measurement.
"""
def __init__(self, request_body: bytes | None, model_obj: Model | None) -> None:
self.body = _parse_request_body(request_body)
self.model_name = _model_name(model_obj, self.body)
self._output_parts: list[str] = []
self._input_tokens: int | None = None
def _estimate_input_tokens(self) -> int:
if self._input_tokens is not None:
return self._input_tokens
try:
self._input_tokens = _count_with_litellm(self.model_name, self.body)
except Exception as exc:
self._input_tokens = estimate_prompt_tokens(self.body)
logger.debug(
"litellm request token count failed; using local estimator",
extra={
"model": self.model_name,
"error": str(exc),
"error_type": type(exc).__name__,
"estimated_tokens": self._input_tokens,
},
)
return self._input_tokens
@property
def output_text(self) -> str:
return "".join(self._output_parts)
def observe(self, response_data: object) -> None:
if isinstance(response_data, dict):
event_type = response_data.get("type")
if isinstance(event_type, str) and event_type.endswith(".done"):
# Responses API ``*.done`` events repeat text already streamed
# via ``*.delta`` events; counting both would double-bill.
return
self._output_parts.extend(_generated_text(response_data))
def billing_data(
self,
response_data: dict[str, Any] | None,
model: str | None = None,
) -> dict[str, Any]:
"""Use measured usage when present, otherwise return a local estimate."""
if isinstance(response_data, dict):
usage = response_data.get("usage")
if not isinstance(usage, dict):
nested = response_data.get("response")
usage = nested.get("usage") if isinstance(nested, dict) else None
if isinstance(usage, dict) and usage:
return {
"model": model or response_data.get("model") or self.model_name,
"usage": usage,
}
if not self._output_parts:
self.observe(response_data)
return self.response_data(model)
def response_data(self, model: str | None = None) -> dict[str, Any]:
text = self.output_text
try:
output_tokens = (
_count_text_with_litellm(self.model_name, text) if text else 0
)
except Exception as exc:
output_tokens = len(text) // 3
logger.debug(
"litellm response token count failed; using local estimator",
extra={
"model": self.model_name,
"error": str(exc),
"error_type": type(exc).__name__,
"estimated_tokens": output_tokens,
},
)
input_tokens = max(0, int(self._estimate_input_tokens()))
output_tokens = max(0, int(output_tokens))
return {
"model": model or self.model_name or "unknown",
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"estimated": True,
},
}
def openai_response_data(self, model: str | None = None) -> dict[str, Any]:
"""Same estimate in the OpenAI chat-completions usage dialect."""
data = self.response_data(model)
usage = data["usage"]
return {
"model": data["model"],
"usage": {
"prompt_tokens": usage["input_tokens"],
"completion_tokens": usage["output_tokens"],
"total_tokens": usage["total_tokens"],
"estimated": True,
},
}
def count_tokens_locally( def count_tokens_locally(
request_body: bytes | None, request_body: bytes | None,
model_obj: Model | None, model_obj: Model | None,
@@ -75,13 +233,7 @@ def count_tokens_locally(
touching the upstream. Always returns 200; never raises.""" touching the upstream. Always returns 200; never raises."""
body = _parse_request_body(request_body) body = _parse_request_body(request_body)
model_name = "" model_name = _model_name(model_obj, body)
if model_obj is not None:
model_name = model_obj.forwarded_model_id or model_obj.id or ""
if not model_name:
body_model = body.get("model")
if isinstance(body_model, str):
model_name = body_model
input_tokens: int input_tokens: int
try: try:
+27 -82
View File
@@ -319,10 +319,10 @@ async def _compute_ehbp_actual_cost(
) -> dict: ) -> dict:
"""Compute the actual cost in msats from Tinfoil usage metrics. """Compute the actual cost in msats from Tinfoil usage metrics.
Falls back to ``max_cost_for_model`` when usage is absent (streaming) or When usage is present, the result is clamped to ``[min_request_msat,
cannot be priced. The result is clamped to ``[min_request_msat, max_cost_for_model]``. Missing or unpriceable usage returns zero: encrypted
max_cost_for_model]`` so the refund never exceeds the reservation and is EHBP bodies cannot be estimated locally, and the authorization ceiling is
never zero. not evidence of consumption.
When the usage-metrics header includes ``model=<name>`` and it differs When the usage-metrics header includes ``model=<name>`` and it differs
from ``model_obj.id``, the actual served model's pricing is used for the from ``model_obj.id``, the actual served model's pricing is used for the
@@ -335,7 +335,7 @@ async def _compute_ehbp_actual_cost(
""" """
usage_dict = parse_tinfoil_usage_metrics(usage_header) usage_dict = parse_tinfoil_usage_metrics(usage_header)
if usage_dict is None: if usage_dict is None:
return _build_cost_info(max_cost_for_model) return _build_cost_info(0)
# The enclave may serve a different model than the one requested (e.g. # The enclave may serve a different model than the one requested (e.g.
# due to failover). The usage-metrics header's ``model=<name>`` carries # due to failover). The usage-metrics header's ``model=<name>`` carries
@@ -406,19 +406,19 @@ async def _compute_ehbp_actual_cost(
) )
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"EHBP usage cost calculation failed, falling back to max cost", "EHBP usage cost calculation failed; releasing instead of charging max cost",
extra={ extra={
"model": pricing_model_id, "model": pricing_model_id,
"error": str(e), "error": str(e),
"usage": usage_dict, "usage": usage_dict,
}, },
) )
return _build_cost_info(max_cost_for_model, actual_model=actual_model) return _build_cost_info(0, actual_model=actual_model)
if isinstance(cost, MaxCostData): if isinstance(cost, MaxCostData):
logger.warning( logger.warning(
"EHBP calculate_cost returned MaxCostData (no model pricing), " "EHBP calculate_cost returned MaxCostData (no usable pricing); "
"falling back to max cost", "releasing instead of charging max cost",
extra={ extra={
"model": pricing_model_id, "model": pricing_model_id,
"max_cost_for_model": max_cost_for_model, "max_cost_for_model": max_cost_for_model,
@@ -426,7 +426,7 @@ async def _compute_ehbp_actual_cost(
"cost_total_msats": cost.total_msats, "cost_total_msats": cost.total_msats,
}, },
) )
return _build_cost_info(max_cost_for_model, actual_model=actual_model) return _build_cost_info(0, actual_model=actual_model)
if isinstance(cost, CostData): if isinstance(cost, CostData):
actual = max(int(cost.total_msats), int(settings.min_request_msat)) actual = max(int(cost.total_msats), int(settings.min_request_msat))
clamped = min(actual, max_cost_for_model) clamped = min(actual, max_cost_for_model)
@@ -450,13 +450,13 @@ async def _compute_ehbp_actual_cost(
) )
# CostDataError # CostDataError
logger.warning( logger.warning(
"EHBP usage cost calculation error, falling back to max cost", "EHBP usage cost calculation error; releasing instead of charging max cost",
extra={ extra={
"model": pricing_model_id, "model": pricing_model_id,
"error": getattr(cost, "message", str(cost)), "error": getattr(cost, "message", str(cost)),
}, },
) )
return _build_cost_info(max_cost_for_model, actual_model=actual_model) return _build_cost_info(0, actual_model=actual_model)
def _extract_usage_from_response( def _extract_usage_from_response(
@@ -600,78 +600,25 @@ async def finalize_ehbp_max_cost_payment(
model_id: str, model_id: str,
reservation_snapshot: ReservationSnapshot | None = None, reservation_snapshot: ReservationSnapshot | None = None,
) -> int: ) -> int:
"""Finalize an EHBP bearer request by charging the reserved max cost. """Release an unmeasured EHBP request without charging its reservation.
EHBP responses are encrypted, so Routstr cannot inspect token usage. Unlike The legacy name is retained for compatibility with internal callers. EHBP
normal completion handlers, this intentionally charges the pre-reserved max responses are encrypted, so no local estimate is possible when the trusted
cost and releases the reservation. usage header/trailer is absent.
""" """
reservation = reservation_snapshot or await get_reservation_snapshot(key, session) reservation = reservation_snapshot or await get_reservation_snapshot(key, session)
await _validate_reservation_snapshot(key, reservation, session) await _validate_reservation_snapshot(key, reservation, session)
if not await _claim_reservation_for_charge(reservation, session): key_log_hash = key.hashed_key[:8] + "..."
return 0 await release_reservation(reservation, session, reservation.reserved_msats)
max_cost_for_model = reservation.reserved_msats logger.warning(
billing_key = await get_billing_key(key, session) "Released unmeasured EHBP reservation without charging max cost",
key_hash = key.hashed_key
billing_key_hash = billing_key.hashed_key
total_cost_msats = max(0, int(max_cost_for_model))
now = int(time.time())
charged = await _charge_reservation_rows(
session,
billing_key_hash=billing_key_hash,
key_hash=key_hash,
reserved_msats=max_cost_for_model,
charge_msats=total_cost_msats,
)
if not charged:
logger.error(
"Failed to finalize EHBP max-cost payment",
extra={
"key_hash": key_hash[:8] + "...",
"billing_key_hash": billing_key_hash[:8] + "...",
"model": model_id,
"max_cost_for_model": max_cost_for_model,
},
)
await _release_failed_ehbp_charge(reservation, session)
return 0
await session.commit()
await _stop_reservation_heartbeat(reservation.release_id)
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
if total_cost_msats > 0 and ROUTSTR_FEE_PERCENT > 0:
fee_msats = math.ceil(total_cost_msats * ROUTSTR_FEE_PERCENT / 100)
try:
await accumulate_routstr_fee(session, fee_msats)
except Exception as e:
logger.warning(
"Failed to accumulate Routstr fee for EHBP request",
extra={"error": str(e), "fee_msats": fee_msats},
)
payments_logger.info(
"FINALIZE",
extra={ extra={
"event": "finalize", "key_hash": key_log_hash,
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model_id, "model": model_id,
"cost_reserved": max_cost_for_model, "max_cost_for_model": max_cost_for_model,
"cost_charged": total_cost_msats,
"input_tokens": 0,
"output_tokens": 0,
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
"total_spent": billing_key.total_spent,
"finalize_type": "ehbp_max_cost",
"finalized_at": now,
}, },
) )
return total_cost_msats return 0
async def send_cashu_refund( async def send_cashu_refund(
@@ -846,8 +793,8 @@ async def forward_ehbp_request(
cost_data["computed_msats"] = computed_msats cost_data["computed_msats"] = computed_msats
else: else:
logger.warning( logger.warning(
"EHBP usage metrics not found in headers or trailers, " "EHBP usage metrics not found in headers or trailers; "
"falling back to max-cost billing", "releasing instead of charging the authorization ceiling",
extra={ extra={
"model": model_obj.id, "model": model_obj.id,
"provider": provider_type, "provider": provider_type,
@@ -868,11 +815,9 @@ async def forward_ehbp_request(
"input_tokens": 0, "input_tokens": 0,
"output_tokens": 0, "output_tokens": 0,
} }
if charged_msats != max_cost_for_model:
cost_data["computed_msats"] = max_cost_for_model
# Build the cost_info dict from what adjust_payment_for_tokens returned # Build the cost_info dict from measured usage or the unmeasured-release
# or from the max-cost fallback. Fields match CostData/MaxCostData.dict(). # fallback. Fields match CostData/MaxCostData.dict().
cost_info = { cost_info = {
"total_msats": cost_data.get("total_msats", max_cost_for_model), "total_msats": cost_data.get("total_msats", max_cost_for_model),
"input_tokens": cost_data.get("input_tokens", 0), "input_tokens": cost_data.get("input_tokens", 0),
@@ -92,12 +92,15 @@ async def test_overrun_with_corrupted_aggregate_releases_without_charging(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_missing_usage_settles_at_reservation_not_zero( async def test_missing_usage_never_turns_reservation_into_charge(
integration_session: AsyncSession, integration_session: AsyncSession,
) -> None: ) -> None:
"""A response with no usable usage data must settle at the reserved max """A reservation is an authorization ceiling, not evidence of usage.
cost (bounded fallback), never at zero — otherwise the request is free
inference. Exercises the REAL calculate_cost, no patching.""" Upstream handlers should provide locally estimated usage when possible. If
no measurement or estimate reaches settlement, release the reservation
rather than charging its full value.
"""
from routstr.auth import ( from routstr.auth import (
adjust_payment_for_tokens, adjust_payment_for_tokens,
get_reservation_snapshot, get_reservation_snapshot,
@@ -122,13 +125,12 @@ async def test_missing_usage_settles_at_reservation_not_zero(
reservation_snapshot=reservation, reservation_snapshot=reservation,
) )
# Charged the authorized max, not zero. assert result["charged_msats"] == 0
assert result["charged_msats"] == reserved
integration_session.expunge_all() integration_session.expunge_all()
key_row = await integration_session.get(ApiKey, key_hash) key_row = await integration_session.get(ApiKey, key_hash)
assert key_row is not None assert key_row is not None
assert key_row.total_spent == reserved, "missing usage must not be free" assert key_row.total_spent == 0
assert key_row.balance == 10_000 - reserved assert key_row.balance == 10_000
assert key_row.reserved_balance == 0 assert key_row.reserved_balance == 0
+89 -1
View File
@@ -13,7 +13,7 @@ from unittest.mock import patch
from routstr.payment.models import Architecture, Model, Pricing from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream import count_tokens as count_tokens_module from routstr.upstream import count_tokens as count_tokens_module
from routstr.upstream.count_tokens import count_tokens_locally from routstr.upstream.count_tokens import MissingUsageEstimator, count_tokens_locally
def _make_model(model_id: str = "anthropic/claude-3-5-sonnet") -> Model: def _make_model(model_id: str = "anthropic/claude-3-5-sonnet") -> Model:
@@ -154,6 +154,94 @@ def test_supports_anthropic_system_block_list() -> None:
assert payload["input_tokens"] > 0 assert payload["input_tokens"] > 0
def test_missing_usage_estimator_prices_request_and_streamed_output() -> None:
model = _make_model()
request_body = _body(
{
"model": model.id,
"messages": [{"role": "user", "content": "price this prompt"}],
}
)
with (
patch.object(count_tokens_module, "_count_with_litellm", return_value=17),
patch.object(count_tokens_module, "_count_text_with_litellm", return_value=5),
):
estimator = MissingUsageEstimator(request_body, model)
estimator.observe(
{
"model": "provider/model",
"choices": [{"delta": {"content": "estimated output"}}],
}
)
response = estimator.response_data("provider/model")
assert response == {
"model": "provider/model",
"usage": {
"input_tokens": 17,
"output_tokens": 5,
"total_tokens": 22,
"estimated": True,
},
}
def test_missing_usage_estimator_skips_responses_api_done_events() -> None:
estimator = MissingUsageEstimator(b"{}", None)
estimator.observe({"type": "response.output_text.delta", "delta": "streamed"})
estimator.observe({"type": "response.output_text.done", "text": "streamed"})
estimator.observe(
{
"type": "response.content_part.done",
"part": {"type": "output_text", "text": "streamed"},
}
)
assert estimator.output_text == "streamed"
def test_missing_usage_estimator_openai_dialect() -> None:
model = _make_model()
request_body = _body(
{
"model": model.id,
"messages": [{"role": "user", "content": "price this prompt"}],
}
)
with (
patch.object(count_tokens_module, "_count_with_litellm", return_value=17),
patch.object(count_tokens_module, "_count_text_with_litellm", return_value=5),
):
estimator = MissingUsageEstimator(request_body, model)
estimator.observe({"choices": [{"delta": {"content": "estimated output"}}]})
response = estimator.openai_response_data("provider/model")
assert response == {
"model": "provider/model",
"usage": {
"prompt_tokens": 17,
"completion_tokens": 5,
"total_tokens": 22,
"estimated": True,
},
}
def test_missing_usage_estimator_does_not_count_response_metadata() -> None:
estimator = MissingUsageEstimator(b"{}", None)
estimator.observe(
{
"id": "chatcmpl-this-is-not-generated-text",
"model": "also-not-generated-text",
"choices": [{"delta": {"role": "assistant"}}],
}
)
assert estimator.output_text == ""
def test_uses_forwarded_model_id_when_present() -> None: def test_uses_forwarded_model_id_when_present() -> None:
model = _make_model("anthropic/claude-3-5-sonnet") model = _make_model("anthropic/claude-3-5-sonnet")
model.forwarded_model_id = "claude-3-5-sonnet-20241022" model.forwarded_model_id = "claude-3-5-sonnet-20241022"
+10 -8
View File
@@ -107,7 +107,7 @@ async def test_finalize_actual_cost_payment_updates_balance_and_releases_reserve
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_finalize_max_cost_payment_updates_parent_and_child_spend( async def test_unmeasured_ehbp_releases_parent_and_child_reservation(
session: AsyncSession, session: AsyncSession,
) -> None: ) -> None:
parent = ApiKey(hashed_key="ehbp-parent", balance=10_000) parent = ApiKey(hashed_key="ehbp-parent", balance=10_000)
@@ -126,19 +126,19 @@ async def test_finalize_max_cost_payment_updates_parent_and_child_spend(
reservation_snapshot=reservation, reservation_snapshot=reservation,
) )
assert charged == 3_000 assert charged == 0
updated_parent = await _api_key(session, "ehbp-parent") updated_parent = await _api_key(session, "ehbp-parent")
updated_child = await _api_key(session, "ehbp-child") updated_child = await _api_key(session, "ehbp-child")
assert updated_parent is not None assert updated_parent is not None
assert updated_child is not None assert updated_child is not None
assert updated_parent.balance == 7_000 assert updated_parent.balance == 10_000
assert updated_parent.reserved_balance == 0 assert updated_parent.reserved_balance == 0
assert updated_parent.reserved_at is None assert updated_parent.reserved_at is None
assert updated_parent.total_spent == 3_000 assert updated_parent.total_spent == 0
assert updated_child.balance == 0 assert updated_child.balance == 0
assert updated_child.reserved_balance == 0 assert updated_child.reserved_balance == 0
assert updated_child.reserved_at is None assert updated_child.reserved_at is None
assert updated_child.total_spent == 3_000 assert updated_child.total_spent == 0
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -178,7 +178,7 @@ async def test_finalize_actual_cost_payment_rolls_back_when_parent_update_matche
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_finalize_max_cost_payment_rolls_back_parent_when_child_update_matches_no_rows( async def test_unmeasured_ehbp_release_is_safe_when_charge_update_would_fail(
session: AsyncSession, session: AsyncSession,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -207,11 +207,13 @@ async def test_finalize_max_cost_payment_rolls_back_parent_when_child_update_mat
updated_parent = await _api_key(session, "ehbp-rollback-parent") updated_parent = await _api_key(session, "ehbp-rollback-parent")
assert updated_parent is not None assert updated_parent is not None
assert updated_parent.balance == 10_000 assert updated_parent.balance == 10_000
assert updated_parent.reserved_balance == 0 # The injected partial-update failure rolls aggregate subtraction back;
# terminal fencing prevents a charge or retry from consuming those funds.
assert updated_parent.reserved_balance == 3_000
assert updated_parent.total_spent == 0 assert updated_parent.total_spent == 0
updated_child = await _api_key(session, "ehbp-missing-child") updated_child = await _api_key(session, "ehbp-missing-child")
assert updated_child is not None assert updated_child is not None
assert updated_child.reserved_balance == 0 assert updated_child.reserved_balance == 3_000
assert updated_child.total_spent == 0 assert updated_child.total_spent == 0
release = await session.get(ReservationRelease, reservation.release_id) release = await session.get(ReservationRelease, reservation.release_id)
assert release is not None and release.status == "released" assert release is not None and release.status == "released"
+6 -8
View File
@@ -74,19 +74,17 @@ def _usage_response() -> dict[str, Any]:
ids=["nan", "inf", "negative"], ids=["nan", "inf", "negative"],
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unusable_token_rate_falls_back_to_max_cost(bad_rate: float) -> None: async def test_unusable_token_rate_never_charges_the_reservation(
"""An unusable configured rate must not be billed on. bad_rate: float,
) -> None:
It reached the token math, which raises after the response was already """An unusable configured rate must not turn authorization into usage."""
served — where the streaming handlers swallow it and the request goes
unbilled.
"""
model = _model(Pricing(prompt=bad_rate, completion=1.0)) model = _model(Pricing(prompt=bad_rate, completion=1.0))
cost = await calculate_cost(_usage_response(), max_cost=1234, model_obj=model) cost = await calculate_cost(_usage_response(), max_cost=1234, model_obj=model)
assert isinstance(cost, MaxCostData) assert isinstance(cost, MaxCostData)
assert cost.total_msats == 1234 assert cost.total_msats == 0
assert (cost.input_tokens, cost.output_tokens) == (1000, 500)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -22,6 +22,7 @@ from routstr.auth import (
) )
from routstr.core.db import ApiKey, ReservationRelease from routstr.core.db import ApiKey, ReservationRelease
from routstr.payment.cost_calculation import MaxCostData from routstr.payment.cost_calculation import MaxCostData
from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream.base import BaseUpstreamProvider from routstr.upstream.base import BaseUpstreamProvider
@@ -671,16 +672,14 @@ async def test_cross_key_reservation_snapshot_is_rejected_without_mutation() ->
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_client_disconnect_midstream_finalizes_and_stops_heartbeat() -> None: async def test_client_disconnect_midstream_estimates_usage_and_stops_heartbeat() -> (
"""A client that aborts the socket mid-stream must not leak its reservation. None
):
"""A client abort releases the hold after charging only estimated usage.
Starlette closes the response generator (``aclose``) on disconnect, whose Starlette closes the response generator (``aclose``) on disconnect. The
``finally`` schedules the background finalizer. That finalizer must settle finalizer still has the request and streamed deltas, so it can estimate
the reservation (charge the reserved max — usage is unknown), reach a usage without converting the reservation ceiling into the charge.
terminal durable state, and stop the lease heartbeat so the sweeper is not
needed. Driven against a real engine and the real finalizer; the socket
abort is modelled deterministically with ``aclose`` (the exact hook
Starlette invokes) to keep the test CI-stable.
""" """
engine = await _engine() engine = await _engine()
provider = BaseUpstreamProvider( provider = BaseUpstreamProvider(
@@ -707,6 +706,26 @@ async def test_client_disconnect_midstream_finalizes_and_stops_heartbeat() -> No
) )
upstream_response.aiter_bytes = aiter_bytes upstream_response.aiter_bytes = aiter_bytes
model = Model(
id="test-model",
name="test-model",
created=0,
description="",
context_length=8_192,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="unknown",
instruct_type=None,
),
pricing=Pricing(prompt=0.01, completion=0.02),
sats_pricing=Pricing(prompt=0.01, completion=0.02),
)
request_body = json.dumps(
{"model": model.id, "messages": [{"role": "user", "content": "hi"}]}
).encode()
background_tasks = BackgroundTasks() background_tasks = BackgroundTasks()
try: try:
with ( with (
@@ -718,13 +737,24 @@ async def test_client_disconnect_midstream_finalizes_and_stops_heartbeat() -> No
"routstr.upstream.base.adjust_payment_for_tokens", "routstr.upstream.base.adjust_payment_for_tokens",
auth_module.adjust_payment_for_tokens, auth_module.adjust_payment_for_tokens,
), ),
patch("routstr.upstream.count_tokens._count_with_litellm", return_value=3),
patch(
"routstr.upstream.count_tokens._count_text_with_litellm",
return_value=2,
),
patch(
"routstr.payment.cost_calculation.sats_usd_price",
return_value=5.0e-5,
),
): ):
response = await provider.handle_streaming_chat_completion( response = await provider.handle_streaming_chat_completion(
response=upstream_response, response=upstream_response,
key=key, key=key,
max_cost_for_model=500, max_cost_for_model=500,
background_tasks=background_tasks, background_tasks=background_tasks,
model_obj=model,
reservation_snapshot=snapshot, reservation_snapshot=snapshot,
request_body=request_body,
) )
iterator = cast(AsyncGenerator[bytes, None], response.body_iterator) iterator = cast(AsyncGenerator[bytes, None], response.body_iterator)
await iterator.__anext__() # first chunk reaches the client await iterator.__anext__() # first chunk reaches the client
@@ -744,9 +774,9 @@ async def test_client_disconnect_midstream_finalizes_and_stops_heartbeat() -> No
# The reservation reached a single terminal outcome; funds are not locked. # The reservation reached a single terminal outcome; funds are not locked.
assert record is not None and record.status in {"charged", "released"} assert record is not None and record.status in {"charged", "released"}
assert final_key.reserved_balance == 0 assert final_key.reserved_balance == 0
# Unknown usage settles at the reserved max, never free. # 3 input tokens × 10 msats + 2 output tokens × 20 msats = 70 msats.
assert final_key.total_spent == 500 assert final_key.total_spent == 70
assert final_key.balance == 500 assert final_key.balance == 930
# The heartbeat is gone — no forever-renewing task on an abandoned request. # The heartbeat is gone — no forever-renewing task on an abandoned request.
assert snapshot.release_id not in auth_module._reservation_heartbeats assert snapshot.release_id not in auth_module._reservation_heartbeats
await engine.dispose() await engine.dispose()
+4 -4
View File
@@ -240,12 +240,12 @@ class TestResolveEhbpTargetUrl:
class TestComputeEhbpActualCost: class TestComputeEhbpActualCost:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_usage_falls_back_to_max_cost(self) -> None: async def test_no_usage_does_not_charge_authorization_ceiling(self) -> None:
model_obj = MagicMock() model_obj = MagicMock()
model_obj.id = "llama3-3-70b" model_obj.id = "llama3-3-70b"
model_obj.forwarded_model_id = "llama3-3-70b" model_obj.forwarded_model_id = "llama3-3-70b"
result = await _compute_ehbp_actual_cost(None, model_obj, 100_000) result = await _compute_ehbp_actual_cost(None, model_obj, 100_000)
assert result["total_msats"] == 100_000 assert result["total_msats"] == 0
assert result["input_tokens"] == 0 assert result["input_tokens"] == 0
assert result["output_tokens"] == 0 assert result["output_tokens"] == 0
@@ -285,7 +285,7 @@ class TestComputeEhbpActualCost:
assert result["output_msats"] == 20 assert result["output_msats"] == 20
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_max_cost_data_falls_back(self) -> None: async def test_unpriceable_usage_does_not_charge_authorization_ceiling(self) -> None:
model_obj = MagicMock() model_obj = MagicMock()
model_obj.id = "llama3-3-70b" model_obj.id = "llama3-3-70b"
model_obj.forwarded_model_id = "llama3-3-70b" model_obj.forwarded_model_id = "llama3-3-70b"
@@ -309,7 +309,7 @@ class TestComputeEhbpActualCost:
model_obj, model_obj,
50_000, 50_000,
) )
assert result["total_msats"] == 50_000 assert result["total_msats"] == 0
assert result["input_tokens"] == 0 assert result["input_tokens"] == 0
assert result["output_tokens"] == 0 assert result["output_tokens"] == 0
@@ -186,7 +186,7 @@ async def test_multiline_data_payload_is_parsed_and_reframed() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_missing_usage_settles_at_authorized_max() -> None: async def test_missing_usage_refunds_instead_of_charging_authorized_max() -> None:
chunks = [ chunks = [
b'data: {"type":"response.created","response":{"model":"gpt-5-mini"}}\r\n\r\n', b'data: {"type":"response.created","response":{"model":"gpt-5-mini"}}\r\n\r\n',
b"data: [DONE]\r\n\r\n", b"data: [DONE]\r\n\r\n",
@@ -198,9 +198,9 @@ async def test_missing_usage_settles_at_authorized_max() -> None:
send_refund.assert_awaited_once() send_refund.assert_awaited_once()
assert send_refund.await_args is not None assert send_refund.await_args is not None
assert send_refund.await_args.args[0] == 10_000 - 9_000 assert send_refund.await_args.args[0] == 10_000
assert response.headers["x-cashu"] == "cashuBrefundtoken0123456789" assert response.headers["x-cashu"] == "cashuBrefundtoken0123456789"
assert response.headers["x-routstr-cost-msats"] == "9000" assert response.headers["x-routstr-cost-msats"] == "0"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -215,7 +215,7 @@ async def test_malformed_events_do_not_retain_whole_token() -> None:
) )
assert send_refund.await_args is not None assert send_refund.await_args is not None
assert send_refund.await_args.args[0] == 1000 assert send_refund.await_args.args[0] == 10_000
body = await _collect(response) body = await _collect(response)
assert b"\\n" not in body assert b"\\n" not in body
assert body.endswith(b"\n\n") assert body.endswith(b"\n\n")