Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Changelog

## 0.9.1 — 2026-08-07

### Added

- **Token-detail passthrough on streamed usage.** The final `include_usage`
frame's `prompt_tokens_details` / `completion_tokens_details` (reasoning and
cached-token breakdowns) and the Anthropic `cache_read_input_tokens` /
`cache_creation_input_tokens` split now survive LiteLLM stream aggregation
instead of being flattened to three bare counts. Forwarding
`cache_read_input_tokens` also lets LiteLLM's estimate-path cost math apply
the cache-read discount (the gateway folds cache reads into `prompt_tokens`,
matching LiteLLM's own convention, so the subtraction is always safe).
Both `_to_generic_chunk` branches — the choice-less usage frame and a
choices-bearing chunk carrying usage inline — go through one shared
`_usage_dict` builder, so the two paths cannot disagree about which detail
fields survive.

- **Spec-correct Responses API usage.** `/v1/responses` (non-stream and SSE)
now always emits `input_tokens_details.cached_tokens` and
`output_tokens_details.reasoning_tokens` — both REQUIRED by
`openai.types.responses.ResponseUsage`; typed openai-python clients crashed
on their absence. Values default to 0, only spec keys are projected (no
leaked `audio_tokens` / prediction-token fields), non-dict upstream garbage
coerces to 0, and Anthropic's top-level `cache_read_input_tokens` maps into
`cached_tokens` (those models carry no `prompt_tokens_details`). On the SSE
path a later bare usage frame merges instead of wiping detail captured
earlier.

### Fixed

- **Streamed calls no longer die on usage frames without detail blocks.**
`prompt_tokens_details` / `completion_tokens_details` are pydantic extras on
`ChatUsage` (`extra="allow"`), not declared fields — attribute access on an
absent extra raises `AttributeError`. Any streamed call whose usage frame
omitted them (Anthropic-shaped, or bare three-count) crashed at the final
frame; LiteLLM surfaced it as `APIConnectionError`, which retry-on-connection
clients treated as transient and retried an already-settled call — paying
twice for one completion. Details are now read from `model_extra`, where
extras actually live. Regression-locked for the bare, Anthropic, and
OpenAI-shaped frames end-to-end through `CustomStreamWrapper` +
`stream_chunk_builder`.

## 0.9.0 — 2026-07-24

### Added
Expand Down
2 changes: 1 addition & 1 deletion blockrun_litellm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@
"model_ids",
"register",
]
__version__ = "0.9.0"
__version__ = "0.9.1"
72 changes: 54 additions & 18 deletions blockrun_litellm/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from litellm.types.utils import GenericStreamingChunk

from blockrun_llm.types import APIError as BlockRunAPIError
from blockrun_llm.types import ChatCompletionChunk
from blockrun_llm.types import ChatCompletionChunk, ChatUsage

from blockrun_litellm import _adapter

Expand Down Expand Up @@ -249,12 +249,53 @@ def _native_extras(chunk: ChatCompletionChunk) -> Dict[str, Any]:
# leaking a stray ``cost_usd`` field into ``provider_specific_fields``.
extras.pop("cost_usd", None)
if chunk.usage is not None:
usage_extra = chunk.usage.model_extra or {}
if usage_extra:
extras.setdefault("usage_details", {}).update(usage_extra)
# NB: ``prompt_tokens_details`` / ``completion_tokens_details`` are NOT
# declared fields on ChatUsage — they are pydantic extras (``extra =
# "allow"``), so they already live in ``model_extra`` and are picked up
# by the dict() copy below. Never read them by attribute: access to an
# absent pydantic extra raises AttributeError, it does not return None.
usage_details: Dict[str, Any] = dict(chunk.usage.model_extra or {})
if chunk.usage.cache_read_input_tokens is not None:
usage_details["cache_read_input_tokens"] = chunk.usage.cache_read_input_tokens
if chunk.usage.cache_creation_input_tokens is not None:
usage_details["cache_creation_input_tokens"] = chunk.usage.cache_creation_input_tokens
if usage_details:
extras.setdefault("usage_details", {}).update(usage_details)
return extras


