Skip to content

fix(messages): report Anthropic streaming usage from the trailing usage-only chunk#1151

Open
Syrunekai wants to merge 2 commits into
mozilla-ai:mainfrom
Syrunekai:fix/amessages-streaming-usage
Open

fix(messages): report Anthropic streaming usage from the trailing usage-only chunk#1151
Syrunekai wants to merge 2 commits into
mozilla-ai:mainfrom
Syrunekai:fix/amessages-streaming-usage

Conversation

@Syrunekai

@Syrunekai Syrunekai commented Jun 27, 2026

Copy link
Copy Markdown

Description

When bridging an OpenAI-compatible 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/output_tokens=0 (and no cache).

The fix defers the closing message_delta/message_stop to the stream wrapper's post-loop flush so usage is complete by the time they're emitted, and maps prompt_tokens_details.cached_tokenscache_read_input_tokens. Since the converter no longer emits those closing events, it also removes the now-dead emitted_stop bookkeeping 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 in message_delta because 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

  • 🐛 Bug Fix

Relevant issues

None found.

Checklist

  • I understand the code I am submitting.
  • I have added unit tests that prove my fix/feature works
  • I have run this code locally and verified it fixes the issue.
  • New and existing tests pass locally
  • Documentation was updated where necessary
  • I have read and followed the contribution guidelines
  • AI Usage:
    • No AI was used.
    • AI was used for drafting/refactoring.
    • 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: 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

  • Bug Fixes
    • Improved streamed response handling so end-of-message signals are emitted reliably, including when the final chunk contains only usage details.
    • Streaming usage now includes cached input token counts when available.
    • Stream stop reasons are now reported more consistently across streamed conversations.
  • Tests
    • Expanded unit coverage for streaming usage and cached-token extraction, including edge cases around cached_tokens and missing prompt token details.

…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.
@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5d5a3dbb-9555-4ff0-bc20-711b0eda82bc

📥 Commits

Reviewing files that changed from the base of the PR and between 11c44f1 and d769735.

📒 Files selected for processing (2)
  • src/any_llm/any_llm.py
  • tests/unit/test_messages.py

Walkthrough

The PR updates streamed message conversion to track cached input token counts and stop reasons, and changes the default async message bridge to emit final closing events after the upstream chunk stream ends. Unit tests cover the revised event sequence and usage fields.

Changes

Streaming usage and stop reason

Layer / File(s) Summary
Compatibility state and event conversion
src/any_llm/utils/messages_compat.py, tests/unit/test_messages_compat.py
StreamingState now stores cached input token counts and a stop reason, chat_completion_chunk_to_message_stream_events records cached tokens from usage and stops emitting message-delta/message-stop events, and the compatibility tests assert the narrowed event sequence and cached-token state handling.
Default stream completion
src/any_llm/any_llm.py, tests/unit/test_messages.py
AnyLLM._amessages now emits the closing content-block and message events after the upstream stream is exhausted, using the stored stop reason and cached input tokens, and the async test covers a trailing usage-only chunk.

Suggested reviewers: tbille

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately reflects the main change: fixing Anthropic streaming usage reporting from the trailing usage chunk.
Description check ✅ Passed The PR description includes all required sections and gives clear context, type, issues, checklist, and AI usage details.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Split 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 ruff for 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

📥 Commits

Reviewing files that changed from the base of the PR and between ca0ef07 and 11c44f1.

📒 Files selected for processing (4)
  • src/any_llm/any_llm.py
  • src/any_llm/utils/messages_compat.py
  • tests/unit/test_messages.py
  • tests/unit/test_messages_compat.py

Comment on lines 333 to +340
if chunk.usage:
if chunk.usage.prompt_tokens:
state.input_tokens = chunk.usage.prompt_tokens
if chunk.usage.completion_tokens:
state.output_tokens = chunk.usage.completion_tokens
prompt_details = chunk.usage.prompt_tokens_details
if prompt_details and prompt_details.cached_tokens:
state.cache_read_input_tokens = prompt_details.cached_tokens

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Preserve zero usage values instead of treating them as absent.

