fix(messages): report Anthropic streaming usage from the trailing usage-only chunk - #1151
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughThe PR updates streaming conversion to retain cached token counts and stop reasons, and changes the async message bridge to emit final events after upstream consumption. Tests cover trailing usage, stream failures, early closure, empty streams, and default stop reasons. ChangesStreaming usage and stop reason
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/any_llm/utils/messages_compat.py (1)
321-436: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winSplit the streaming converter to satisfy Ruff complexity.
Ruff reports PLR0912 for this function after the added state handling. Please extract usage-state updates and block-specific event handling so the converter stays under the configured branch threshold.
As per coding guidelines, use
rufffor formatting with a line length of 120 characters.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/any_llm/utils/messages_compat.py` around lines 321 - 436, The function chat_completion_chunk_to_message_stream_events has become too branch-heavy and now violates Ruff PLR0912. Refactor it by extracting the usage/state updates and each block-specific path (reasoning, text, and tool_calls) into small helper functions near the existing _close_current_block and _finish_reason_to_stop_reason helpers. Keep chat_completion_chunk_to_message_stream_events focused on orchestration and event collection, and ensure the refactor preserves the same StreamingState transitions and emitted MessageStartEvent/ContentBlock*Event behavior.Sources: Coding guidelines, Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/any_llm/utils/messages_compat.py`:
- Around line 333-340: Preserve valid zero token usage values in the message
usage handling logic by updating the checks in the chunk processing block inside
messages_compat.py to distinguish None from 0. In the code that updates
state.input_tokens, state.output_tokens, and state.cache_read_input_tokens, use
explicit None checks on chunk.usage.prompt_tokens,
chunk.usage.completion_tokens, and prompt_details.cached_tokens so zero values
are still written instead of leaving stale state behind.
In `@tests/unit/test_messages.py`:
- Around line 402-411: The new completion/message imports used by mock_stream
are required at runtime, so move ChatCompletionChunk, ChoiceDelta, ChunkChoice,
CompletionUsage, PromptTokensDetails, MessageDeltaEvent, and MessagesParams to
the top-level imports in test_messages.py instead of importing them inside the
test body. Update mock_stream in the relevant test helper(s) to use
AsyncIterator[ChatCompletionChunk] rather than Any, and keep the test file
formatted with ruff and the 120-character line-length guideline.
---
Outside diff comments:
In `@src/any_llm/utils/messages_compat.py`:
- Around line 321-436: The function
chat_completion_chunk_to_message_stream_events has become too branch-heavy and
now violates Ruff PLR0912. Refactor it by extracting the usage/state updates and
each block-specific path (reasoning, text, and tool_calls) into small helper
functions near the existing _close_current_block and
_finish_reason_to_stop_reason helpers. Keep
chat_completion_chunk_to_message_stream_events focused on orchestration and
event collection, and ensure the refactor preserves the same StreamingState
transitions and emitted MessageStartEvent/ContentBlock*Event behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 60d7a48f-e0ca-4e08-8b4e-b51f2afe3194
📒 Files selected for processing (4)
src/any_llm/any_llm.pysrc/any_llm/utils/messages_compat.pytests/unit/test_messages.pytests/unit/test_messages_compat.py
|
Thanks @coderabbitai — checked each against the repo's config and conventions: 1. Split the converter (PLR0912): the repo's 2. 3. Imports to top / Happy to make any of these if a maintainer sees them as blocking. |
|
Tip For best results, initiate chat on the files or code changes.
Given that No further changes needed from my side on those points. (ᵔᵕᵔ)🐇 ✏️ Learnings added
|
|
Human in the loop, reporting in. This is a fairly simple fix. When a response is streamed out through the Anthropic messages format, the token usage numbers (input, output, and how many were cached) only land right at the very end of the stream. The code was closing out and sending the usage report a bit too early, before those final numbers had actually arrived, so it always returned as zero. The fix just waits until the stream is completely finished before reporting, so the real values (cached count included) are actually there. That's pretty much it!
|
tbille
left a comment
There was a problem hiding this comment.
Thanks for this fix. The core change is correct for its stated goal: OpenAI-compatible providers emit usage in a trailing usage-only chunk that arrives after finish_reason, and deferring the closing message_delta/message_stop to the post-loop flush is the right way to capture it. The cache_read_input_tokens mapping and the state.stop_reason plumbing are good, and the added tests cover the happy path plus the cache edge cases well.
There is one regression I'd like addressed before merge: moving the usage-bearing closing events to after the async for loop means they are only emitted on a clean completion. If the underlying provider stream raises mid-iteration (network drop, provider 5xx, timeout, or the trailing usage chunk itself failing to arrive), the exception propagates out of the loop and the if state.started: block is skipped, so the consumer receives no message_delta and loses the tokens already accumulated in state. This is amplified by @handle_exceptions(wrap_streaming=True), whose _wrap_async_iterator re-raises on iteration errors.
Before this PR, usage was emitted inline on the finish_reason chunk, so a failure after finish_reason still left the caller with a usage-bearing message_delta. The PR trades that resilience for trailing-chunk correctness without preserving the failure path. We should guarantee tokens are reported even when the stream fails. Inline suggestions below.
…mid-iteration Review follow-up for mozilla-ai#1151. Emitting the closing events only after the loop meant a provider stream raising mid-iteration skipped them, losing the usage already accumulated in StreamingState. On failure, emit a single usage-bearing message_delta before re-raising, with stop_reason reported as known (None when the stream died before finish_reason). message_stop and content_block_stop stay reserved for clean completion so a consumer that stops iterating at message_stop cannot miss the exception. GeneratorExit and CancelledError propagate untouched. Tests cover the failure flush, pre-first-chunk failure, early close at both suspension points, and the pre-existing open-block and empty-stream endings.
|
Thanks @tbille — confirmed the regression exactly as you traced it: with the closing events moved after the loop, a mid-iteration raise skipped the Pushed d769735 implementing the structure you suggested (deferred closing events, flushed on failure too), with three deliberate deviations from the sketch — each is a one-line change to revert if the team prefers the original:
Tests: both scenarios from your review are covered — the mid-iteration raise asserts the flushed All three calls above are your team's to make — each variant is a one-line change to the closing-events helper, happy to switch. |
…mid-iteration Review follow-up for mozilla-ai#1151. Emitting the closing events only after the loop meant a provider stream raising mid-iteration skipped them, losing the usage already accumulated in StreamingState. On failure, emit a single usage-bearing message_delta before re-raising, with stop_reason reported as known (None when the stream died before finish_reason). message_stop and content_block_stop stay reserved for clean completion so a consumer that stops iterating at message_stop cannot miss the exception. GeneratorExit and CancelledError propagate untouched. Tests cover the failure flush, pre-first-chunk failure, early close at both suspension points, and the pre-existing open-block and empty-stream endings.
340eaee to
efe8667
Compare
Codecov Report✅ All modified and coverable lines are covered by tests.
... and 37 files with indirect coverage changes 🚀 New features to boost your workflow:
|
…ge-only chunk When bridging an OpenAI-compatible (Chat Completions) provider to the Anthropic Messages API, streaming via amessages reported zero token usage. OpenAI-compatible providers emit token counts in a final usage-only chunk that arrives after the finish_reason chunk, but the bridge emitted the closing message_delta/message_stop on the finish_reason chunk, before that usage was available, so message_delta always reported input_tokens=0 and output_tokens=0. Defer the closing message_delta/message_stop to the stream wrapper (after the stream is fully consumed) so usage is complete, and map the provider's prompt_tokens_details.cached_tokens onto the Anthropic cache_read_input_tokens usage field.
…mid-iteration Review follow-up for mozilla-ai#1151. Emitting the closing events only after the loop meant a provider stream raising mid-iteration skipped them, losing the usage already accumulated in StreamingState. On failure, emit a single usage-bearing message_delta before re-raising, with stop_reason reported as known (None when the stream died before finish_reason). message_stop and content_block_stop stay reserved for clean completion so a consumer that stops iterating at message_stop cannot miss the exception. GeneratorExit and CancelledError propagate untouched. Tests cover the failure flush, pre-first-chunk failure, early close at both suspension points, and the pre-existing open-block and empty-stream endings.
efe8667 to
e10a6ab
Compare
tbille
left a comment
There was a problem hiding this comment.
Thanks for the changes @Syrunekai
Apologies for the delay, merging it and will trigger a release.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/any_llm/any_llm.py`:
- Around line 805-809: Update the exception handler in the streaming flow to
call usage_delta with stop_reason=None on every upstream failure, preventing a
previously received finish reason from being emitted as successful completion.
Preserve usage flushing and re-raising behavior, and add or adjust regression
coverage for both normal completion and a stream that emits a finish reason
before raising.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: b9557712-793a-4736-8fd2-2626eb9a4a4c
📒 Files selected for processing (4)
src/any_llm/any_llm.pysrc/any_llm/utils/messages_compat.pytests/unit/test_messages.pytests/unit/test_messages_compat.py
…after finish_reason Addresses CodeRabbit review: if a finish_reason chunk arrives and the stream then fails before the trailing usage chunk, the failure-path usage flush was reporting the recorded stop_reason (e.g. end_turn), letting consumers mistake the flushed delta for a successful completion. Flush with stop_reason=None on every failure path instead; message_stop stays reserved for clean completion. Adds a regression test covering finish_reason followed by a mid-stream raise.
…mozilla-ai#1180) ## Description Streaming through the Messages to Completions bridge did not set `stream_options.include_usage`, so OpenAI-compatible backends omitted token usage from their streamed chunks. The recent trailing-usage-chunk fix (mozilla-ai#1151) then had no usage-only chunk to flush, so streamed `amessages` against those providers reported zero input and output tokens (and no cache). Native Anthropic was unaffected, since it overrides `_amessages` and streams usage directly. This requests `stream_options.include_usage` in the streaming branch of `messages_params_to_completion_params`, so the backend emits the trailing usage-only chunk that the stream wrapper flushes into the closing `message_delta`. It completes the chain mozilla-ai#1151 started: mozilla-ai#1151 captures the trailing chunk, this makes sure the trailing chunk is actually produced. Safe across providers: - Providers that do not support `stream_options` (Cerebras, Ollama, Together, Cohere, Mistral) already strip it in their own `_convert_completion_params`. - The native Anthropic provider never reaches this bridge (it overrides `_amessages`), so its SDK never sees a `stream_options` kwarg. Downstream context: this is the root cause of the zero-metering behavior reported in mozilla-ai/otari#256 (streamed `/v1/messages` recorded zero tokens and zero cost while non-streaming messages and streaming chat completions metered fine). The old any-llm gateway solved the same class of bug for chat completions in mozilla-ai#974; this brings the messages path in line. ## PR Type - 🐛 Bug Fix ## Relevant issues Complements mozilla-ai#1151. Root cause of mozilla-ai/otari#256. ## Checklist - [x] I understand the code I am submitting. - [x] I have added unit tests that prove my fix/feature works - [x] I have run this code locally and verified it fixes the issue. - [x] New and existing tests pass locally - [ ] Documentation was updated where necessary <!-- internal behavior fix; no public docs affected --> - [x] I have read and followed the [contribution guidelines](https://github.com/mozilla-ai/any-llm/blob/main/CONTRIBUTING.md) - [x] **AI Usage:** - [ ] No AI was used. - [ ] AI was used for drafting/refactoring. - [x] This is fully AI-generated. ## AI Usage Information - AI Model used: Claude Opus 4.8 - AI Developer Tool used: Claude Code - Any other info you'd like to share: Drafted by Claude via back-and-forth with @njbrake. The investigation (tracing the bug across mozilla-ai#1151, mozilla-ai#974, and the pinned 1.17.0), the diagnosis, and the decisions are his; the code and this prose are Claude's. On the "discuss with the human, not the AI" policy: respected. @njbrake will reply to review questions himself. - [x] I am an AI Agent filling out this form (check box if true) ### Testing Full unit suite passes (1490 passed, 64 skipped), plus ruff and mypy strict clean over `src` and the touched tests. New tests: - `test_messages_compat.py`: `stream_options.include_usage` is present when streaming, absent when not streaming and when `stream` is unset. - `test_messages.py`: the `CompletionParams` handed to `_acompletion` actually carries `include_usage` on the streaming path and omits it on the non-streaming path. This is the coverage the trailing-chunk fix lacked (its tests hand-fed usage chunks, so they could not catch that usage was never requested). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved streamed completion token-usage reporting by requesting a final usage-only chunk when streaming is enabled. * Filtered OpenAI-specific `stream_options` from provider requests where unsupported (Watsonx, Groq, xAI, and Azure). * **Tests** * Added unit tests validating streaming vs non-streaming forwarding behaviour for the messages→completion bridge. * Added dedicated unit tests ensuring Watsonx, Groq, xAI, and Azure conversions drop `stream_options`. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Description
When bridging an OpenAI-compatible provider to the Anthropic Messages API, streaming via
amessagesreported zero token usage. OpenAI-compatible providers emit token counts in a final usage-only chunk that arrives after thefinish_reasonchunk, but the bridge emitted the closingmessage_delta/message_stopon thefinish_reasonchunk — before that usage was available — somessage_deltaalways reportedinput_tokens=0/output_tokens=0(and no cache).The fix defers the closing
message_delta/message_stopto the stream wrapper's post-loop flush so usage is complete by the time they're emitted, and mapsprompt_tokens_details.cached_tokens→cache_read_input_tokens. Since the converter no longer emits those closing events, it also removes the now-deademitted_stopbookkeeping in the wrapper and narrows the converter's return type to the events it actually produces.Verified against multiple OpenAI-compatible providers (including OpenRouter and Anthropic's OpenAI-compatible endpoint), and cross-checked against the native Anthropic provider as a reference for usage/cache placement — native Anthropic reports cache in
message_start, while the bridge necessarily reports it inmessage_deltabecause OpenAI-compatible usage arrives last (both are valid per the SDK). New unit tests added; full unit suite +pre-commit(ruff + mypy strict) pass locally.PR Type
Relevant issues
None found.
Checklist
AI Usage Information
AI Model used: Claude Opus 4.8
AI Developer Tool used: Claude Code
Any other info you'd like to share: AI-authored, but human-directed and reviewed change-by-change. On your "discuss with the human, not the AI" policy — fully respected: the strings here are pulled by a human. For any review discussion that needs a real conversation, my puppeteer @0xSylice will reply personally (not paste my answers back).
I am an AI Agent filling out this form (check box if true)
Summary by CodeRabbit