def _usage_dict(usage: ChatUsage) -> Dict[str, Any]:
"""Map a :class:`ChatUsage` → the plain usage dict LiteLLM's streaming
handler feeds into ``litellm.Usage``.

Carries the token-detail passthrough: ``prompt_tokens_details`` /
``completion_tokens_details`` (pydantic extras — read from ``model_extra``,
see :func:`_native_extras`) and the Anthropic cache split. The gateway
folds cache reads INTO ``prompt_tokens`` (matching LiteLLM's own
convention), so forwarding ``cache_read_input_tokens`` lets LiteLLM apply
the cache-read discount instead of billing the full prompt rate.

Used by BOTH ``_to_generic_chunk`` branches: the choice-less
``include_usage`` final frame AND a choices-bearing chunk that carries
usage inline. Keeping one builder means the two paths can never disagree
about which detail fields survive.
"""
out: Dict[str, Any] = {
"prompt_tokens": usage.prompt_tokens,
"completion_tokens": usage.completion_tokens,
"total_tokens": usage.total_tokens,
}
extra = usage.model_extra or {}
for key in ("prompt_tokens_details", "completion_tokens_details"):
if extra.get(key) is not None:
out[key] = extra[key]
if usage.cache_read_input_tokens is not None:
out["cache_read_input_tokens"] = usage.cache_read_input_tokens
if usage.cache_creation_input_tokens is not None:
out["cache_creation_input_tokens"] = usage.cache_creation_input_tokens
return out


def _to_generic_chunk(chunk: ChatCompletionChunk) -> GenericStreamingChunk:
"""Map a BlockRun :class:`ChatCompletionChunk` → LiteLLM
:class:`GenericStreamingChunk` (a ``TypedDict``).
Expand All @@ -275,13 +316,7 @@ def _to_generic_chunk(chunk: ChatCompletionChunk) -> GenericStreamingChunk:
# off them instead of re-estimating the prompt with its own tokenizer
# (tiktoken drifts ~37% vs the gateway's real upstream count). Older
# gateways that never send this frame still hit the usage=None path.
usage = None
if chunk.usage is not None:
usage = {
"prompt_tokens": chunk.usage.prompt_tokens,
"completion_tokens": chunk.usage.completion_tokens,
"total_tokens": chunk.usage.total_tokens,
}
usage = _usage_dict(chunk.usage) if chunk.usage is not None else None
# NOTE: deliberately do NOT set tool_use here. This is the post-finish
# usage frame; adding the key changes LiteLLM's CustomStreamWrapper
# post-finish guard and lets the frame survive even without
Expand Down Expand Up @@ -318,13 +353,14 @@ def _to_generic_chunk(chunk: ChatCompletionChunk) -> GenericStreamingChunk:
# Anthropic adapter — see _iter_stream_chunks(), which wraps this function.

# BlockRun's per-chunk usage is rarely populated; LiteLLM tolerates None.
usage = None
if chunk.usage is not None:
usage = {
"prompt_tokens": chunk.usage.prompt_tokens,
"completion_tokens": chunk.usage.completion_tokens,
"total_tokens": chunk.usage.total_tokens,
}
# When a provider DOES attach usage to a choices-bearing chunk (instead of
# a separate include_usage frame), it must carry the same detail fields as
# the choice-less branch — a bare three-count dict here would contradict
# the full detail riding on provider_specific_fields, and LiteLLM's chunk
# builder resets prompt_tokens_details unconditionally from the LAST
# usage-bearing chunk (streaming_chunk_builder_utils), so an asymmetric
# frame can null out detail a previous frame delivered.
usage = _usage_dict(chunk.usage) if chunk.usage is not None else None

return GenericStreamingChunk(
text=text,
Expand Down
77 changes: 66 additions & 11 deletions blockrun_litellm/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -1735,6 +1735,57 @@ def _responses_to_chat(
return model, messages, openai_kwargs, bool(body.get("stream"))


def _int_or_zero(value: Any) -> int:
"""Coerce an upstream token count to a non-negative int (0 on garbage).

The detail blocks arrive as untyped pydantic extras — whatever JSON the
upstream sent. This is the guard between that and our typed public
Responses surface (LiteLLM has the same guard in ``Usage.__init__``).
"""
if isinstance(value, bool): # bool is an int subclass; reject explicitly
return 0
if isinstance(value, int):
return max(value, 0)
if isinstance(value, float) and value == int(value):
return max(int(value), 0)
return 0


def _usage_to_responses(usage: Dict[str, Any]) -> Dict[str, Any]:
"""chat.completion ``usage`` dict → Responses API ``usage`` object.

Spec-correct per ``openai.types.responses.ResponseUsage``: BOTH
``input_tokens_details`` and ``output_tokens_details`` are REQUIRED, as are
``cached_tokens`` / ``reasoning_tokens`` inside them — so they are always
emitted, defaulting to 0, never conditionally omitted. Only the spec keys
are projected: splatting the chat-shaped dicts verbatim would leak
``audio_tokens`` / ``accepted_prediction_tokens`` etc. into a surface that
doesn't define them, and would pass through non-dict garbage unchecked.