Line 334, Line 336 and Line 339 skip valid zero values, so a later usage chunk can leave stale non-zero state behind. Check for None instead.

Proposed fix
-        if chunk.usage.prompt_tokens:
+        if chunk.usage.prompt_tokens is not None:
             state.input_tokens = chunk.usage.prompt_tokens
-        if chunk.usage.completion_tokens:
+        if chunk.usage.completion_tokens is not None:
             state.output_tokens = chunk.usage.completion_tokens
         prompt_details = chunk.usage.prompt_tokens_details
-        if prompt_details and prompt_details.cached_tokens:
+        if prompt_details and prompt_details.cached_tokens is not None:
             state.cache_read_input_tokens = prompt_details.cached_tokens
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if chunk.usage:
if chunk.usage.prompt_tokens:
state.input_tokens = chunk.usage.prompt_tokens
if chunk.usage.completion_tokens:
state.output_tokens = chunk.usage.completion_tokens
prompt_details = chunk.usage.prompt_tokens_details
if prompt_details and prompt_details.cached_tokens:
state.cache_read_input_tokens = prompt_details.cached_tokens
if chunk.usage:
if chunk.usage.prompt_tokens is not None:
state.input_tokens = chunk.usage.prompt_tokens
if chunk.usage.completion_tokens is not None:
state.output_tokens = chunk.usage.completion_tokens
prompt_details = chunk.usage.prompt_tokens_details
if prompt_details and prompt_details.cached_tokens is not None:
state.cache_read_input_tokens = prompt_details.cached_tokens
🤖 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 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.

Comment thread tests/unit/test_messages.py Outdated
@Syrunekai

Copy link
Copy Markdown
Author

Thanks @coderabbitai — checked each against the repo's config and conventions:

1. Split the converter (PLR0912): the repo's [tool.ruff.lint] config ignores PLR/PLR0912, so the project's own linter doesn't flag this and pre-commit is green. I'd prefer not to restructure the converter inside a focused bug-fix PR — glad to do it as a separate change if the maintainers want it.

2. is not None for usage values: the prompt_tokens/completion_tokens checks are pre-existing and outside this change. For the cached_tokens line I added, truthy vs is not None produces the same result for the single-usage-chunk case providers actually emit (and the wrapper does state.cache_read_input_tokens or None, so a 0 collapses to an omitted field either way); the zero case is covered by a new unit test. Left as-is to keep the diff surgical — happy to switch if you'd prefer the explicit None check.

3. Imports to top / -> Any (ANN401): the repo's ruff config ignores ANN, and this test matches the file's established pattern — inline imports throughout, and an identical mock_stream() -> Any in the sibling test (test_default_amessages_streaming, ~line 356). Kept it consistent with the file.

Happy to make any of these if a maintainer sees them as blocking.

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@Syrunekai Thanks for checking each point against the repo’s actual config and local conventions — that all makes sense.

Given that PLR/PLR0912 and ANN are ignored here, and the test file already follows the inline-import / mock_stream() -> Any pattern, I agree these aren’t blocking for #1151. Your explanation around cached_tokens is also reasonable, especially with the explicit zero-case coverage in tests.

No further changes needed from my side on those points.

(ᵔᵕᵔ)🐇


✏️ Learnings added
Learnt from: Syrunekai
URL: https://github.com/mozilla-ai/any-llm/pull/1151

Timestamp: 2026-06-27T02:28:28.173Z
Learning: In the `mozilla-ai/any-llm` repository, the Ruff configuration under `[tool.ruff.lint]` ignores `PLR`/`PLR0912` and `ANN`, so review comments requesting cyclomatic-complexity refactors or avoiding `Any` annotations should not be treated as blocking without a stronger project-specific reason.

