Files
routstr-core/routstr/upstream/base.py
T

5051 lines
196 KiB
Python

from __future__ import annotations
import asyncio
import json
import math
import traceback
import typing
import uuid
from collections.abc import AsyncGenerator, AsyncIterator, Iterator
from typing import Any, Mapping, Self, cast
import httpx
from fastapi import BackgroundTasks, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from pydantic.v1 import BaseModel
from ..auth import adjust_payment_for_tokens
from ..core import get_logger
from ..core.db import (
ApiKey,
AsyncSession,
UpstreamProviderRow,
create_session,
store_cashu_transaction,
)
from ..core.exceptions import UpstreamError
from ..core.redaction import redact_org_ids
from ..payment.cost_calculation import (
CostData,
CostDataError,
MaxCostData,
calculate_cost,
)
from ..payment.helpers import create_error_response
from ..payment.models import (
Model,
Pricing,
_calculate_usd_max_costs,
_update_model_sats_pricing,
backfill_cache_pricing,
list_models,
)
from ..payment.price import sats_usd_price
from ..wallet import recieve_token, send_token
from . import messages_dispatch
from .cache_breakpoints import (
inject_anthropic_cache_breakpoints,
is_explicit_cache_model,
)
from .count_tokens import count_tokens_locally
from .litellm_routing import detect_litellm_prefix
from .rate_limit import UPSTREAM_RATE_LIMIT, classify_rate_limit
if typing.TYPE_CHECKING:
from .ehbp import ConfidentialInferenceProfile, EHBPForwardingTarget
logger = get_logger(__name__)
def _is_json_content_type(content_type: str | None) -> bool:
"""Return True when the upstream response should be parsed as JSON.
"""
if not content_type:
return False
main = content_type.split(";", 1)[0].strip().lower()
if main in ("application/json", "text/json"):
return True
return main.startswith("application/") and main.endswith("+json")
class TopupData(BaseModel):
"""Universal top-up data schema for Lightning Network invoices."""
invoice_id: str
payment_request: str
amount: int
currency: str
expires_at: int | None = None
checkout_url: str | None = None
class BaseUpstreamProvider:
"""Provider for forwarding requests to an upstream AI service API."""
provider_type: str = "base"
default_base_url: str | None = None
platform_url: str | None = None
supports_anthropic_messages: bool = False
# When None, the prefix is detected from `base_url` at dispatch time
# (see `get_litellm_provider_prefix`). Subclasses set this to lock the
# provider regardless of URL.
litellm_provider_prefix: str | None = None
base_url: str
api_key: str
provider_fee: float = 1.05
# Primary key of the ``upstream_providers`` row this instance was built
# from. Set by ``from_db_row`` so a live provider can re-find its own row by
# stable identity instead of its rotatable ``api_key``. ``None`` for
# instances not sourced from a row.
db_id: int | None = None
_models_cache: list[Model] = []
_models_by_id: dict[str, Model] = {}
def __init__(self, base_url: str, api_key: str, provider_fee: float = 1.01):
"""Initialize the upstream provider.
Args:
base_url: Base URL of the upstream API endpoint
api_key: API key for authenticating with the upstream service
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
"""
self.base_url = base_url
self.api_key = api_key
self.provider_fee = provider_fee
self.db_id = None
self._models_cache = []
self._models_by_id = {}
def get_litellm_provider_prefix(self) -> str:
"""Resolve the litellm provider prefix for this provider instance.
1. If the subclass pinned `litellm_provider_prefix`, use it.
2. Otherwise infer from `base_url` (e.g. ``api.fireworks.ai`` →
``fireworks_ai/``) so custom/generic rows reach the correct
litellm backend instead of falling back to ``openai/``.
3. Default ``openai/`` for unknown OpenAI-compatible servers.
"""
if self.__class__.litellm_provider_prefix:
return self.__class__.litellm_provider_prefix
return detect_litellm_prefix(self.base_url)
@classmethod
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "Self | None":
"""Instantiate a provider from a database row, carrying its identity.
Construction itself is delegated to the ``_build_from_row`` hook (which
subclasses override to match their constructor); this wrapper stamps the
row's primary key onto the instance as ``db_id`` so the provider can
later re-find its own row by identity rather than by its ``api_key``.
Args:
provider_row: Database row containing provider configuration
Returns:
Instantiated provider or None if instantiation fails
"""
provider = cls._build_from_row(provider_row)
if provider is not None:
provider.db_id = provider_row.id
return provider
@classmethod
def _build_from_row(cls, provider_row: "UpstreamProviderRow") -> "Self | None":
"""Construct the provider instance from a row (no identity stamping).
Overridden by subclasses whose constructors differ from the base
``(base_url, api_key, provider_fee)`` shape. Callers should use
``from_db_row`` instead, which also attaches ``db_id``.
"""
return cls(
base_url=provider_row.base_url,
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
"""Get metadata about this provider type for API responses.
Returns:
Dict with provider type metadata including id, name, default_base_url, fixed_base_url, platform_url, can_create_account, can_topup, can_show_balance
"""
return {
"id": cls.provider_type,
"name": cls.provider_type.title(),
"default_base_url": cls.default_base_url or "",
"fixed_base_url": bool(cls.default_base_url),
"platform_url": cls.platform_url,
"can_create_account": False,
"can_topup": False,
"can_show_balance": False,
}
@staticmethod
def _fold_cache_into_input_tokens(usage: object) -> None:
"""Fold cache token counts into ``input_tokens`` / ``prompt_tokens``.
Cost calculation has already used the per-bucket counts to bill the
request correctly; what the client sees in the visible token total
should be a single rolled-up prompt count *including* the cache
portion. The standalone ``cache_read_input_tokens`` /
``cache_creation_input_tokens`` fields are left in place for clients
that want the breakdown.
For Anthropic-shaped responses (``input_tokens`` present), the cache
fields are forced to ``0`` when the upstream omitted them, so the
client always sees a consistent shape.
"""
if not isinstance(usage, dict):
return
# Normalise missing cache fields to 0 on Anthropic-shaped usage so
# downstream consumers can rely on them being present.
if "input_tokens" in usage:
usage.setdefault("cache_read_input_tokens", 0)
usage.setdefault("cache_creation_input_tokens", 0)
try:
cache_read = int(usage.get("cache_read_input_tokens") or 0)
cache_creation = int(usage.get("cache_creation_input_tokens") or 0)
except (TypeError, ValueError):
return
extra = cache_read + cache_creation
if extra <= 0:
return
if "input_tokens" in usage:
try:
usage["input_tokens"] = int(usage.get("input_tokens") or 0) + extra
except (TypeError, ValueError):
pass
if "prompt_tokens" in usage:
try:
usage["prompt_tokens"] = (
int(usage.get("prompt_tokens") or 0) + extra
)
except (TypeError, ValueError):
pass
def _apply_provider_field(self, response_json: object) -> None:
"""Stamp the routstr ``provider`` field onto an upstream response payload.
Format is ``"<provider_type>:<upstream_provider>"`` when the upstream
already reported its own provider (e.g. OpenRouter returns
``"provider": "Fireworks"``), otherwise just ``"<provider_type>"``
for direct upstreams.
Idempotent: re-stamping an already-stamped payload must not nest the
prefix repeatedly (e.g. never ``"anthropic:anthropic"``). This matters
because streaming paths can apply the field more than once per chunk.
"""
if not isinstance(response_json, dict):
return
provider_type = (self.provider_type or "").strip()
existing = response_json.get("provider")
existing_str = existing.strip() if isinstance(existing, str) else ""
if not existing_str:
response_json["provider"] = provider_type
return
# Already stamped by a previous pass — leave it untouched.
if existing_str == provider_type or existing_str.startswith(
f"{provider_type}:"
):
response_json["provider"] = existing_str
return
response_json["provider"] = f"{provider_type}:{existing_str}"
def inject_cost_metadata(
self,
response_json: dict,
cost_data: CostData | MaxCostData | dict,
key: ApiKey,
) -> None:
"""Unifies the injection of cost and usage metadata across all completion types."""
self._apply_provider_field(response_json)
if isinstance(cost_data, dict):
total_msats = cost_data.get("total_msats", 0)
total_usd = cost_data.get("total_usd", 0.0)
cost_dict = cost_data
else:
total_msats = cost_data.total_msats
total_usd = cost_data.total_usd
cost_dict = cost_data.dict()
sats_cost = total_msats // 1000
# Inject into top-level usage block (OpenAI/Anthropic style)
if "usage" in response_json:
response_json["usage"]["cost"] = total_usd
response_json["usage"]["cost_sats"] = sats_cost
response_json["usage"]["remaining_balance_msats"] = key.balance
self._fold_cache_into_input_tokens(response_json["usage"])
# Inject into Anthropic nested usage block if present
if (
"message" in response_json
and isinstance(response_json["message"], dict)
and "usage" in response_json["message"]
):
response_json["message"]["usage"]["sats_cost"] = sats_cost
self._fold_cache_into_input_tokens(response_json["message"]["usage"])
# Unified Routstr metadata
response_json["metadata"] = response_json.get("metadata", {})
response_json["metadata"]["routstr"] = {
"cost": cost_dict,
"sats_cost": sats_cost,
"remaining_balance_msats": key.balance,
}
# Legacy/Compatibility fields
response_json["cost"] = cost_dict.copy()
response_json["cost"]["sats_cost"] = sats_cost
response_json["cost"]["remaining_balance_msats"] = key.balance
def prepare_headers(self, request_headers: dict) -> dict:
"""Prepare headers for upstream request by removing proxy-specific headers and adding authentication.
Args:
request_headers: Original request headers from the client
Returns:
Headers dict ready for upstream forwarding with authentication added
"""
logger.debug(
"Preparing upstream headers",
extra={
"original_headers_count": len(request_headers),
"has_upstream_api_key": bool(self.api_key),
},
)
headers = dict(request_headers)
removed_headers = []
for header in [
"host",
"content-length",
"refund-lnurl",
"key-expiry-time",
"x-cashu",
]:
if headers.pop(header, None) is not None:
removed_headers.append(header)
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
if headers.pop("authorization", None) is not None:
removed_headers.append("authorization (replaced with upstream key)")
else:
for auth_header in ["Authorization", "authorization"]:
if headers.pop(auth_header, None) is not None:
removed_headers.append(auth_header)
for header in ["authorization", "accept-encoding"]:
if headers.pop(header, None) is not None:
removed_headers.append(f"{header} (replaced with routstr-safe version)")
# Explicitly define the list of supported compression encodings
headers["accept-encoding"] = "gzip, deflate, br, identity"
logger.debug(
"Headers prepared for upstream",
extra={
"final_headers_count": len(headers),
"removed_headers": removed_headers,
"added_upstream_auth": bool(self.api_key),
},
)
return headers
def prepare_params(
self, path: str, query_params: Mapping[str, str] | None
) -> Mapping[str, str]:
"""Prepare query parameters for upstream request.
Base implementation passes through query params unchanged. Override in subclasses for provider-specific params.
Args:
path: Request path
query_params: Original query parameters from the client
Returns:
Query parameters dict ready for upstream forwarding
"""
return query_params or {}
def transform_model_name(self, model_id: str) -> str:
"""Transform model ID for this provider's API format.
Base implementation returns model_id unchanged. Override in subclasses for provider-specific transformations.
Args:
model_id: Model identifier (may include provider prefix)
Returns:
Transformed model ID for this provider
"""
return model_id
def normalize_request_path(self, path: str, model_obj: Model | None = None) -> str:
"""Normalize request path before forwarding to upstream."""
if path.startswith("v1/"):
return path.replace("v1/", "", 1)
return path
def get_request_base_url(self, path: str, model_obj: Model | None = None) -> str:
"""Get upstream base URL used when building forwarding URL."""
return self.base_url.rstrip("/")
def build_request_url(self, path: str, model_obj: Model | None = None) -> str:
"""Build full upstream URL from normalized path."""
clean_path = path.lstrip("/")
return f"{self.get_request_base_url(path, model_obj)}/{clean_path}"
def prepare_responses_request_body(
self, body: bytes | None, model_obj: Model
) -> bytes | None:
"""Transform request body for Responses API specific requirements.
Handles Responses API specific transformations while maintaining model name transforms.
Args:
body: Original request body bytes
model_obj: Model object containing the original model information
Returns:
Transformed request body bytes
"""
if not body:
return body
try:
data = json.loads(body)
if isinstance(data, dict):
# Handle model transformation in various locations
if "model" in data:
original_model = model_obj.id
transformed_model = self.transform_model_name(original_model)
data["model"] = transformed_model
logger.debug(
"Transformed model name in Responses API request",
extra={
"original": original_model,
"transformed": transformed_model,
"provider": self.provider_type or self.base_url,
},
)
# Handle model in input field (alternative format)
if (
"input" in data
and isinstance(data["input"], dict)
and "model" in data["input"]
):
original_model = model_obj.id
transformed_model = self.transform_model_name(original_model)
data["input"]["model"] = transformed_model
# Ensure proper Responses API structure
# Add any Responses-specific transformations here
return json.dumps(data).encode()
except Exception as e:
logger.debug(
"Could not transform Responses API request body",
extra={
"error": str(e),
"provider": self.provider_type or self.base_url,
},
)
return body
def _upstream_accepts_cache_control(self) -> bool:
"""True when this upstream accepts explicit ``cache_control`` markers.
Only OpenRouter (documents Anthropic + Alibaba explicit caching) and the
native Anthropic API accept the markers. Stamping them toward an
automatic-cache or non-supporting upstream risks a 400, so injection is
confined to these. Base URL is also checked so an OpenRouter endpoint
configured through the generic provider is still recognised.
"""
if self.provider_type in ("openrouter", "anthropic"):
return True
return "openrouter.ai" in (self.base_url or "")
def prepare_request_body(
self, body: bytes | None, model_obj: Model
) -> bytes | None:
"""Transform request body for provider-specific requirements.
Automatically transforms model names and, for streaming chat
completions, opts the upstream into emitting per-chunk ``usage``
so cost tracking can read real token counts instead of falling
back to ``MaxCostData``.
Args:
body: Original request body bytes
Returns:
Transformed request body bytes
"""
if not body:
return body
try:
data = json.loads(body)
except Exception as e:
logger.debug(
"Could not parse request body for transformation",
extra={
"error": str(e),
"provider": self.provider_type or self.base_url,
},
)
return body
if not isinstance(data, dict):
return body
changed = False
if "model" in data:
original_model = model_obj.id
transformed_model = self.transform_model_name(original_model)
if data["model"] != transformed_model:
data["model"] = transformed_model
logger.debug(
"Transformed model name in request",
extra={
"original": original_model,
"transformed": transformed_model,
"provider": self.provider_type or self.base_url,
},
)
changed = True
# OpenAI-compatible streaming responses omit ``usage`` unless the
# request sets ``stream_options.include_usage = true``. Without it
# we can't reconcile token counts at end of stream and the
# request gets billed at max-cost with zero tokens. Discriminate
# chat-completions-shaped requests by the ``messages`` field so we
# don't poke unrelated endpoints.
if (
data.get("stream") is True
and "messages" in data
and isinstance(data.get("messages"), list)
):
existing = data.get("stream_options")
merged = dict(existing) if isinstance(existing, dict) else {}
if merged.get("include_usage") is not True:
merged["include_usage"] = True
data["stream_options"] = merged
changed = True
# Explicit-cache models (Anthropic Claude, Alibaba Qwen / deepseek-v3.2)
# cache nothing without ``cache_control`` markers in the body. Clients
# that don't recognise a routstr URL as one of these never send them, so
# caching silently never engages over routstr even though it works
# against OpenRouter directly. Stamp the standard breakpoints so caching
# works by default, deferring to any client-set markers. Gated to
# upstreams that accept the markers (OpenRouter / Anthropic) so they
# never leak to an automatic-cache provider that would reject them.
if (
"messages" in data
and isinstance(data.get("messages"), list)
and self._upstream_accepts_cache_control()
and is_explicit_cache_model(
model_obj.id,
model_obj.forwarded_model_id,
model_obj.canonical_slug,
)
):
if inject_anthropic_cache_breakpoints(data):
changed = True
if changed:
return json.dumps(data).encode()
return body
def _extract_upstream_error_message(
self, body_bytes: bytes
) -> tuple[str, str | None]:
"""Extract error message and code from upstream error response body.
Args:
body_bytes: Raw response body bytes from upstream
Returns:
Tuple of (error_message, error_code), where error_code may be None
"""
message: str = "Upstream request failed"
upstream_code: str | None = None
if not body_bytes:
return message, upstream_code
try:
data = json.loads(body_bytes)
if isinstance(data, dict):
err = data.get("error")
if isinstance(err, dict):
raw_msg = (
err.get("message") or err.get("detail") or err.get("error")
)
if isinstance(raw_msg, (str, int, float)):
message = str(raw_msg)
upstream_code_raw = err.get("code") or err.get("type")
if isinstance(upstream_code_raw, (str, int, float)):
upstream_code = str(upstream_code_raw)
elif "message" in data and isinstance(
data["message"], (str, int, float)
):
message = str(data["message"]) # type: ignore[arg-type]
elif "detail" in data and isinstance(data["detail"], (str, int, float)):
message = str(data["detail"]) # type: ignore[arg-type]
except Exception:
preview = body_bytes.decode("utf-8", errors="ignore").strip()
if preview:
message = preview[:500]
return redact_org_ids(message), upstream_code
async def on_upstream_error_redirect(
self, status_code: int, error_message: str
) -> None:
"""Hook called when the proxy redirects to another provider due to an error.
Subclasses can implement this to perform actions like disabling the provider
if it's out of balance.
Args:
status_code: The HTTP status code returned by the upstream
error_message: The error message extracted from the upstream response
"""
pass
async def forward_upstream_error_response(
self,
request: Request,
path: str,
upstream_response: httpx.Response,
model_id: str | None = None,
) -> Response:
"""Log upstream errors and forward the response in a JSON envelope."""
status_code = upstream_response.status_code
headers = dict(upstream_response.headers)
content_type = headers.get("content-type") or headers.get("Content-Type", "")
upstream_request_id = (
headers.get("request-id")
or headers.get("Request-Id")
or headers.get("x-request-id")
or headers.get("X-Request-Id")
or headers.get("anthropic-request-id")
or headers.get("openai-request-id")
)
body_read_error = None
try:
body_bytes = await upstream_response.aread()
except Exception as exc:
body_bytes = b""
body_read_error = f"{type(exc).__name__}: {exc}"
# ``message`` is already redacted by ``_extract_upstream_error_message``;
# the raw body preview is redacted here before it reaches logs or the
# forwarded envelope so provider account identifiers never leak.
message, upstream_code = self._extract_upstream_error_message(body_bytes)
body_preview = redact_org_ids(
body_bytes.decode("utf-8", errors="ignore").strip()[:500]
)
is_json_body = _is_json_content_type(content_type)
# Classify upstream rate-limit failures into a stable, structured error.
rate_limit = classify_rate_limit(status_code, message, headers)
error_code: str | int = upstream_code or status_code
error_details: dict[str, object] | None = None
if rate_limit is not None:
error_code = UPSTREAM_RATE_LIMIT
error_details = rate_limit.as_details()
logger.warning(
"Upstream %s returned %s for model=%s path=%s: %s",
self.provider_type,
status_code,
model_id or "unknown",
path,
(message or body_preview or "<empty>")[:300],
extra={
"path": path,
"provider": self.provider_type,
"model": model_id or "unknown",
"upstream_status": status_code,
"upstream_code": upstream_code,
"error_code": error_code,
"upstream_content_type": content_type,
"upstream_request_id": upstream_request_id,
"message_preview": message[:200],
"body_preview": body_preview,
"body_read_error": body_read_error,
"method": request.method,
"json_normalized": not is_json_body,
},
)
for header_name in (
"content-length",
"Content-Length",
"transfer-encoding",
"Transfer-Encoding",
"content-encoding",
"Content-Encoding",
"connection",
"Connection",
"keep-alive",
"Keep-Alive",
"proxy-authenticate",
"Proxy-Authenticate",
"proxy-authorization",
"Proxy-Authorization",
"te",
"TE",
"trailer",
"Trailer",
"upgrade",
"Upgrade",
):
headers.pop(header_name, None)
# Propagate a usable retry hint to the caller when the upstream supplied
# one but did not echo a ``Retry-After`` header. RFC 7231 delta-seconds
# is an integer, so round sub-second hints up to a usable ``1``.
if (
rate_limit is not None
and rate_limit.retry_after_seconds is not None
and "retry-after" not in {k.lower() for k in headers}
):
headers["Retry-After"] = str(max(1, math.ceil(rate_limit.retry_after_seconds)))
if is_json_body:
if not content_type:
headers.pop("content-type", None)
headers.pop("Content-Type", None)
media_type = content_type or None
# Re-serialise the body with organization IDs stripped. The narrow
# ``org-*`` regex preserves the surrounding JSON structure.
redacted_text = redact_org_ids(body_bytes.decode("utf-8", errors="ignore"))
redacted_body = redacted_text.encode()
# Surface the stable rate-limit classification on the forwarded
# body so callers can switch on ``error.code`` without parsing the
# provider-specific message. Fall back to the redacted bytes if the
# body is not a JSON object with an ``error`` mapping.
if rate_limit is not None:
try:
parsed = json.loads(redacted_text)
err = parsed.get("error") if isinstance(parsed, dict) else None
if isinstance(err, dict):
err["code"] = UPSTREAM_RATE_LIMIT
err["details"] = error_details
redacted_body = json.dumps(parsed).encode()
except (ValueError, AttributeError):
pass
return Response(
content=redacted_body,
status_code=status_code,
headers=headers,
media_type=media_type,
)
# Non-JSON upstream error (HTML, plain text, empty, ...). Wrap it in
# the standard JSON envelope so callers don't need a second parser.
for header_name in ("content-type", "Content-Type"):
headers.pop(header_name, None)
error_obj: dict[str, object] = {
"message": message or "Upstream returned a non-JSON error response",
"type": "upstream_error",
"code": error_code,
"upstream_status": status_code,
"upstream_content_type": content_type or None,
"upstream_body_preview": body_preview or None,
}
if error_details is not None:
error_obj["details"] = error_details
envelope = {
"error": error_obj,
"request_id": getattr(request.state, "request_id", None),
}
return Response(
content=json.dumps(envelope).encode(),
status_code=status_code,
headers=headers,
media_type="application/json",
)
async def handle_streaming_chat_completion(
self,
response: httpx.Response,
key: ApiKey,
max_cost_for_model: int,
background_tasks: BackgroundTasks,
requested_model: str | None = None,
) -> StreamingResponse:
"""Handle streaming chat completion responses with token usage tracking and cost adjustment.
Args:
response: Streaming response from upstream
key: API key for the authenticated user
max_cost_for_model: Maximum cost deducted upfront for the model
Returns:
StreamingResponse with cost data injected at the end
"""
logger.debug(
"Processing streaming chat completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"response_status": response.status_code,
},
)
async def stream_with_cost(
max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]:
usage_finalized: bool = False
last_model_seen: str | None = None
usage_chunk_data: dict | None = None
done_seen: bool = False
async def finalize_db_only() -> None:
nonlocal usage_finalized
if usage_finalized:
return
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
return
try:
await adjust_payment_for_tokens(
fresh_key,
{"model": last_model_seen or "unknown", "usage": None},
new_session,
max_cost_for_model,
)
usage_finalized = True
except Exception:
pass
def _process_event(
raw_event: bytes, final: bool = False
) -> Iterator[bytes]:
"""Process one complete SSE event block (lines up to a blank line).
Handles arbitrary upstream framing across every supported
provider:
* ``data:`` lines are gathered and concatenated per the SSE
spec, so a payload split across network chunks is reassembled
before parsing.
* Comment/keepalive lines (those beginning with ``:`` such as
OpenRouter's ``: OPENROUTER PROCESSING``) are dropped. They
carry no JSON and forwarding them downstream breaks naive SSE
clients; the keepalive only matters for the upstream hop.
* Other SSE fields (``event:``/``id:``/``retry:``) are preserved
and kept attached to the event's ``data:`` line, which the
OpenAI Responses API and Anthropic-style streams rely on.
* ``[DONE]`` is swallowed so it can be re-emitted exactly once at
end of stream.
"""
nonlocal last_model_seen, usage_chunk_data, done_seen
event = raw_event.strip(b"\r\n")
if not event:
return
field_lines: list[bytes] = []
data_lines: list[bytes] = []
for line in event.split(b"\n"):
line = line.rstrip(b"\r")
if line.startswith(b"data:"):
# Strip the field name and a single optional leading space.
data_lines.append(line[len(b"data:") :].lstrip(b" "))
elif line.startswith(b":"):
# SSE comment / keepalive - drop.
continue
elif line:
# Other SSE field (event:/id:/retry:) - preserve in order.
field_lines.append(line)
if not data_lines:
return
data = b"\n".join(data_lines)
if not data.strip():
return
# Re-emit preserved SSE fields immediately before the data line so
# event/data framing stays intact (single trailing newline each;
# the blank-line terminator is appended to the data line below).
prefix = b"".join(fl + b"\n" for fl in field_lines)
if data.strip() == b"[DONE]":
done_seen = True
return
try:
obj = json.loads(data)
except Exception:
obj = None
if isinstance(obj, dict):
self._apply_provider_field(obj)
if obj.get("model"):
last_model_seen = str(obj.get("model"))
if requested_model:
obj["model"] = requested_model
if (
"id" not in obj
or not isinstance(obj["id"], str)
or obj["id"] == "existing-id"
):
if not hasattr(self, "_current_stream_id"):
self._current_stream_id = f"chatcmpl-{uuid.uuid4()}"
obj["id"] = self._current_stream_id
if isinstance(obj.get("usage"), dict):
# Capture usage for end-of-stream cost reconciliation.
# Some models (e.g. Gemini thinking models over the
# OpenAI-compat endpoint) attach ``usage`` to the SAME
# chunk that carries the final content/finish_reason
# rather than sending a separate ``choices: []`` usage
# chunk. Only swallow the chunk when it is a pure usage
# chunk (no choices); otherwise the content would be
# silently dropped and the client would receive no
# assistant message at all.
if obj.get("choices"):
# Capture usage (with model) for the cost trailer,
# but with choices stripped so the trailer never
# re-emits this chunk's content.
usage_chunk_data = {
k: v for k, v in obj.items() if k != "choices"
}
usage_chunk_data["choices"] = []
# Forward the content now, without usage, so token
# usage is reported exactly once (in the trailer).
forward = {k: v for k, v in obj.items() if k != "usage"}
yield (
prefix
+ b"data: "
+ json.dumps(forward).encode()
+ b"\n\n"
)
return
usage_chunk_data = obj
return
yield prefix + b"data: " + json.dumps(obj).encode() + b"\n\n"
else:
if final:
# Final flush of a truncated tail: the upstream closed
# mid-event, so ``data`` is incomplete JSON. Emitting it
# as a ``data:`` frame would hand the client invalid
# JSON (the "unexpected token" parse error). Drop it.
return
# Non-JSON data payload (partial fragment already reassembled
# by buffering, or a provider control string). Re-prefix each
# line so multi-line ``data`` stays valid SSE framing - a bare
# second line would otherwise reach the client without its
# ``data:`` field and break naive parsers.
body = b"".join(
b"data: " + ln + b"\n" for ln in data.split(b"\n")
)
yield prefix + body + b"\n"
try:
# Buffer bytes across network chunks and dispatch only on the SSE
# event delimiter (a blank line). ``aiter_bytes`` yields arbitrary
# byte boundaries, so a single event's JSON can span chunks and
# multiple events can arrive together; buffering makes parsing
# boundary-independent for every provider.
buffer = b""
async for chunk in response.aiter_bytes():
# Normalize the *joined* buffer, not each chunk in
# isolation: a CRLF event delimiter can straddle two
# ``aiter_bytes`` chunks (``...\r`` then ``\n...``). A
# per-chunk replace would leave a stray ``\r`` and the
# ``\n\n`` split would miss the delimiter, merging two
# events into one frame and breaking SSE clients.
buffer = (buffer + chunk).replace(b"\r\n", b"\n")
while b"\n\n" in buffer:
raw_event, buffer = buffer.split(b"\n\n", 1)
for out in _process_event(raw_event):
yield out
# Flush any trailing event that lacked a final blank line.
if buffer.strip():
for out in _process_event(buffer, final=True):
yield out
async with create_session() as session:
fresh_key = await session.get(key.__class__, key.hashed_key)
if fresh_key:
cost_data: dict
try:
adjustment_input = (
usage_chunk_data
if usage_chunk_data is not None
else {
"model": last_model_seen or "unknown",
"usage": None,
}
)
cost_data = await adjust_payment_for_tokens(
fresh_key,
adjustment_input,
session,
max_cost_for_model,
)
usage_finalized = True
except Exception as e:
logger.exception(
"Error during usage finalization",
extra={
"key_hash": key.hashed_key[:8] + "...",
"error": str(e),
},
)
# Fall back so we still emit a non-zero sats cost downstream.
cost_data = {
"base_msats": 0,
"input_msats": 0,
"output_msats": 0,
"total_msats": 0,
"total_usd": 0.0,
"input_tokens": 0,
"output_tokens": 0,
}
if usage_chunk_data is None:
if not hasattr(self, "_current_stream_id"):
self._current_stream_id = (
f"chatcmpl-{uuid.uuid4()}"
)
usage_chunk_data = {
"id": self._current_stream_id,
"object": "chat.completion.chunk",
"model": last_model_seen or "unknown",
"choices": [],
"usage": {
"prompt_tokens": cost_data.get(
"input_tokens", 0
),
"completion_tokens": cost_data.get(
"output_tokens", 0
),
"total_tokens": cost_data.get(
"input_tokens", 0
)
+ cost_data.get("output_tokens", 0),
},
}
try:
self.inject_cost_metadata(
usage_chunk_data, cost_data, fresh_key
)
except Exception:
logger.exception(
"Failed to inject cost metadata into streaming chunk",
extra={
"key_hash": key.hashed_key[:8] + "...",
},
)
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
if done_seen:
yield b"data: [DONE]\n\n"
except Exception as stream_error:
logger.warning(
"Streaming interrupted; finalizing in background",
extra={
"error": str(stream_error),
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
finally:
if not usage_finalized:
# Create a background task to ensure finalization happens
# even if the generator is closed early
background_tasks.add_task(finalize_db_only)
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
response_headers.pop("content-encoding", None)
response_headers.pop("content-length", None)
return StreamingResponse(
stream_with_cost(max_cost_for_model),
status_code=response.status_code,
headers=response_headers,
)
async def handle_non_streaming_chat_completion(
self,
response: httpx.Response,
key: ApiKey,
session: AsyncSession,
deducted_max_cost: int,
requested_model: str | None = None,
) -> Response:
"""Handle non-streaming chat completion responses with token usage tracking and cost adjustment.
Args:
response: Response from upstream
key: API key for the authenticated user
session: Database session for updating balance
deducted_max_cost: Maximum cost deducted upfront
Returns:
Response with cost data added to JSON body
"""
logger.debug(
"Processing non-streaming chat completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"response_status": response.status_code,
},
)
content: bytes | None = None
try:
content = await response.aread()
response_json = json.loads(content)
self._apply_provider_field(response_json)
logger.debug(
"Parsed response JSON",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": response_json.get("model", "unknown"),
"has_usage": "usage" in response_json,
},
)
if requested_model:
response_json["model"] = requested_model
if "id" not in response_json or not isinstance(response_json["id"], str):
response_json["id"] = f"chatcmpl-{uuid.uuid4()}"
cost_data = await adjust_payment_for_tokens(
key,
response_json,
session,
deducted_max_cost,
)
await session.refresh(key)
remaining_balance_msats = key.balance
# Merge cost into usage for OpenCode
if "usage" in response_json:
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
response_json["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
self._fold_cache_into_input_tokens(response_json["usage"])
# Keep detailed cost
response_json["metadata"] = response_json.get("metadata", {})
response_json["metadata"]["routstr"] = {"cost": cost_data}
response_json["metadata"]["routstr"]["cost"]["sats_cost"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["metadata"]["routstr"]["cost"]["remaining_balance_msats"] = (
remaining_balance_msats
)
response_json["cost"] = cost_data
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
logger.debug(
"Payment adjustment completed for non-streaming",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"model": response_json.get("model", "unknown"),
"balance_after_adjustment": key.balance,
},
)
allowed_headers = {
"content-type",
"cache-control",
"date",
"vary",
"access-control-allow-origin",
"access-control-allow-methods",
"access-control-allow-headers",
"access-control-allow-credentials",
"access-control-expose-headers",
"access-control-max-age",
}
response_headers = {
k: v
for k, v in response.headers.items()
if k.lower() in allowed_headers
}
if requested_model:
response_json["model"] = requested_model
return Response(
content=json.dumps(response_json).encode(),
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except json.JSONDecodeError as e:
logger.error(
"Failed to parse JSON from upstream response",
extra={
"error": str(e),
"key_hash": key.hashed_key[:8] + "...",
"content_preview": content[:200].decode(errors="ignore")
if content
else "empty",
},
)
raise
except Exception as e:
logger.error(
"Error processing non-streaming chat completion",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
async def handle_streaming_responses_completion(
self,
response: httpx.Response,
key: ApiKey,
max_cost_for_model: int,
requested_model: str | None = None,
) -> StreamingResponse:
"""Handle streaming Responses API responses with token usage tracking and cost adjustment.
Args:
response: Streaming response from upstream
key: API key for the authenticated user
max_cost_for_model: Maximum cost deducted upfront for the model
Returns:
StreamingResponse with cost data injected at the end
"""
logger.debug(
"Processing streaming Responses API completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"response_status": response.status_code,
},
)
async def stream_with_responses_cost(
max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]:
usage_finalized: bool = False
last_model_seen: str | None = None
reasoning_tokens: int = 0
usage_chunk_data: dict | None = None
done_seen: bool = False
async def finalize_db_only() -> None:
nonlocal usage_finalized
if usage_finalized:
return
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
return
try:
await adjust_payment_for_tokens(
fresh_key,
{"model": last_model_seen or "unknown", "usage": None},
new_session,
max_cost_for_model,
)
usage_finalized = True
except Exception:
pass
def _process_event(
raw_event: bytes, final: bool = False
) -> Iterator[bytes]:
"""Process one complete SSE event block for the Responses API.
Buffers full events (delimited by a blank line) so parsing is
boundary-independent, gathers ``data:`` lines, drops comment/
keepalive lines (e.g. OpenRouter's ``: OPENROUTER PROCESSING``),
and preserves ``event:``/``id:`` fields attached to their data
line so Responses API event framing stays intact.
"""
nonlocal last_model_seen, usage_chunk_data, done_seen
nonlocal reasoning_tokens
event = raw_event.strip(b"\r\n")
if not event:
return
field_lines: list[bytes] = []
data_lines: list[bytes] = []
for line in event.split(b"\n"):
line = line.rstrip(b"\r")
if line.startswith(b"data:"):
data_lines.append(line[len(b"data:") :].lstrip(b" "))
elif line.startswith(b":"):
# SSE comment / keepalive - drop.
continue
elif line:
# Preserve event:/id:/retry: (Responses API event names).
field_lines.append(line)
if not data_lines:
return
data = b"\n".join(data_lines)
if not data.strip():
return
prefix = b"".join(fl + b"\n" for fl in field_lines)
if data.strip() == b"[DONE]":
done_seen = True
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
obj = None
if isinstance(obj, dict):
self._apply_provider_field(obj)
if obj.get("model"):
last_model_seen = str(obj.get("model"))
if requested_model:
obj["model"] = requested_model
# Track reasoning tokens for Responses API
if usage := obj.get("usage", {}):
if isinstance(usage, dict) and "reasoning_tokens" in usage:
reasoning_tokens += usage.get("reasoning_tokens", 0)
# Responses API usage is in response.completed/incomplete events
chunk_type = obj.get("type", "")
if chunk_type in (
"response.completed",
"response.incomplete",
):
usage_chunk_data = obj
return
yield prefix + b"data: " + json.dumps(obj).encode() + b"\n\n"
else:
if final:
# Final flush of a truncated tail: upstream closed
# mid-event, so ``data`` is incomplete JSON. Dropping it
# avoids handing the client an invalid ``data:`` frame.
return
# Re-prefix each line so multi-line ``data`` stays valid SSE
# framing for the client.
body = b"".join(
b"data: " + ln + b"\n" for ln in data.split(b"\n")
)
yield prefix + body + b"\n"
try:
# Buffer across network chunks; dispatch only on the SSE event
# delimiter so parsing is independent of byte boundaries.
buffer = b""
async for chunk in response.aiter_bytes():
# Normalize the *joined* buffer, not each chunk in
# isolation: a CRLF event delimiter can straddle two
# ``aiter_bytes`` chunks (``...\r`` then ``\n...``). A
# per-chunk replace would leave a stray ``\r`` and the
# ``\n\n`` split would miss the delimiter, merging two
# events into one frame and breaking SSE clients.
buffer = (buffer + chunk).replace(b"\r\n", b"\n")
while b"\n\n" in buffer:
raw_event, buffer = buffer.split(b"\n\n", 1)
for out in _process_event(raw_event):
yield out
if buffer.strip():
for out in _process_event(buffer, final=True):
yield out
# Always emit a cost-bearing data chunk
async with create_session() as session:
fresh_key = await session.get(key.__class__, key.hashed_key)
if fresh_key:
cost_data: dict
try:
adjustment_input = (
usage_chunk_data
if usage_chunk_data is not None
else {
"model": last_model_seen or "unknown",
"usage": None,
}
)
cost_data = await adjust_payment_for_tokens(
fresh_key,
adjustment_input,
session,
max_cost_for_model,
)
usage_finalized = True
except Exception as e:
logger.exception(
"Error during Responses API usage finalization",
extra={
"key_hash": key.hashed_key[:8] + "...",
"error": str(e),
},
)
cost_data = {
"base_msats": 0,
"input_msats": 0,
"output_msats": 0,
"total_msats": 0,
"total_usd": 0.0,
"input_tokens": 0,
"output_tokens": 0,
}
if usage_chunk_data is None:
usage_chunk_data = {
"type": "response.completed",
"response": {
"model": last_model_seen or "unknown",
"usage": {
"input_tokens": cost_data.get(
"input_tokens", 0
),
"output_tokens": cost_data.get(
"output_tokens", 0
),
"total_tokens": cost_data.get(
"input_tokens", 0
)
+ cost_data.get("output_tokens", 0),
},
},
"usage": {
"input_tokens": cost_data.get(
"input_tokens", 0
),
"output_tokens": cost_data.get(
"output_tokens", 0
),
"total_tokens": cost_data.get(
"input_tokens", 0
)
+ cost_data.get("output_tokens", 0),
},
}
remaining_balance_msats = fresh_key.balance
sats_cost = cost_data.get("total_msats", 0) // 1000
if (
"response" in usage_chunk_data
and isinstance(usage_chunk_data["response"], dict)
and "usage" in usage_chunk_data["response"]
):
usage_chunk_data["response"]["usage"]["cost"] = (
cost_data.get("total_usd", 0.0)
)
usage_chunk_data["response"]["usage"][
"cost_sats"
] = sats_cost
usage_chunk_data["response"]["usage"][
"remaining_balance_msats"
] = remaining_balance_msats
try:
self.inject_cost_metadata(
usage_chunk_data, cost_data, fresh_key
)
except Exception:
logger.exception(
"Failed to inject cost metadata into Responses streaming chunk",
extra={
"key_hash": key.hashed_key[:8] + "...",
},
)
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
if done_seen:
yield b"data: [DONE]\n\n"
except Exception as stream_error:
logger.warning(
"Responses API streaming interrupted; finalizing in background",
extra={
"error": str(stream_error),
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
finally:
if not usage_finalized:
await finalize_db_only()
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
response_headers.pop("content-encoding", None)
response_headers.pop("content-length", None)
return StreamingResponse(
stream_with_responses_cost(max_cost_for_model),
status_code=response.status_code,
headers=response_headers,
)
async def handle_non_streaming_responses_completion(
self,
response: httpx.Response,
key: ApiKey,
session: AsyncSession,
deducted_max_cost: int,
requested_model: str | None = None,
) -> Response:
"""Handle non-streaming Responses API responses with token usage tracking and cost adjustment.
Args:
response: Response from upstream
key: API key for the authenticated user
session: Database session for updating balance
deducted_max_cost: Maximum cost deducted upfront
Returns:
Response with cost data added to JSON body
"""
logger.debug(
"Processing non-streaming Responses API completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"response_status": response.status_code,
},
)
content: bytes | None = None
try:
content = await response.aread()
response_json = json.loads(content)
self._apply_provider_field(response_json)
logger.debug(
"Parsed Responses API response JSON",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": response_json.get("model", "unknown"),
"has_usage": "usage" in response_json,
"has_reasoning_tokens": "usage" in response_json
and isinstance(response_json.get("usage"), dict)
and "reasoning_tokens" in response_json["usage"],
},
)
if requested_model:
response_json["model"] = requested_model
if "id" not in response_json or not isinstance(response_json["id"], str):
response_json["id"] = f"chatcmpl-{uuid.uuid4()}"
cost_data = await adjust_payment_for_tokens(
key,
response_json,
session,
deducted_max_cost,
)
await session.refresh(key)
remaining_balance_msats = key.balance
# Merge cost into usage for OpenCode
if "usage" in response_json:
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
response_json["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
self._fold_cache_into_input_tokens(response_json["usage"])
# Keep detailed cost
response_json["metadata"] = response_json.get("metadata", {})
response_json["metadata"]["routstr"] = {"cost": cost_data}
response_json["metadata"]["routstr"]["cost"]["sats_cost"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["metadata"]["routstr"]["cost"]["remaining_balance_msats"] = (
remaining_balance_msats
)
response_json["cost"] = cost_data
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
logger.debug(
"Payment adjustment completed for non-streaming Responses API",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"model": response_json.get("model", "unknown"),
"balance_after_adjustment": key.balance,
},
)
allowed_headers = {
"content-type",
"cache-control",
"date",
"vary",
"access-control-allow-origin",
"access-control-allow-methods",
"access-control-allow-headers",
"access-control-allow-credentials",
"access-control-expose-headers",
"access-control-max-age",
}
response_headers = {
k: v
for k, v in response.headers.items()
if k.lower() in allowed_headers
}
if requested_model:
response_json["model"] = requested_model
return Response(
content=json.dumps(response_json).encode(),
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except json.JSONDecodeError as e:
logger.error(
"Failed to parse JSON from upstream Responses API response",
extra={
"error": str(e),
"key_hash": key.hashed_key[:8] + "...",
"content_preview": content[:200].decode(errors="ignore")
if content
else "empty",
},
)
raise
except Exception as e:
logger.error(
"Error processing non-streaming Responses API completion",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
async def _finalize_generic_streaming_payment(
self, key_hash: str, max_cost: int, path: str
) -> None:
"""Background task to finalize payment for generic streaming requests."""
async with create_session() as session:
key = await session.get(ApiKey, key_hash)
if not key:
logger.warning(
"Key not found during background payment finalization",
extra={"key_hash": key_hash[:8] + "..."},
)
return
try:
# Finalize with "unknown" model and no usage to release reservation/charge max cost
await adjust_payment_for_tokens(
key,
{"model": "unknown", "usage": None},
session,
max_cost,
)
logger.debug(
"Finalized generic streaming payment in background",
extra={
"path": path,
"key_hash": key_hash[:8] + "...",
},
)
except Exception as e:
logger.error(
"Error finalizing generic streaming payment in background",
extra={
"error": str(e),
"key_hash": key_hash[:8] + "...",
"path": path,
},
)
async def handle_streaming_messages_completion(
self,
response: httpx.Response,
key: ApiKey,
max_cost_for_model: int,
requested_model: str | None = None,
) -> StreamingResponse:
async def stream_with_cost(
max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]:
stored_chunks: list[bytes] = []
usage_finalized: bool = False
last_model_seen: str | None = None
input_tokens: int = 0
output_tokens: int = 0
cache_read_input_tokens: int = 0
cache_creation_input_tokens: int = 0
total_cost: float = 0.0
input_cost: float = 0.0
output_cost: float = 0.0
def _coerce_usd(value: object) -> float:
if value is None or isinstance(value, bool):
return 0.0
if not isinstance(value, (int, float, str)):
return 0.0
try:
return max(0.0, float(value))
except (TypeError, ValueError):
return 0.0
def _absorb_usd(usage_or_root: dict) -> None:
nonlocal total_cost, input_cost, output_cost
cd = usage_or_root.get("cost_details")
if isinstance(cd, dict):
total_cost = max(
total_cost,
_coerce_usd(cd.get("total_cost")),
)
input_cost = max(
input_cost,
_coerce_usd(cd.get("input_cost")),
)
output_cost = max(
output_cost,
_coerce_usd(cd.get("output_cost")),
)
for field in ("total_cost", "cost"):
total_cost = max(
total_cost, _coerce_usd(usage_or_root.get(field))
)
async def finalize_without_usage() -> bytes | None:
nonlocal usage_finalized
if usage_finalized:
return None
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
usage_finalized = True
return None
try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens(
fresh_key,
fallback,
new_session,
max_cost_for_model,
)
usage_finalized = True
return f"event: cost\ndata: {json.dumps({'cost': cost_data})}\n\n".encode()
except Exception:
usage_finalized = True
return None
try:
async for chunk in response.aiter_bytes():
stored_chunks.append(chunk)
try:
decoded_chunk = chunk.decode("utf-8", errors="ignore")
modified_lines = []
changed = False
for line in decoded_chunk.split("\n"):
if line.startswith("data: "):
try:
data = json.loads(line[6:])
if isinstance(data, dict):
msg = data.get("message", {})
if msg and msg.get("model"):
last_model_seen = str(msg.get("model"))
provider_added = (
"provider" not in data
)
self._apply_provider_field(data)
if requested_model:
# Apply requested_model override
model_updated = False
if msg:
msg["model"] = requested_model
model_updated = True
if data.get("model"):
data["model"] = requested_model
model_updated = True
if model_updated or provider_added:
line = "data: " + json.dumps(data)
changed = True
elif provider_added:
line = "data: " + json.dumps(data)
changed = True
if usage := msg.get("usage"):
input_tokens += usage.get("input_tokens", 0)
output_tokens += usage.get(
"output_tokens", 0
)
# Anthropic's `message_start.usage`
# carries the cumulative cache
# snapshot for the prompt — pick
# the max() so subsequent
# `message_delta.usage` events
# (which only restate the same
# numbers) don't double-count.
cache_read_input_tokens = max(
cache_read_input_tokens,
int(
usage.get(
"cache_read_input_tokens", 0
)
or 0
),
)
cache_creation_input_tokens = max(
cache_creation_input_tokens,
int(
usage.get(
"cache_creation_input_tokens",
0,
)
or 0
),
)
_absorb_usd(usage)
if usage := data.get("usage"):
input_tokens += usage.get("input_tokens", 0)
output_tokens += usage.get(
"output_tokens", 0
)
cache_read_input_tokens = max(
cache_read_input_tokens,
int(
usage.get(
"cache_read_input_tokens", 0
)
or 0
),
)
cache_creation_input_tokens = max(
cache_creation_input_tokens,
int(
usage.get(
"cache_creation_input_tokens",
0,
)
or 0
),
)
_absorb_usd(usage)
# Some upstreams attach cost fields at
# the event root rather than nested
# under `usage`.
_absorb_usd(data)
except json.JSONDecodeError:
pass
modified_lines.append(line)
if changed:
yield "\n".join(modified_lines).encode("utf-8")
else:
yield chunk
except Exception:
yield chunk
usage_data = {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cache_read_input_tokens": cache_read_input_tokens,
"cache_creation_input_tokens": cache_creation_input_tokens,
}
messages_dispatch.embed_usd_costs(
usage_data,
total_cost,
input_cost,
output_cost,
)
if (
input_tokens > 0
or output_tokens > 0
or cache_read_input_tokens > 0
or cache_creation_input_tokens > 0
or total_cost > 0
):
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
combined_data = {
"model": last_model_seen or "unknown",
"usage": usage_data,
}
cost_data = await adjust_payment_for_tokens(
fresh_key,
combined_data,
new_session,
max_cost_for_model,
)
self.inject_cost_metadata(
combined_data, cost_data, fresh_key
)
usage_finalized = True
# Emit the full combined_data as the cost
yield f"event: cost\ndata: {json.dumps(combined_data)}\n\n".encode()
except Exception:
pass
if not usage_finalized:
maybe_cost_event = await finalize_without_usage()
if maybe_cost_event is not None:
yield maybe_cost_event
except httpx.ReadError:
if not usage_finalized:
await finalize_without_usage()
# Upstream dropped the connection mid-stream; response already started, swallow silently
except Exception:
if not usage_finalized:
await finalize_without_usage()
raise
finally:
if not usage_finalized:
await finalize_without_usage()
response_headers = dict(response.headers)
response_headers.pop("content-encoding", None)
response_headers.pop("content-length", None)
return StreamingResponse(
stream_with_cost(max_cost_for_model),
status_code=response.status_code,
headers=response_headers,
)
async def handle_non_streaming_messages_completion(
self,
response: httpx.Response,
key: ApiKey,
session: AsyncSession,
deducted_max_cost: int,
path: str,
requested_model: str | None = None,
) -> Response:
try:
content = await response.aread()
response_json = json.loads(content)
if requested_model:
if "model" in response_json:
response_json["model"] = requested_model
if (
"message" in response_json
and isinstance(response_json["message"], dict)
and "model" in response_json["message"]
):
response_json["message"]["model"] = requested_model
if path.endswith("count_tokens") and "usage" not in response_json:
input_tokens = response_json.get("input_tokens", 0)
response_json["usage"] = {"input_tokens": input_tokens}
cost_data = await adjust_payment_for_tokens(
key,
response_json,
session,
deducted_max_cost,
)
self.inject_cost_metadata(response_json, cost_data, key)
allowed_headers = {
"content-type",
"cache-control",
"date",
"vary",
"access-control-allow-origin",
"access-control-allow-methods",
"access-control-allow-headers",
"access-control-allow-credentials",
"access-control-expose-headers",
"access-control-max-age",
}
response_headers = {
k: v
for k, v in response.headers.items()
if k.lower() in allowed_headers
}
return Response(
content=json.dumps(response_json).encode(),
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except Exception:
raise
# ------------------------------------------------------------------
# Litellm /v1/messages dispatch (thin wrappers)
#
# The actual translation logic lives in ``messages_dispatch``. These
# method shims exist so subclasses and tests can keep the original
# provider-bound API.
# ------------------------------------------------------------------
_coerce_litellm_payload = staticmethod(messages_dispatch.coerce_litellm_payload)
_parse_sse_blocks = staticmethod(messages_dispatch.parse_sse_blocks)
_events_from_chunk = staticmethod(messages_dispatch.events_from_chunk)
async def _aggregate_anthropic_events_to_message(
self, iterator: AsyncIterator[Any]
) -> dict:
return await messages_dispatch.aggregate_anthropic_events_to_message(
iterator
)
async def _dispatch_anthropic_messages(
self,
request_body: bytes | None,
model_obj: Model,
*,
log_extra: dict[str, Any] | None = None,
) -> tuple[bool, Any, str | None]:
return await messages_dispatch.dispatch_anthropic_messages(
request_body=request_body,
model_obj=model_obj,
base_url=self.base_url,
api_key=self.api_key,
provider_prefix=self.get_litellm_provider_prefix(),
transform_model_name=self.transform_model_name,
log_extra=log_extra,
)
async def _forward_messages_via_litellm(
self,
request_body: bytes | None,
key: ApiKey,
session: AsyncSession,
max_cost_for_model: int,
model_obj: Model,
) -> Response | StreamingResponse:
"""Translate /v1/messages to upstream chat/completions via litellm.
Used when the upstream provider does not natively serve Anthropic
Messages (i.e. supports_anthropic_messages is False). Cost
tracking and metadata injection mirror the native messages path.
"""
stream, result, requested_model = await self._dispatch_anthropic_messages(
request_body,
model_obj,
log_extra={"key_hash": key.hashed_key[:8] + "..."},
)
if stream:
return self._stream_litellm_messages(
cast(AsyncIterator[Any], result),
key,
max_cost_for_model,
requested_model,
)
response_json = messages_dispatch.coerce_litellm_payload(result)
if requested_model and "model" in response_json:
response_json["model"] = requested_model
cost_data = await adjust_payment_for_tokens(
key,
response_json,
session,
max_cost_for_model,
)
self.inject_cost_metadata(response_json, cost_data, key)
return Response(
content=json.dumps(response_json).encode(),
status_code=200,
media_type="application/json",
)
async def _forward_x_cashu_messages_via_litellm(
self,
request_body: bytes,
amount: int,
unit: str,
max_cost_for_model: int,
model_obj: Model,
mint: str | None = None,
request_id: str | None = None,
) -> Response | StreamingResponse:
"""Dispatch /v1/messages via litellm for x-cashu payments.
Computes cost from upstream usage, refunds the unspent balance via
an X-Cashu response header, and returns the Anthropic-shaped body.
"""
stream, result, requested_model = await self._dispatch_anthropic_messages(
request_body,
model_obj,
log_extra={"payment_unit": unit, "payment_amount": amount},
)
if stream:
return await self._stream_x_cashu_litellm_messages(
cast(AsyncIterator[Any], result),
amount,
unit,
max_cost_for_model,
requested_model,
mint,
request_id,
)
response_json = messages_dispatch.coerce_litellm_payload(result)
self._apply_provider_field(response_json)
if requested_model and "model" in response_json:
response_json["model"] = requested_model
cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model)
if cost_data and "usage" in response_json and isinstance(
response_json["usage"], dict
):
response_json["usage"]["cost_sats"] = cost_data.total_msats // 1000
self._fold_cache_into_input_tokens(response_json["usage"])
response_headers: dict[str, str] = {}
if cost_data:
refund_amount = messages_dispatch.compute_refund(
amount, unit, cost_data.total_msats
)
if refund_amount > 0:
refund_token = await self.send_refund(
refund_amount,
unit,
mint,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
logger.info(
"Refund processed for non-streaming /v1/messages via litellm",
extra={
"refund_amount": refund_amount,
"unit": unit,
"model": response_json.get("model", "unknown"),
},
)
return Response(
content=json.dumps(response_json).encode(),
status_code=200,
headers=response_headers,
media_type="application/json",
)
_compute_refund = staticmethod(messages_dispatch.compute_refund)
def _stream_litellm_messages(
self,
iterator: AsyncIterator[Any],
key: ApiKey,
max_cost_for_model: int,
requested_model: str | None,
) -> StreamingResponse:
"""Re-emit a litellm Anthropic-event iterator as live SSE bytes
with cost reconciliation appended at end of stream."""
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
usage_finalized = False
last_model_seen: str | None = None
input_tokens = 0
output_tokens = 0
cache_read_input_tokens = 0
cache_creation_input_tokens = 0
total_cost = 0.0
input_cost = 0.0
output_cost = 0.0
async def finalize_without_usage() -> bytes | None:
nonlocal usage_finalized
if usage_finalized:
return None
logger.warning(
"Finalizing /v1/messages stream with no usage data — "
"client will be billed at max-cost with zero tokens. "
"Likely cause: upstream omitted `usage` from the SSE "
"stream (check that the request includes "
"`stream_options.include_usage=true` and that the "
"upstream actually emits a final usage chunk).",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": last_model_seen or "unknown",
"provider": self.provider_type or self.base_url,
"max_cost_msats": max_cost_for_model,
},
)
async with create_session() as new_session:
fresh_key = await new_session.get(
key.__class__, key.hashed_key
)
if not fresh_key:
usage_finalized = True
return None
try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens(
fresh_key,
fallback,
new_session,
max_cost_for_model,
)
usage_finalized = True
return (
f"event: cost\ndata: "
f"{json.dumps({'cost': cost_data})}\n\n"
).encode()
except Exception:
usage_finalized = True
return None
try:
async for annotated in messages_dispatch.stream_annotated_events(
iterator, requested_model
):
if annotated.model:
last_model_seen = annotated.model
# Anthropic SSE reports usage cumulatively across
# message_start + message_delta — take the max snapshot
# rather than summing, otherwise input tokens
# double-count.
input_tokens = max(input_tokens, annotated.input_tokens)
output_tokens = max(output_tokens, annotated.output_tokens)
cache_read_input_tokens = max(
cache_read_input_tokens,
annotated.cache_read_input_tokens,
)
cache_creation_input_tokens = max(
cache_creation_input_tokens,
annotated.cache_creation_input_tokens,
)
total_cost = max(total_cost, annotated.total_cost)
input_cost = max(input_cost, annotated.input_cost)
output_cost = max(output_cost, annotated.output_cost)
yield annotated.sse_bytes
if (
input_tokens > 0
or output_tokens > 0
or cache_read_input_tokens > 0
or cache_creation_input_tokens > 0
or total_cost > 0
):
async with create_session() as new_session:
fresh_key = await new_session.get(
key.__class__, key.hashed_key
)
if fresh_key:
try:
rebuilt_usage: dict = {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cache_read_input_tokens": (
cache_read_input_tokens
),
"cache_creation_input_tokens": (
cache_creation_input_tokens
),
}
messages_dispatch.embed_usd_costs(
rebuilt_usage,
total_cost,
input_cost,
output_cost,
)
combined_data: dict = {
"model": last_model_seen or "unknown",
"usage": rebuilt_usage,
}
cost_data = await adjust_payment_for_tokens(
fresh_key,
combined_data,
new_session,
max_cost_for_model,
)
self.inject_cost_metadata(
combined_data, cost_data, fresh_key
)
usage_finalized = True
yield (
f"event: cost\ndata: "
f"{json.dumps({'cost': cost_data})}\n\n"
).encode()
except Exception:
pass
if not usage_finalized:
cost_event = await finalize_without_usage()
if cost_event is not None:
yield cost_event
except Exception:
if not usage_finalized:
await finalize_without_usage()
raise
return StreamingResponse(
stream_with_cost(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
async def _stream_x_cashu_litellm_messages(
self,
iterator: AsyncIterator[Any],
amount: int,
unit: str,
max_cost_for_model: int,
requested_model: str | None,
mint: str | None,
request_id: str | None,
) -> StreamingResponse:
"""Buffer a litellm stream end-to-end, compute cost, then replay.
Note this is **not** true streaming — the full event sequence is
accumulated into memory before a single byte is sent to the
client. The constraint is the ``X-Cashu`` refund token, which must
be set as a response *header* and therefore has to be known before
the response begins. The bearer-key path
(:meth:`_stream_litellm_messages`) avoids this by emitting cost as
a trailing ``event: cost`` SSE message; switching x-cashu to the
same trailing-event contract would let this path stream live, at
the cost of a wire-format change for clients that read ``X-Cashu``
from headers today.
"""
buffered: list[bytes] = []
last_model_seen: str | None = None
input_tokens = 0
output_tokens = 0
cache_read_input_tokens = 0
cache_creation_input_tokens = 0
total_cost = 0.0
input_cost = 0.0
output_cost = 0.0
async for annotated in messages_dispatch.stream_annotated_events(
iterator, requested_model
):
if annotated.model:
last_model_seen = annotated.model
# See _stream_litellm_messages for why this is max() not +=.
input_tokens = max(input_tokens, annotated.input_tokens)
output_tokens = max(output_tokens, annotated.output_tokens)
cache_read_input_tokens = max(
cache_read_input_tokens, annotated.cache_read_input_tokens
)
cache_creation_input_tokens = max(
cache_creation_input_tokens,
annotated.cache_creation_input_tokens,
)
total_cost = max(total_cost, annotated.total_cost)
input_cost = max(input_cost, annotated.input_cost)
output_cost = max(output_cost, annotated.output_cost)
buffered.append(annotated.sse_bytes)
response_headers: dict[str, str] = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
if (
input_tokens == 0
and output_tokens == 0
and cache_read_input_tokens == 0
and cache_creation_input_tokens == 0
and total_cost == 0
):
logger.warning(
"x-cashu /v1/messages stream finished with no usage data "
"— refund cannot be computed and the client effectively "
"pays the full cashu amount. Likely cause: upstream "
"omitted `usage` from the SSE stream.",
extra={
"model": last_model_seen or "unknown",
"provider": self.provider_type or self.base_url,
"amount": amount,
"unit": unit,
},
)
if (
input_tokens > 0
or output_tokens > 0
or cache_read_input_tokens > 0
or cache_creation_input_tokens > 0
or total_cost > 0
):
rebuilt_usage: dict = {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cache_read_input_tokens": cache_read_input_tokens,
"cache_creation_input_tokens": cache_creation_input_tokens,
}
messages_dispatch.embed_usd_costs(
rebuilt_usage, total_cost, input_cost, output_cost
)
response_data: dict = {
"model": last_model_seen or "unknown",
"usage": rebuilt_usage,
}
try:
cost_data = await self.get_x_cashu_cost(
response_data, max_cost_for_model
)
if cost_data:
refund_amount = messages_dispatch.compute_refund(
amount, unit, cost_data.total_msats
)
if refund_amount > 0:
refund_token = await self.send_refund(
refund_amount,
unit,
mint,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
logger.info(
"Refund processed for streaming /v1/messages "
"via litellm",
extra={
"refund_amount": refund_amount,
"unit": unit,
"model": last_model_seen,
},
)
except Exception as exc:
logger.error(
"Error calculating cost for streaming /v1/messages",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"amount": amount,
"unit": unit,
},
)
async def replay() -> AsyncGenerator[bytes, None]:
for chunk in buffered:
yield chunk
return StreamingResponse(
replay(),
media_type="text/event-stream",
headers=response_headers,
)
async def forward_request(
self,
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
max_cost_for_model: int,
session: AsyncSession,
model_obj: Model,
) -> Response | StreamingResponse:
"""Forward authenticated request to upstream service with cost tracking.
Args:
request: Original FastAPI request
path: Request path
headers: Prepared headers for upstream
request_body: Request body bytes, if any
key: API key for authenticated user
max_cost_for_model: Maximum cost deducted upfront
session: Database session for balance updates
Returns:
Response or StreamingResponse from upstream with cost tracking
"""
path = self.normalize_request_path(path, model_obj)
if (
path.endswith("messages/count_tokens")
and not self.supports_anthropic_messages
):
return count_tokens_locally(request_body, model_obj)
if (
path.endswith("messages")
and not path.endswith("count_tokens")
and not self.supports_anthropic_messages
):
return await self._forward_messages_via_litellm(
request_body=request_body,
key=key,
session=session,
max_cost_for_model=max_cost_for_model,
model_obj=model_obj,
)
url = self.build_request_url(path, model_obj)
original_model_id = (
(model_obj.forwarded_model_id or model_obj.id) if model_obj else None
)
transformed_body = self.prepare_request_body(request_body, model_obj)
logger.debug(
"Forwarding request to upstream",
extra={
"url": url,
"method": request.method,
"path": path,
"model": original_model_id or "unknown",
"provider": self.provider_type,
"key_hash": key.hashed_key[:8] + "...",
},
)
client = httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None,
)
try:
if transformed_body is not None:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=transformed_body,
params=self.prepare_params(path, request.query_params),
),
stream=True,
)
else:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request.stream(),
params=self.prepare_params(path, request.query_params),
),
stream=True,
)
if response.status_code != 200:
if response.status_code >= 500:
try:
body_bytes = await response.aread()
except Exception:
body_bytes = b""
# Redact provider account identifiers before the body text
# reaches logs or the raised error.
body_preview = redact_org_ids(
body_bytes.decode("utf-8", errors="ignore").strip()[:500]
)
rate_limit = classify_rate_limit(
response.status_code,
body_preview,
dict(response.headers),
)
logger.error(
"Upstream %s returned %s for model=%s path=%s: %s",
self.provider_type,
response.status_code,
original_model_id or "unknown",
path,
body_preview or "<empty>",
extra={
"provider": self.provider_type,
"model": original_model_id or "unknown",
"status_code": response.status_code,
"error_code": rate_limit.code if rate_limit else None,
"reason_phrase": response.reason_phrase,
"path": path,
"body_preview": body_preview,
},
)
await response.aclose()
await client.aclose()
raise UpstreamError(
f"Upstream {self.provider_type} returned {response.status_code} "
f"for model {original_model_id or 'unknown'}: "
f"{body_preview[:200] or '<empty>'}",
status_code=response.status_code,
code=rate_limit.code if rate_limit else None,
details=rate_limit.as_details() if rate_limit else None,
)
try:
mapped_error = await self.forward_upstream_error_response(
request, path, response, model_id=original_model_id
)
finally:
await response.aclose()
await client.aclose()
return mapped_error
if (
path.endswith("chat/completions")
or path.endswith("embeddings")
or path.endswith("messages")
or path.endswith("messages/count_tokens")
):
if path.endswith("messages"):
client_wants_streaming = False
if request_body:
try:
request_data = json.loads(request_body)
client_wants_streaming = request_data.get("stream", False)
except json.JSONDecodeError:
pass
content_type = response.headers.get("content-type", "")
upstream_is_streaming = "text/event-stream" in content_type
is_streaming = client_wants_streaming and upstream_is_streaming
if is_streaming and response.status_code == 200:
result = await self.handle_streaming_messages_completion(
response,
key,
max_cost_for_model,
requested_model=original_model_id,
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result.background = background_tasks
return result
if response.status_code == 200:
try:
return await self.handle_non_streaming_messages_completion(
response,
key,
session,
max_cost_for_model,
path,
requested_model=original_model_id,
)
finally:
await response.aclose()
await client.aclose()
if path.endswith("messages/count_tokens"):
if response.status_code == 200:
try:
return await self.handle_non_streaming_messages_completion(
response,
key,
session,
max_cost_for_model,
path,
requested_model=original_model_id,
)
finally:
await response.aclose()
await client.aclose()
if path.endswith("chat/completions"):
client_wants_streaming = False
if request_body:
try:
request_data = json.loads(request_body)
client_wants_streaming = request_data.get("stream", False)
logger.debug(
"Chat completion request analysis",
extra={
"client_wants_streaming": client_wants_streaming,
"model": request_data.get("model", "unknown"),
"key_hash": key.hashed_key[:8] + "...",
},
)
except json.JSONDecodeError:
logger.warning(
"Failed to parse request body JSON for streaming detection"
)
content_type = response.headers.get("content-type", "")
upstream_is_streaming = "text/event-stream" in content_type
is_streaming = client_wants_streaming and upstream_is_streaming
logger.debug(
"Response type analysis",
extra={
"is_streaming": is_streaming,
"client_wants_streaming": client_wants_streaming,
"upstream_is_streaming": upstream_is_streaming,
"content_type": content_type,
"key_hash": key.hashed_key[:8] + "...",
},
)
if is_streaming and response.status_code == 200:
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result = await self.handle_streaming_chat_completion(
response,
key,
max_cost_for_model,
background_tasks,
requested_model=original_model_id,
)
result.background = background_tasks
return result
# Handle both non-streaming chat completions and embeddings
if response.status_code == 200:
try:
return await self.handle_non_streaming_chat_completion(
response,
key,
session,
max_cost_for_model,
requested_model=original_model_id,
)
finally:
await response.aclose()
await client.aclose()
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
background_tasks.add_task(
self._finalize_generic_streaming_payment,
key.hashed_key,
max_cost_for_model,
path,
)
logger.debug(
"Streaming non-chat response",
extra={
"path": path,
"status_code": response.status_code,
"key_hash": key.hashed_key[:8] + "...",
},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
except UpstreamError:
raise
except httpx.RequestError as exc:
await client.aclose()
error_type = type(exc).__name__
error_details = str(exc)
logger.error(
"HTTP request error to upstream",
extra={
"error_type": error_type,
"error_details": error_details,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"key_hash": key.hashed_key[:8] + "...",
},
)
# Don't revert here — proxy.py owns payment revert to avoid double-revert
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
error_message = "Upstream service request timed out"
elif isinstance(exc, httpx.NetworkError):
error_message = "Network error while connecting to upstream service"
else:
error_message = f"Error connecting to upstream service: {error_type}"
raise UpstreamError(error_message, status_code=502)
except Exception as exc:
await client.aclose()
tb = traceback.format_exc()
logger.error(
"Unexpected error in upstream forwarding",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"key_hash": key.hashed_key[:8] + "...",
"traceback": tb,
},
)
# Don't revert here — proxy.py owns payment revert to avoid double-revert
raise UpstreamError("An unexpected server error occurred", status_code=500)
supports_ehbp: bool = False
def get_confidential_inference_profile(self) -> "ConfidentialInferenceProfile | None":
"""Return provider policy for encrypted/confidential inference forwarding."""
return None
def get_ehbp_forwarding_target(
self, path: str, model_obj: Model
) -> "EHBPForwardingTarget":
"""Return the EHBP forwarding target for this provider.
Providers must explicitly opt in by setting ``supports_ehbp = True``
and overriding this method. Most upstreams do not accept EHBP-encrypted
request bodies, so the base provider intentionally does not provide a
default endpoint.
"""
raise NotImplementedError(
f"Provider {self.provider_type} does not support EHBP forwarding"
)
async def forward_responses_request(
self,
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
max_cost_for_model: int,
session: AsyncSession,
model_obj: Model,
) -> Response | StreamingResponse:
"""Forward authenticated Responses API request to upstream service with cost tracking.
Args:
request: Original FastAPI request
path: Request path
headers: Prepared headers for upstream
request_body: Request body bytes, if any
key: API key for authenticated user
max_cost_for_model: Maximum cost deducted upfront
session: Database session for balance updates
model_obj: Model object for the request
Returns:
Response or StreamingResponse from upstream with cost tracking
"""
path = self.normalize_request_path(path, model_obj)
url = self.build_request_url(path, model_obj)
original_model_id = (
(model_obj.forwarded_model_id or model_obj.id) if model_obj else None
)
transformed_body = self.prepare_responses_request_body(request_body, model_obj)
logger.debug(
"Forwarding Responses API request to upstream",
extra={
"url": url,
"method": request.method,
"path": path,
"model": original_model_id or "unknown",
"provider": self.provider_type,
"key_hash": key.hashed_key[:8] + "...",
},
)
client = httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None,
)
try:
if transformed_body is not None:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=transformed_body,
params=self.prepare_params(path, request.query_params),
),
stream=True,
)
else:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request.stream(),
params=self.prepare_params(path, request.query_params),
),
stream=True,
)
if response.status_code != 200:
if response.status_code >= 500:
try:
body_bytes = await response.aread()
except Exception:
body_bytes = b""
# Redact provider account identifiers before the body text
# reaches logs or the raised error.
body_preview = redact_org_ids(
body_bytes.decode("utf-8", errors="ignore").strip()[:500]
)
rate_limit = classify_rate_limit(
response.status_code,
body_preview,
dict(response.headers),
)
logger.error(
"Upstream %s returned %s for model=%s path=%s: %s",
self.provider_type,
response.status_code,
original_model_id or "unknown",
path,
body_preview or "<empty>",
extra={
"provider": self.provider_type,
"model": original_model_id or "unknown",
"status_code": response.status_code,
"error_code": rate_limit.code if rate_limit else None,
"path": path,
"body_preview": body_preview,
},
)
await response.aclose()
await client.aclose()
raise UpstreamError(
f"Upstream {self.provider_type} returned {response.status_code} "
f"for model {original_model_id or 'unknown'}: "
f"{body_preview[:200] or '<empty>'}",
status_code=response.status_code,
code=rate_limit.code if rate_limit else None,
details=rate_limit.as_details() if rate_limit else None,
)
try:
mapped_error = await self.forward_upstream_error_response(
request, path, response, model_id=original_model_id
)
finally:
await response.aclose()
await client.aclose()
return mapped_error
if path.startswith("responses"):
content_type = response.headers.get("content-type", "")
is_streaming = "text/event-stream" in content_type
logger.debug(
"Responses API response type analysis",
extra={
"is_streaming": is_streaming,
"content_type": content_type,
"key_hash": key.hashed_key[:8] + "...",
},
)
if is_streaming and response.status_code == 200:
result = await self.handle_streaming_responses_completion(
response,
key,
max_cost_for_model,
requested_model=original_model_id,
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result.background = background_tasks
return result
if response.status_code == 200:
try:
return await self.handle_non_streaming_responses_completion(
response,
key,
session,
max_cost_for_model,
requested_model=original_model_id,
)
finally:
await response.aclose()
await client.aclose()
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
background_tasks.add_task(
self._finalize_generic_streaming_payment,
key.hashed_key,
max_cost_for_model,
path,
)
logger.debug(
"Streaming non-Responses API response",
extra={
"path": path,
"status_code": response.status_code,
"key_hash": key.hashed_key[:8] + "...",
},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
except UpstreamError:
raise
except httpx.RequestError as exc:
await client.aclose()
error_type = type(exc).__name__
error_details = str(exc)
logger.error(
"HTTP request error to upstream Responses API",
extra={
"error_type": error_type,
"error_details": error_details,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"key_hash": key.hashed_key[:8] + "...",
},
)
# Don't revert here — proxy.py owns payment revert to avoid double-revert
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
error_message = "Upstream service request timed out"
elif isinstance(exc, httpx.NetworkError):
error_message = "Network error while connecting to upstream service"
else:
error_message = f"Error connecting to upstream service: {error_type}"
raise UpstreamError(error_message, status_code=502)
except Exception as exc:
await client.aclose()
tb = traceback.format_exc()
logger.error(
"Unexpected error in upstream Responses API forwarding",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"key_hash": key.hashed_key[:8] + "...",
"traceback": tb,
},
)
# Don't revert here — proxy.py owns payment revert to avoid double-revert
raise UpstreamError("An unexpected server error occurred", status_code=500)
async def forward_get_request(
self,
request: Request,
path: str,
headers: dict,
) -> Response | StreamingResponse:
"""Forward unauthenticated GET request to upstream service.
Args:
request: Original FastAPI request
path: Request path
headers: Prepared headers for upstream
Returns:
StreamingResponse from upstream
"""
path = self.normalize_request_path(path)
url = self.build_request_url(path)
logger.debug(
"Forwarding GET request to upstream",
extra={
"url": url,
"method": request.method,
"path": path,
"provider": self.provider_type,
},
)
async with httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None,
) as client:
try:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request.stream(),
params=self.prepare_params(path, request.query_params),
),
)
logger.debug(
"GET request forwarded",
extra={
"path": path,
"status_code": response.status_code,
"provider": self.provider_type,
},
)
if response.status_code != 200:
try:
mapped = await self.forward_upstream_error_response(
request, path, response
)
finally:
await response.aclose()
return mapped
response_headers = dict(response.headers)
response_headers.pop("content-encoding", None)
response_headers.pop("content-length", None)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=response_headers,
)
except Exception as exc:
tb = traceback.format_exc()
logger.error(
"Error forwarding GET request",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"traceback": tb,
},
)
return create_error_response(
"internal_error",
"An unexpected server error occurred",
500,
request=request,
)
async def get_x_cashu_cost(
self, response_data: dict, max_cost_for_model: int
) -> MaxCostData | CostData | None:
"""Calculate cost for X-Cashu payment based on response data.
Args:
response_data: Response data containing model and usage information
max_cost_for_model: Maximum cost for the model
Returns:
Cost data object (MaxCostData or CostData) or None if calculation fails
"""
model = response_data.get("model", None)
logger.debug(
"Calculating cost for response",
extra={"model": model, "has_usage": "usage" in response_data},
)
match await calculate_cost(
response_data,
max_cost_for_model,
):
case MaxCostData() as cost:
logger.debug(
"Using max cost pricing",
extra={"model": model, "max_cost_msats": cost.total_msats},
)
return cost
case CostData() as cost:
logger.debug(
"Using token-based pricing",
extra={
"model": model,
"total_cost_msats": cost.total_msats,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
},
)
return cost
case CostDataError() as error:
logger.error(
"Cost calculation error",
extra={
"model": model,
"error_message": error.message,
"error_code": error.code,
},
)
raise HTTPException(
status_code=400,
detail={
"error": {
"message": error.message,
"type": "invalid_request_error",
"code": error.code,
}
},
)
return None
async def send_refund(
self,
amount: int,
unit: str,
mint: str | None = None,
request_id: str | None = None,
) -> str:
"""Create and send a refund token to the user.
Args:
amount: Refund amount
unit: Unit of the refund (sat or msat)
mint: Optional mint URL for the refund token
request_id: Optional HTTP request ID for tracking
Returns:
Refund token string
"""
logger.debug(
"Creating refund token",
extra={"amount": amount, "unit": unit, "mint": mint},
)
max_retries = 3
last_exception = None
for attempt in range(max_retries):
try:
refund_token = await send_token(amount, unit=unit, mint_url=mint)
logger.info(
"Refund token created successfully",
extra={
"amount": amount,
"unit": unit,
"mint": mint,
"attempt": attempt + 1,
"token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
try:
await store_cashu_transaction(
token=refund_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
except Exception:
pass # store_cashu_transaction already logs
return refund_token
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
"Refund token creation failed, retrying",
extra={
"error": str(e),
"error_type": type(e).__name__,
"attempt": attempt + 1,
"max_retries": max_retries,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
else:
logger.error(
"Failed to create refund token after all retries",
extra={
"error": str(e),
"error_type": type(e).__name__,
"attempt": attempt + 1,
"max_retries": max_retries,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
raise HTTPException(
status_code=401,
detail={
"error": {
"message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}",
"type": "invalid_request_error",
"code": "send_token_failed",
}
},
)
async def handle_x_cashu_streaming_response(
self,
content_str: str,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None = None,
request_id: str | None = None,
) -> StreamingResponse:
"""Handle streaming response for X-Cashu payment, calculating refund if needed.
Args:
content_str: Response content as string
response: Original httpx response
amount: Payment amount received
unit: Payment unit (sat or msat)
max_cost_for_model: Maximum cost for the model
Returns:
StreamingResponse with refund token in header if applicable
"""
logger.debug(
"Processing streaming response",
extra={
"amount": amount,
"unit": unit,
"content_lines": len(content_str.strip().split("\n")),
},
)
response_headers = dict(response.headers)
if "transfer-encoding" in response_headers:
del response_headers["transfer-encoding"]
if "content-encoding" in response_headers:
del response_headers["content-encoding"]
usage_data = None
model = None
cost_data: CostData | MaxCostData | None = None
lines = content_str.strip().split("\n")
for line in lines:
if line.startswith("data: "):
try:
data_json = json.loads(line[6:])
# OpenAI format: usage and model at top level
if "usage" in data_json:
usage_data = data_json["usage"]
model = data_json.get("model") or model
elif "model" in data_json and not model:
model = data_json["model"]
# Anthropic format: model and input usage inside "message" key
if "message" in data_json:
msg = data_json["message"]
if not model and msg.get("model"):
model = msg["model"]
if msg.get("usage") and not usage_data:
usage_data = msg["usage"]
elif msg.get("usage") and usage_data:
# Merge: message_start has input_tokens, message_delta has output_tokens
merged = dict(usage_data)
for k, v in msg["usage"].items():
merged[k] = merged.get(k, 0) + v
usage_data = merged
except json.JSONDecodeError:
continue
if usage_data and model:
logger.debug(
"Found usage data in streaming response",
extra={
"model": model,
"usage_data": usage_data,
"amount": amount,
"unit": unit,
},
)
response_data = {"usage": usage_data, "model": model}
try:
cost_data = await self.get_x_cashu_cost(
response_data, max_cost_for_model
)
if cost_data:
if unit == "msat":
refund_amount = amount - cost_data.total_msats
elif unit == "sat":
refund_amount = amount - (cost_data.total_msats + 999) // 1000
else:
raise ValueError(f"Invalid unit: {unit}")
if refund_amount > 0:
logger.debug(
"Processing refund for streaming response",
extra={
"original_amount": amount,
"cost_msats": cost_data.total_msats,
"refund_amount": refund_amount,
"unit": unit,
"model": model,
},
)
refund_token = await self.send_refund(
refund_amount,
unit,
mint,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
logger.info(
"Refund processed for streaming response",
extra={
"refund_amount": refund_amount,
"unit": unit,
"refund_token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
else:
logger.debug(
"No refund needed for streaming response",
extra={
"amount": amount,
"cost_msats": cost_data.total_msats,
"model": model,
},
)
except Exception as e:
logger.error(
"Error calculating cost for streaming response",
extra={
"error": str(e),
"error_type": type(e).__name__,
"model": model,
"amount": amount,
"unit": unit,
},
)
for i, line in enumerate(lines):
if line.startswith("data: "):
try:
data_json = json.loads(line[6:])
if not isinstance(data_json, dict):
continue
changed = False
if "provider" not in data_json:
self._apply_provider_field(data_json)
changed = True
if (
cost_data
and "usage" in data_json
and data_json["usage"]
):
data_json["usage"]["cost_sats"] = (
cost_data.total_msats // 1000
)
changed = True
if changed:
lines[i] = "data: " + json.dumps(data_json)
except json.JSONDecodeError:
pass
async def generate() -> AsyncGenerator[bytes, None]:
for line in lines:
yield (line + "\n").encode("utf-8")
return StreamingResponse(
generate(),
status_code=response.status_code,
headers=response_headers,
media_type="text/plain",
)
async def handle_x_cashu_non_streaming_response(
self,
content_str: str,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None = None,
request_id: str | None = None,
) -> Response:
"""Handle non-streaming response for X-Cashu payment, calculating refund if needed.
Args:
content_str: Response content as string
response: Original httpx response
amount: Payment amount received
unit: Payment unit (sat or msat)
max_cost_for_model: Maximum cost for the model
Returns:
Response with refund token in header if applicable
"""
logger.debug(
"Processing non-streaming response",
extra={"amount": amount, "unit": unit, "content_length": len(content_str)},
)
try:
response_json = json.loads(content_str)
self._apply_provider_field(response_json)
cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model)
if cost_data and "usage" in response_json:
response_json["usage"]["cost_sats"] = cost_data.total_msats // 1000
if not cost_data:
logger.error(
"Failed to calculate cost for response",
extra={
"amount": amount,
"unit": unit,
"response_model": response_json.get("model", "unknown"),
},
)
return Response(
content=json.dumps(
{
"error": {
"message": "Error forwarding request to upstream",
"type": "upstream_error",
"code": response.status_code,
}
}
),
status_code=response.status_code,
media_type="application/json",
)
response_headers = dict(response.headers)
if "transfer-encoding" in response_headers:
del response_headers["transfer-encoding"]
if "content-encoding" in response_headers:
del response_headers["content-encoding"]
if unit == "msat":
refund_amount = amount - cost_data.total_msats
elif unit == "sat":
refund_amount = amount - (cost_data.total_msats + 999) // 1000
else:
raise ValueError(f"Invalid unit: {unit}")
logger.debug(
"Processing non-streaming response cost calculation",
extra={
"original_amount": amount,
"cost_msats": cost_data.total_msats,
"refund_amount": refund_amount,
"unit": unit,
"model": response_json.get("model", "unknown"),
},
)
if refund_amount > 0:
refund_token = await self.send_refund(
refund_amount,
unit,
mint,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
logger.info(
"Refund processed for non-streaming response",
extra={
"refund_amount": refund_amount,
"unit": unit,
"refund_token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
return Response(
content=json.dumps(response_json),
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except json.JSONDecodeError as e:
logger.error(
"Failed to parse JSON from upstream response",
extra={
"error": str(e),
"content_preview": content_str[:200] + "..."
if len(content_str) > 200
else content_str,
"amount": amount,
"unit": unit,
},
)
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response.headers["X-Cashu"] = refund_token
try:
await store_cashu_transaction(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
except Exception:
pass
logger.warning(
"Emergency refund issued due to JSON parse error",
extra={
"original_amount": amount,
"refund_amount": emergency_refund,
},
)
return Response(
content=content_str,
status_code=response.status_code,
headers=dict(response.headers),
media_type="application/json",
)
async def handle_x_cashu_chat_completion(
self,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None = None,
request_id: str | None = None,
) -> StreamingResponse | Response:
"""Handle chat completion response for X-Cashu payment, detecting streaming vs non-streaming.
Args:
response: Response from upstream
amount: Payment amount received
unit: Payment unit (sat or msat)
max_cost_for_model: Maximum cost for the model
Returns:
StreamingResponse or Response depending on response type
"""
logger.debug(
"Handling chat completion response",
extra={"amount": amount, "unit": unit, "status_code": response.status_code},
)
try:
content = await response.aread()
content_str = (
content.decode("utf-8") if isinstance(content, bytes) else content
)
is_streaming = content_str.startswith("data:") or "data:" in content_str
logger.debug(
"Chat completion response analysis",
extra={
"is_streaming": is_streaming,
"content_length": len(content_str),
"amount": amount,
"unit": unit,
},
)
if is_streaming:
return await self.handle_x_cashu_streaming_response(
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
request_id=request_id,
)
else:
return await self.handle_x_cashu_non_streaming_response(
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
request_id=request_id,
)
except Exception as e:
logger.error(
"Error processing chat completion response",
extra={
"error": str(e),
"error_type": type(e).__name__,
"amount": amount,
"unit": unit,
},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
)
async def forward_x_cashu_request(
self,
request: Request,
path: str,
headers: dict,
amount: int,
unit: str,
max_cost_for_model: int,
model_obj: Model,
mint: str | None = None,
) -> Response | StreamingResponse:
"""Forward request paid with X-Cashu token to upstream service.
Args:
request: Original FastAPI request
path: Request path
headers: Prepared headers for upstream
amount: Payment amount from X-Cashu token
unit: Payment unit (sat or msat)
max_cost_for_model: Maximum cost for the model
model_obj: Model object for the request
Returns:
Response or StreamingResponse with refund if applicable
"""
if path.startswith("v1/"):
path = path.replace("v1/", "")
request_body = await request.body()
if (
path.endswith("messages/count_tokens")
and not self.supports_anthropic_messages
):
return count_tokens_locally(request_body, model_obj)
if (
path.endswith("messages")
and not path.endswith("count_tokens")
and not self.supports_anthropic_messages
):
return await self._forward_x_cashu_messages_via_litellm(
request_body=request_body,
amount=amount,
unit=unit,
max_cost_for_model=max_cost_for_model,
model_obj=model_obj,
mint=mint,
request_id=getattr(request.state, "request_id", None),
)
url = f"{self.base_url}/{path}"
transformed_body = self.prepare_request_body(request_body, model_obj)
logger.debug(
"Forwarding request to upstream",
extra={
"url": url,
"method": request.method,
"path": path,
"amount": amount,
"unit": unit,
},
)
async with httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None,
) as client:
try:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=transformed_body if transformed_body else request_body,
params=self.prepare_params(path, request.query_params),
),
stream=True,
)
if response.status_code != 200:
logger.error(
"Received upstream response",
extra={
"reason_phrase": response.reason_phrase,
"status_code": response.status_code,
"path": path,
"response_headers": dict(response.headers),
},
)
else:
logger.debug(
"Received upstream response",
extra={
"status_code": response.status_code,
"path": path,
"response_headers": dict(response.headers),
},
)
if response.status_code != 200:
logger.warning(
"Upstream request failed, processing refund",
extra={
"status_code": response.status_code,
"path": path,
"amount": amount,
"unit": unit,
},
)
refund_token = await self.send_refund(
amount,
unit,
mint,
request_id=getattr(request.state, "request_id", None),
)
logger.info(
"Refund processed for failed upstream request",
extra={
"status_code": response.status_code,
"refund_amount": amount,
"unit": unit,
"refund_token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
error_response = Response(
content=json.dumps(
{
"error": {
"message": "Error forwarding request to upstream",
"type": "upstream_error",
"code": response.status_code,
"refund_token": refund_token,
}
}
),
status_code=response.status_code,
media_type="application/json",
)
error_response.headers["X-Cashu"] = refund_token
return error_response
if (
path.endswith("chat/completions")
or path.endswith("embeddings")
or path.endswith("messages")
or path.endswith("messages/count_tokens")
):
logger.debug(
"Processing completion/embeddings/messages response",
extra={"path": path, "amount": amount, "unit": unit},
)
result = await self.handle_x_cashu_chat_completion(
response,
amount,
unit,
max_cost_for_model,
mint,
request_id=getattr(request.state, "request_id", None),
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
result.background = background_tasks
return result
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
logger.debug(
"Streaming non-chat response",
extra={"path": path, "status_code": response.status_code},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
except Exception as exc:
tb = traceback.format_exc()
logger.error(
"Unexpected error in upstream forwarding",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"traceback": tb,
},
)
return create_error_response(
"internal_error",
"An unexpected server error occurred",
500,
request=request,
)
async def handle_x_cashu_responses(
self,
request: Request,
x_cashu_token: str,
path: str,
max_cost_for_model: int,
model_obj: Model,
) -> Response | StreamingResponse:
"""Handle X-Cashu payment for Responses API requests.
Args:
request: Original FastAPI request
x_cashu_token: X-Cashu token from request header
path: Request path
max_cost_for_model: Maximum cost for the model
model_obj: Model object for the request
Returns:
Response or StreamingResponse from upstream with refund if applicable
"""
logger.debug(
"Processing X-Cashu payment for Responses API",
extra={
"path": path,
"method": request.method,
"token_preview": x_cashu_token[:20] + "..."
if len(x_cashu_token) > 20
else x_cashu_token,
},
)
try:
headers = dict(request.headers)
amount, unit, mint = await recieve_token(x_cashu_token)
headers = self.prepare_headers(dict(request.headers))
request_id = getattr(request.state, "request_id", None)
try:
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
except Exception:
pass
logger.info(
"X-Cashu token redeemed for Responses API",
extra={"amount": amount, "unit": unit, "path": path, "mint": mint},
)
return await self.forward_x_cashu_responses_request(
request,
path,
headers,
amount,
unit,
max_cost_for_model,
model_obj,
mint,
)
except Exception as e:
error_message = str(e)
logger.error(
"X-Cashu payment for Responses API failed",
extra={
"error": error_message,
"error_type": type(e).__name__,
"path": path,
"method": request.method,
},
)
# Use same error handling as regular X-Cashu
if "already spent" in error_message.lower():
return create_error_response(
"token_already_spent",
"The provided CASHU token has already been spent",
400,
request=request,
token=x_cashu_token,
)
if "invalid token" in error_message.lower():
return create_error_response(
"invalid_token",
"The provided CASHU token is invalid",
400,
request=request,
token=x_cashu_token,
)
if "mint error" in error_message.lower():
return create_error_response(
"mint_error",
f"CASHU mint error: {error_message}",
422,
request=request,
token=x_cashu_token,
)
return create_error_response(
"cashu_error",
f"CASHU token processing failed: {error_message}",
400,
request=request,
token=x_cashu_token,
)
async def forward_x_cashu_responses_request(
self,
request: Request,
path: str,
headers: dict,
amount: int,
unit: str,
max_cost_for_model: int,
model_obj: Model,
mint: str | None = None,
) -> Response | StreamingResponse:
"""Forward Responses API request paid with X-Cashu token to upstream service.
Args:
request: Original FastAPI request
path: Request path
headers: Prepared headers for upstream
amount: Payment amount from X-Cashu token
unit: Payment unit (sat or msat)
max_cost_for_model: Maximum cost for the model
model_obj: Model object for the request
mint: Mint URL for refund tokens
Returns:
Response or StreamingResponse with refund if applicable
"""
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{self.base_url}/{path}"
request_body = await request.body()
transformed_body = self.prepare_responses_request_body(request_body, model_obj)
logger.debug(
"Forwarding Responses API request to upstream with X-Cashu payment",
extra={
"url": url,
"method": request.method,
"path": path,
"amount": amount,
"unit": unit,
},
)
async with httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None,
) as client:
try:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=transformed_body if transformed_body else request_body,
params=self.prepare_params(path, request.query_params),
),
stream=True,
)
logger.debug(
"Received upstream Responses API response",
extra={
"status_code": response.status_code,
"path": path,
"response_headers": dict(response.headers),
},
)
if response.status_code != 200:
logger.warning(
"Upstream Responses API request failed, processing refund",
extra={
"status_code": response.status_code,
"path": path,
"amount": amount,
"unit": unit,
},
)
refund_token = await self.send_refund(
amount,
unit,
mint,
request_id=getattr(request.state, "request_id", None),
)
logger.info(
"Refund processed for failed upstream Responses API request",
extra={
"status_code": response.status_code,
"refund_amount": amount,
"unit": unit,
"refund_token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
error_response = Response(
content=json.dumps(
{
"error": {
"message": "Error forwarding Responses API request to upstream",
"type": "upstream_error",
"code": response.status_code,
"refund_token": refund_token,
}
}
),
status_code=response.status_code,
media_type="application/json",
)
error_response.headers["X-Cashu"] = refund_token
return error_response
if path.startswith("responses"):
logger.debug(
"Processing Responses API response",
extra={"path": path, "amount": amount, "unit": unit},
)
result = await self.handle_x_cashu_responses_completion(
response,
amount,
unit,
max_cost_for_model,
mint,
request_id=getattr(request.state, "request_id", None),
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
result.background = background_tasks
return result
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
logger.debug(
"Streaming non-responses response",
extra={"path": path, "status_code": response.status_code},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
except Exception as exc:
tb = traceback.format_exc()
logger.error(
"Unexpected error in upstream Responses API forwarding",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"traceback": tb,
},
)
return create_error_response(
"internal_error",
"An unexpected server error occurred",
500,
request=request,
)
async def handle_x_cashu_responses_completion(
self,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None = None,
request_id: str | None = None,
) -> StreamingResponse | Response:
"""Handle Responses API completion response for X-Cashu payment.
Args:
response: Response from upstream
amount: Payment amount received
unit: Payment unit (sat or msat)
max_cost_for_model: Maximum cost for the model
mint: Mint URL for refund tokens
Returns:
StreamingResponse or Response depending on response type
"""
logger.debug(
"Handling Responses API completion response",
extra={"amount": amount, "unit": unit, "status_code": response.status_code},
)
try:
content = await response.aread()
content_str = (
content.decode("utf-8") if isinstance(content, bytes) else content
)
is_streaming = content_str.startswith("data:") or "data:" in content_str
logger.debug(
"Responses API completion response analysis",
extra={
"is_streaming": is_streaming,
"content_length": len(content_str),
"amount": amount,
"unit": unit,
},
)
if is_streaming:
return await self.handle_x_cashu_streaming_responses_response(
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
request_id=request_id,
)
else:
return await self.handle_x_cashu_non_streaming_responses_response(
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
request_id=request_id,
)
except Exception as e:
logger.error(
"Error processing Responses API completion response",
extra={
"error": str(e),
"error_type": type(e).__name__,
"amount": amount,
"unit": unit,
},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
)
async def handle_x_cashu_streaming_responses_response(
self,
content_str: str,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None = None,
request_id: str | None = None,
) -> StreamingResponse:
"""Handle streaming Responses API response for X-Cashu payment.
Similar to regular streaming but handles Responses API specific tokens like reasoning_tokens.
"""
logger.debug(
"Processing streaming Responses API response",
extra={
"amount": amount,
"unit": unit,
"content_lines": len(content_str.strip().split("\\n")),
},
)
response_headers = dict(response.headers)
if "transfer-encoding" in response_headers:
del response_headers["transfer-encoding"]
if "content-encoding" in response_headers:
del response_headers["content-encoding"]
usage_data = None
model = None
reasoning_tokens = 0
lines = content_str.strip().split("\\n")
for line in lines:
if line.startswith("data: "):
try:
data_json = json.loads(line[6:])
if "usage" in data_json:
usage_data = data_json["usage"]
model = data_json.get("model")
# Track reasoning tokens for Responses API
if (
isinstance(usage_data, dict)
and "reasoning_tokens" in usage_data
):
reasoning_tokens = usage_data.get("reasoning_tokens", 0)
elif "model" in data_json and not model:
model = data_json["model"]
except json.JSONDecodeError:
continue
if usage_data and model:
logger.debug(
"Found usage data in streaming Responses API response",
extra={
"model": model,
"usage_data": usage_data,
"reasoning_tokens": reasoning_tokens,
"amount": amount,
"unit": unit,
},
)
response_data = {"usage": usage_data, "model": model}
try:
cost_data = await self.get_x_cashu_cost(
response_data, max_cost_for_model
)
if cost_data:
if unit == "msat":
refund_amount = amount - cost_data.total_msats
elif unit == "sat":
refund_amount = amount - (cost_data.total_msats + 999) // 1000
else:
raise ValueError(f"Invalid unit: {unit}")
if refund_amount > 0:
logger.debug(
"Processing refund for streaming Responses API response",
extra={
"original_amount": amount,
"cost_msats": cost_data.total_msats,
"refund_amount": refund_amount,
"unit": unit,
"model": model,
"reasoning_tokens": reasoning_tokens,
},
)
refund_token = await self.send_refund(
refund_amount,
unit,
mint,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
logger.info(
"Refund processed for streaming Responses API response",
extra={
"refund_amount": refund_amount,
"unit": unit,
"refund_token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
else:
logger.debug(
"No refund needed for streaming Responses API response",
extra={
"amount": amount,
"cost_msats": cost_data.total_msats,
"model": model,
},
)
except Exception as e:
logger.error(
"Error calculating cost for streaming Responses API response",
extra={
"error": str(e),
"error_type": type(e).__name__,
"model": model,
"amount": amount,
"unit": unit,
},
)
for i, line in enumerate(lines):
if line.startswith("data: "):
try:
data_json = json.loads(line[6:])
if not isinstance(data_json, dict):
continue
changed = False
if "provider" not in data_json:
self._apply_provider_field(data_json)
changed = True
if (
cost_data
and "usage" in data_json
and data_json["usage"]
):
data_json["usage"]["cost_sats"] = (
cost_data.total_msats // 1000
)
changed = True
if changed:
lines[i] = "data: " + json.dumps(data_json)
except json.JSONDecodeError:
pass
async def generate() -> AsyncGenerator[bytes, None]:
for line in lines:
yield (line + "\\n").encode("utf-8")
return StreamingResponse(
generate(),
status_code=response.status_code,
headers=response_headers,
media_type="text/plain",
)
async def handle_x_cashu_non_streaming_responses_response(
self,
content_str: str,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None = None,
request_id: str | None = None,
) -> Response:
"""Handle non-streaming Responses API response for X-Cashu payment."""
logger.debug(
"Processing non-streaming Responses API response",
extra={"amount": amount, "unit": unit, "content_length": len(content_str)},
)
try:
response_json = json.loads(content_str)
self._apply_provider_field(response_json)
cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model)
if cost_data and "usage" in response_json:
response_json["usage"]["cost_sats"] = cost_data.total_msats // 1000
if not cost_data:
logger.error(
"Failed to calculate cost for Responses API response",
extra={
"amount": amount,
"unit": unit,
"response_model": response_json.get("model", "unknown"),
},
)
return Response(
content=json.dumps(
{
"error": {
"message": "Error forwarding Responses API request to upstream",
"type": "upstream_error",
"code": response.status_code,
}
}
),
status_code=response.status_code,
media_type="application/json",
)
response_headers = dict(response.headers)
if "transfer-encoding" in response_headers:
del response_headers["transfer-encoding"]
if "content-encoding" in response_headers:
del response_headers["content-encoding"]
if unit == "msat":
refund_amount = amount - cost_data.total_msats
elif unit == "sat":
refund_amount = amount - (cost_data.total_msats + 999) // 1000
else:
raise ValueError(f"Invalid unit: {unit}")
logger.debug(
"Processing non-streaming Responses API cost calculation",
extra={
"original_amount": amount,
"cost_msats": cost_data.total_msats,
"refund_amount": refund_amount,
"unit": unit,
"model": response_json.get("model", "unknown"),
},
)
if refund_amount > 0:
refund_token = await self.send_refund(
refund_amount,
unit,
mint,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
logger.info(
"Refund processed for non-streaming Responses API response",
extra={
"refund_amount": refund_amount,
"unit": unit,
"refund_token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
return Response(
content=json.dumps(response_json),
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except json.JSONDecodeError as e:
logger.error(
"Failed to parse JSON from upstream Responses API response",
extra={
"error": str(e),
"content_preview": content_str[:200] + "..."
if len(content_str) > 200
else content_str,
"amount": amount,
"unit": unit,
},
)
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response.headers["X-Cashu"] = refund_token
try:
await store_cashu_transaction(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
except Exception:
pass
logger.warning(
"Emergency refund issued for Responses API due to JSON parse error",
extra={
"original_amount": amount,
"refund_amount": emergency_refund,
},
)
return Response(
content=content_str,
status_code=response.status_code,
headers=dict(response.headers),
media_type="application/json",
)
async def handle_x_cashu(
self,
request: Request,
x_cashu_token: str,
path: str,
max_cost_for_model: int,
model_obj: Model,
) -> Response | StreamingResponse:
"""Handle request with X-Cashu token payment, redeeming token and forwarding request.
Args:
request: Original FastAPI request
x_cashu_token: X-Cashu token from request header
path: Request path
max_cost_for_model: Maximum cost for the model
model_obj: Model object for the request
Returns:
Response or StreamingResponse from upstream with refund if applicable
"""
logger.debug(
"Processing X-Cashu payment request",
extra={
"path": path,
"method": request.method,
"token_preview": x_cashu_token[:20] + "..."
if len(x_cashu_token) > 20
else x_cashu_token,
},
)
try:
headers = dict(request.headers)
amount, unit, mint = await recieve_token(x_cashu_token)
headers = self.prepare_headers(dict(request.headers))
request_id = getattr(request.state, "request_id", None)
try:
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
except Exception:
pass
logger.info(
"X-Cashu token redeemed successfully",
extra={"amount": amount, "unit": unit, "path": path, "mint": mint},
)
return await self.forward_x_cashu_request(
request,
path,
headers,
amount,
unit,
max_cost_for_model,
model_obj,
mint,
)
except Exception as e:
error_message = str(e)
logger.error(
"X-Cashu payment request failed",
extra={
"error": error_message,
"error_type": type(e).__name__,
"path": path,
"method": request.method,
},
)
if "already spent" in error_message.lower():
return create_error_response(
"token_already_spent",
"The provided CASHU token has already been spent",
400,
request=request,
token=x_cashu_token,
)
if "invalid token" in error_message.lower():
return create_error_response(
"invalid_token",
"The provided CASHU token is invalid",
400,
request=request,
token=x_cashu_token,
)
if "mint error" in error_message.lower():
return create_error_response(
"mint_error",
f"CASHU mint error: {error_message}",
422,
request=request,
token=x_cashu_token,
)
return create_error_response(
"cashu_error",
f"CASHU token processing failed: {error_message}",
400,
request=request,
token=x_cashu_token,
)
def _apply_provider_fee_to_model(self, model: Model) -> Model:
"""Apply provider fee to model's USD pricing and calculate max costs.
Cache rates missing from the upstream pricing feed are backfilled from
litellm's cost map first, so they carry the provider fee like every
other price component.
Args:
model: Model object to update
Returns:
Model with provider fee applied to pricing and max costs calculated
"""
base_pricing = backfill_cache_pricing(model.id, model.pricing)
adjusted_pricing = Pricing.parse_obj(
{k: v * self.provider_fee for k, v in base_pricing.dict().items()}
)
temp_model = Model(
id=model.id,
name=model.name,
created=model.created,
description=model.description,
context_length=model.context_length,
architecture=model.architecture,
pricing=adjusted_pricing,
sats_pricing=None,
per_request_limits=model.per_request_limits,
top_provider=model.top_provider,
enabled=model.enabled,
upstream_provider_id=model.upstream_provider_id,
canonical_slug=model.canonical_slug,
alias_ids=model.alias_ids,
forwarded_model_id=model.forwarded_model_id,
)
(
adjusted_pricing.max_prompt_cost,
adjusted_pricing.max_completion_cost,
adjusted_pricing.max_cost,
) = _calculate_usd_max_costs(temp_model)
return Model(
id=model.id,
name=model.name,
created=model.created,
description=model.description,
context_length=model.context_length,
architecture=model.architecture,
pricing=adjusted_pricing,
sats_pricing=model.sats_pricing,
per_request_limits=model.per_request_limits,
top_provider=model.top_provider,
enabled=model.enabled,
upstream_provider_id=model.upstream_provider_id,
canonical_slug=model.canonical_slug,
alias_ids=model.alias_ids,
forwarded_model_id=model.forwarded_model_id,
)
async def fetch_models(self) -> list[Model]:
"""Fetch available models from upstream API and update cache.
Returns:
List of Model objects with pricing
"""
try:
or_models, provider_models_response = await asyncio.gather(
self._fetch_openrouter_models(),
self._fetch_provider_models(),
)
provider_model_ids = self._parse_model_ids(provider_models_response)
found_models = []
not_found_models = []
for model_id in provider_model_ids:
or_model = self._match_model(model_id, or_models)
if or_model:
try:
model = Model(**or_model) # type: ignore
found_models.append(model)
except Exception as e:
logger.warning(
f"Failed to parse model {model_id}",
extra={"error": str(e), "error_type": type(e).__name__},
)
else:
not_found_models.append(model_id)
if not_found_models:
logger.debug(
f"({len(not_found_models)}/{len(provider_model_ids)}) unmatched models for {self.provider_type or self.base_url}",
extra={"not_found_models": not_found_models},
)
return found_models
except Exception as e:
logger.error(
f"Error fetching models for {self.provider_type or self.base_url}",
extra={"error": str(e), "error_type": type(e).__name__},
)
return []
async def _fetch_openrouter_models(self) -> list[dict]:
"""Fetch models from OpenRouter API."""
url = "https://openrouter.ai/api/v1/models"
embeddings_url = "https://openrouter.ai/api/v1/embeddings/models"
async with httpx.AsyncClient(timeout=30.0) as client:
models_response, embeddings_response = await asyncio.gather(
client.get(url), client.get(embeddings_url), return_exceptions=True
)
all_models = []
def process_models_response(
response: httpx.Response | BaseException,
) -> list[dict]:
if not isinstance(response, BaseException):
response.raise_for_status()
data = response.json()
return [
model
for model in data.get("data", [])
if ":free" not in model.get("id", "").lower()
]
return []
all_models.extend(process_models_response(models_response))
all_models.extend(process_models_response(embeddings_response))
return all_models
async def _fetch_provider_models(self) -> dict:
"""Fetch models from provider's API."""
url = f"{self.base_url.rstrip('/')}/models"
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else None
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
def _parse_model_ids(self, response: dict) -> list[str]:
"""Parse model IDs from provider response."""
return [model.get("id") for model in response.get("data", []) if "id" in model]
def _match_model(self, model_id: str, or_models: list[dict]) -> dict | None:
"""Match provider model ID with OpenRouter model."""
return next(
(
model
for model in or_models
if (model.get("id") == model_id)
or (model.get("id", "").split("/")[-1] == model_id)
or (model.get("canonical_slug") == model_id)
or (model.get("canonical_slug", "").split("/")[-1] == model_id)
),
None,
)
async def refresh_models_cache(self) -> None:
"""Refresh the in-memory models cache from upstream API."""
try:
async with create_session() as session:
provider = (
await session.get(UpstreamProviderRow, self.db_id)
if self.db_id is not None
else None
)
if not provider or not provider.id:
raise HTTPException(status_code=404, detail="Provider not found")
db_models = await list_models(
session=session,
upstream_id=provider.id,
include_disabled=False,
apply_fees=False,
)
db_model_ids: set[str] = {model.id for model in db_models}
models = await self.fetch_models()
model_ids = [model.id for model in models]
diff = set(db_model_ids) - set(model_ids)
for db_model_id in diff:
found_db_model = next(
(
model_obj
for model_obj in db_models
if model_obj.id == db_model_id
)
)
models.append(found_db_model)
models_with_fees = [
self._apply_provider_fee_to_model(m) for m in models
]
try:
sats_to_usd = sats_usd_price()
self._models_cache = [
_update_model_sats_pricing(m, sats_to_usd)
for m in models_with_fees
]
except Exception:
self._models_cache = models_with_fees
self._models_by_id = {m.forwarded_model_id or m.id: m for m in self._models_cache}
except Exception as e:
logger.error(
f"Failed to refresh models cache for {self.provider_type or self.base_url}",
extra={"error": str(e), "error_type": type(e).__name__},
)
def get_cached_models(self) -> list[Model]:
"""Get cached models for this provider.
Returns:
List of cached Model objects
"""
return self._models_cache
def get_cached_model_by_id(self, model_id: str) -> Model | None:
"""Get a specific cached model by ID.
Args:
model_id: Model identifier
Returns:
Model object or None if not found
"""
return self._models_by_id.get(model_id)
@classmethod
async def create_account_static(cls) -> dict[str, object]:
"""Create a new account with the provider (class method, no instance needed).
Returns:
Dict with account creation details including api_key
Raises:
NotImplementedError: If provider does not support account creation
"""
raise NotImplementedError(
f"Provider {cls.provider_type} does not support account creation"
)
async def create_account(self) -> dict[str, object]:
"""Create a new account with the provider.
Returns:
Dict with account creation details including api_key
Raises:
NotImplementedError: If provider does not support account creation
"""
raise NotImplementedError(
f"Provider {self.provider_type} does not support account creation"
)
async def initiate_topup(self, amount: int) -> TopupData:
"""Initiate a Lightning Network top-up for the provider account.
Args:
amount: Amount in currency units to top up
Returns:
TopupData with standardized invoice information
Raises:
NotImplementedError: If provider does not support top-up
"""
raise NotImplementedError(
f"Provider {self.provider_type} does not support top-up"
)
async def get_balance(self) -> float | None:
"""Get the current account balance from the provider.
Returns:
Float representing the balance amount, or None if not supported/available.
Typically in USD or the provider's credit unit.
Raises:
NotImplementedError: If provider does not support balance checking (default behavior)
"""
raise NotImplementedError(
f"Provider {self.provider_type} does not support balance checking"
)