Anthropic models carry cache reads ONLY in the top-level
``cache_read_input_tokens`` (no ``prompt_tokens_details``), so that field
is the fallback for ``cached_tokens``. Safe to equate: the gateway folds
cache reads into ``prompt_tokens``, matching what OpenAI's own
``cached_tokens`` is a subset of.
"""
ptd = usage.get("prompt_tokens_details")
ctd = usage.get("completion_tokens_details")
ptd = ptd if isinstance(ptd, dict) else {}
ctd = ctd if isinstance(ctd, dict) else {}
cached = _int_or_zero(ptd.get("cached_tokens"))
if cached == 0:
cached = _int_or_zero(usage.get("cache_read_input_tokens"))
return {
"input_tokens": _int_or_zero(usage.get("prompt_tokens")),
"output_tokens": _int_or_zero(usage.get("completion_tokens")),
"total_tokens": _int_or_zero(usage.get("total_tokens")),
"input_tokens_details": {"cached_tokens": cached},
"output_tokens_details": {
"reasoning_tokens": _int_or_zero(ctd.get("reasoning_tokens"))
},
}


def _chat_payload_to_response(payload: Dict[str, Any], model: str) -> Dict[str, Any]:
"""chat.completion dict → Responses API ``response`` object (non-streaming)."""
choice = (payload.get("choices") or [{}])[0]
Expand All @@ -1758,11 +1809,7 @@ def _chat_payload_to_response(payload: Dict[str, Any], model: str) -> Dict[str,
}
],
"output_text": text,
"usage": {
"input_tokens": usage.get("prompt_tokens", 0),
"output_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
},
"usage": _usage_to_responses(usage),
}


Expand Down Expand Up @@ -1824,7 +1871,7 @@ def base(
seq += 1

parts: List[str] = []
usage_out = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
usage_out: Dict[str, Any] = _usage_to_responses({})

async with _get_semaphore():
try:
Expand All @@ -1849,11 +1896,19 @@ def base(
seq += 1
u = cd.get("usage")
if u:
usage_out = {
"input_tokens": u.get("prompt_tokens", 0),
"output_tokens": u.get("completion_tokens", 0),
"total_tokens": u.get("total_tokens", 0),
}
# MERGE, don't replace: the gateway emits one canonical
# include_usage final frame, but if a provider ever sends a
# later bare usage frame (three counts, no details), a
# wholesale reassignment would wipe detail captured
# earlier. Scalar counts take the latest value; the two
# detail blocks only advance, never regress to zero.
fresh = _usage_to_responses(u)
for block in ("input_tokens_details", "output_tokens_details"):
prev = usage_out.get(block) or {}
if any(fresh[block].values()) or not any(prev.values()):
continue
fresh[block] = prev
usage_out = fresh
except PaymentError as exc:
yield _responses_event(
seq,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "blockrun-litellm"
version = "0.9.0"
version = "0.9.1"
description = "LiteLLM adapter for BlockRun — call x402-paid AI models via LiteLLM (custom provider or local OpenAI-compatible proxy)"
readme = "README.md"
license = "MIT"
Expand Down
3 changes: 3 additions & 0 deletions tests/test_fingerprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def _fingerprinted_response() -> ChatResponse:
cache_read_input_tokens=4,
# OpenAI-native nested breakdown (extra → must survive)
prompt_tokens_details={"cached_tokens": 4},
completion_tokens_details={"reasoning_tokens": 3},
),
)

Expand Down Expand Up @@ -113,6 +114,7 @@ def test_build_response_preserves_usage_cache_details() -> None:
usage = dumped["usage"]
assert usage["cache_read_input_tokens"] == 4
assert usage["prompt_tokens_details"]["cached_tokens"] == 4
assert usage["completion_tokens_details"]["reasoning_tokens"] == 3


def test_build_response_preserves_reasoning_content() -> None:
Expand Down Expand Up @@ -173,4 +175,5 @@ def test_proxy_dump_preserves_fingerprint() -> None:
assert dumped["service_tier"] == "default"
assert dumped["usage"]["cache_read_input_tokens"] == 4
assert dumped["usage"]["prompt_tokens_details"]["cached_tokens"] == 4
assert dumped["usage"]["completion_tokens_details"]["reasoning_tokens"] == 3
assert dumped["choices"][0]["message"]["reasoning_content"] == "because reasons"
10 changes: 9 additions & 1 deletion tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,15 @@ def test_responses_non_streaming_shape() -> None:
assert j["output"][0]["type"] == "message"
assert j["output"][0]["content"][0] == {"type": "output_text", "text": "我是助手", "annotations": []}
assert j["output_text"] == "我是助手"
assert j["usage"] == {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}
# input/output_tokens_details are REQUIRED by the Responses spec
# (openai.types.responses.ResponseUsage) — always present, zero-defaulted.
assert j["usage"] == {
"input_tokens": 5,
"output_tokens": 3,
"total_tokens": 8,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0},
}
assert j["id"].startswith("resp_")


Expand Down
Loading