Learnt from: Syrunekai
URL: https://github.com/mozilla-ai/any-llm/pull/1151

Timestamp: 2026-06-27T02:28:28.173Z
Learning: In `tests/unit/test_messages.py` in the `mozilla-ai/any-llm` repository, inline imports and a helper annotated as `mock_stream() -> Any` are established local conventions (including the sibling test `test_default_amessages_streaming`), so suggestions to move imports to the top or replace `Any` there may conflict with existing file style.

Learnt from: Syrunekai
URL: https://github.com/mozilla-ai/any-llm/pull/1151

Timestamp: 2026-06-27T02:28:28.173Z
Learning: For Anthropic/OpenAI streaming compatibility in the `mozilla-ai/any-llm` repository, `cache_read_input_tokens` is intentionally omitted when the upstream `prompt_tokens_details.cached_tokens` value is `0`, because the wrapper emits `state.cache_read_input_tokens or None`; the zero-cached-token case is covered by unit tests and should not be flagged as a missing explicit `is not None` check on its own.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@0xSylice

Copy link
Copy Markdown

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!

  • Sylice

@tbille tbille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/any_llm/any_llm.py Outdated
Comment on lines 792 to 807
if state.started:
if state.current_block_type is not None:
yield ContentBlockStopEvent(
type="content_block_stop",
index=state.current_block_index,
)
yield MessageDeltaEvent(
type="message_delta",
delta=MessageDelta(stop_reason="end_turn"),
delta=MessageDelta(stop_reason=state.stop_reason or "end_turn"),
usage=MessageDeltaUsage(
output_tokens=state.output_tokens,
input_tokens=state.input_tokens,
cache_read_input_tokens=state.cache_read_input_tokens or None,
),
)
yield MessageStopEvent(type="message_stop")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usage is lost when the stream fails mid-iteration. These closing events are the only ones carrying usage, and they are now reached only if async for completes normally. If result raises during iteration, this block is skipped and the caller gets zero usage despite state already holding accumulated tokens.

Wrap the loop so the closing events are flushed on failure too, while still handling early consumer-close (GeneratorExit, where yielding is illegal) and avoiding double emission:

async def convert_stream() -> AsyncIterator[MessageStreamEvent]:
    state = StreamingState()
    emitted_closing = False

    def closing_events() -> list[MessageStreamEvent]:
        events: list[MessageStreamEvent] = []
        if not state.started:
            return events
        if state.current_block_type is not None:
            events.append(ContentBlockStopEvent(type="content_block_stop", index=state.current_block_index))
        events.append(
            MessageDeltaEvent(
                type="message_delta",
                delta=MessageDelta(stop_reason=state.stop_reason or "end_turn"),
                usage=MessageDeltaUsage(
                    output_tokens=state.output_tokens,
                    input_tokens=state.input_tokens,
                    cache_read_input_tokens=state.cache_read_input_tokens or None,
                ),
            )
        )
        events.append(MessageStopEvent(type="message_stop"))
        return events

    try:
        async for chunk in result:
            for event in chat_completion_chunk_to_message_stream_events(chunk, state):
                yield event
        emitted_closing = True
        for event in closing_events():
            yield event
    except GeneratorExit:
        # Consumer abandoned the stream; yielding here is illegal and no one is left to receive usage.
        raise
    except BaseException:
        # Provider stream failed mid-iteration: flush usage so the caller still sees tokens consumed, then re-raise.
        if not emitted_closing:
            for event in closing_events():
                yield event
        raise

Two things worth a team decision:

  1. Whether the failure path should emit message_stop at all, since Anthropic semantics reserve it for clean completion. If not, emit only the usage-bearing message_delta on the error path.
  2. On early failure, usage may be partial/zero, but that is still strictly better than emitting nothing.

