mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
better price estimation
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user