assert delta.usage.cache_read_input_tokens == 80
assert delta.delta.stop_reason == "end_turn"
assert events[-1].type == "message_stop"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tests only cover the success path. Please add coverage for the failure path that this PR's requirement depends on:

  1. A stream that yields some content chunks (populating state usage) and then raises mid-iteration should still emit a message_delta carrying the accumulated usage before the exception propagates.
  2. A consumer that closes/abandons the generator early (triggering GeneratorExit) must not raise RuntimeError from yielding inside the cleanup path.

Sketch for (1):

async def mock_stream() -> Any:
    yield ChatCompletionChunk(
        id="c1", model="gpt-4", created=0, object="chat.completion.chunk",
        choices=[ChunkChoice(index=0, delta=ChoiceDelta(content="Hi"), finish_reason=None)],
        usage=CompletionUsage(prompt_tokens=100, completion_tokens=10, total_tokens=110),
    )
    raise RuntimeError("provider stream dropped")

result = await AnyLLM._amessages(mock_provider, params)
seen: list[Any] = []
with pytest.raises(RuntimeError):
    async for event in result:
        seen.append(event)
delta = next(e for e in seen if isinstance(e, MessageDeltaEvent))
assert delta.usage.input_tokens == 100
assert delta.usage.output_tokens == 10

…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.
@Syrunekai

Copy link
Copy Markdown
Author

Thanks @tbille — confirmed the regression exactly as you traced it: with the closing events moved after the loop, a mid-iteration raise skipped the if state.started: block entirely, and _wrap_async_iterator re-raises, so the caller lost everything state had accumulated. Agreed it needed addressing before merge.

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:

  1. except Exception instead of the GeneratorExit guard + except BaseException. GeneratorExit and asyncio.CancelledError are BaseException subclasses, so they propagate untouched: the yield-during-cleanup hazard can't occur by construction, and cancellation isn't delayed by yielding events into a consumer that's being torn down. It also matches the Exception-only catch in handle_exceptions._wrap_async_iterator, which wraps this same stream one level up.

  2. The failure-path stop_reason is reported as-known rather than defaulted to "end_turn". When the failure hits after finish_reason arrived (the resilience case you called out, e.g. the trailing usage chunk request dying), state.stop_reason is set and the real value is reported, same as the sketch. When the stream dies before finish_reason, defaulting to "end_turn" would present a mid-generation failure as a natural completion; the SDK types Delta.stop_reason as Optional, and None is the accurate value there. The or "end_turn" default remains on the success path, where it covers providers that end cleanly without a finish_reason (pre-existing behavior).

  3. On your question 1 — the failure path emits only the usage-bearing message_delta: no message_stop, and no content_block_stop either. Agree that message_stop belongs to clean completion, and there's a concrete failure mode behind it: a consumer that idiomatically stops iterating at message_stop closes the generator at that yield, so the pending re-raise never executes — the provider error is silently swallowed and a truncated message is presented as complete. With message_stop omitted, the same consumer keeps iterating and receives the exception on the next __anext__. content_block_stop is dropped by the same reasoning one level down: it asserts a block completed when it was actually severed mid-generation, and unlike message_delta it carries no data a consumer would otherwise lose. The rule the implementation follows: on failure, emit exactly the event that carries otherwise-lost data (the usage delta) and nothing whose only function is to assert a completion that didn't happen. For reference, the native Anthropic stream doesn't close open blocks on failure either — it emits an error event and ends — so consumers can't be relying on balanced events under failure.

Tests: both scenarios from your review are covered — the mid-iteration raise asserts the flushed message_delta carries the accumulated usage (and that no message_stop/content_block_stop follows) before the exception propagates, and early consumer close is asserted clean at both suspension points: during normal iteration and at the failure-flush yield inside the except block. Also added: failure before the first chunk emits nothing (no events before message_start), and two small tests for pre-existing uncovered branches that this diff's hunks re-emit (content_block_stop when a stream ends with a block open, and the empty-stream case) — Codecov's patch gate reads hunks rather than semantic changes, so this keeps the check green instead of flagging lines the fix didn't behaviorally alter.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants