Skip to content

fix(streaming): use parse_obj fallback to preserve nested event types (fixes #941)#1542

Open
Echolonius wants to merge 2 commits into
anthropics:mainfrom
Echolonius:fix/streaming-deserialization-fallback
Open

fix(streaming): use parse_obj fallback to preserve nested event types (fixes #941)#1542
Echolonius wants to merge 2 commits into
anthropics:mainfrom
Echolonius:fix/streaming-deserialization-fallback

Conversation

@Echolonius
Copy link
Copy Markdown

@Echolonius Echolonius commented May 14, 2026

Problem

When Pydantic's union discriminator silently returns the raw dict instead of a typed model, accumulate_event raises TypeError at the second isinstance check:

Unexpected event runtime type, after deserialising twice - {'type': 'content_block_delta', 'index': 0, 'delta': {'type': 'text_delta', 'text': '...\}} - <class 'dict'>

Why existing PRs (#1167, #1287, #1448, #1467) do not fully fix it

PRs #1287, #1448, and #1467 all add a type-map fallback, but use model_construct(**raw). That method skips Pydantic validation and leaves nested fields as raw dicts — so event.delta inside a content_block_delta event remains {"type": "text_delta", "text": "..."}. The moment accumulate_event accesses event.delta.type, a secondary AttributeError is raised and the text is never accumulated.

PR #1167 fixes the root cause in _models.py but changes core model-construction logic in a way that is hard to verify will not have side effects elsewhere.

This fix

Replace model_construct with parse_obj() — the SDK's existing Pydantic v1/v2 compatibility helper (in _compat.py). parse_obj runs full validation, so nested union fields like delta are properly resolved to their typed objects (TextDelta, InputJSONDelta, etc.), not left as raw dicts. If parse_obj itself raises, the original TypeError is preserved.

The same fallback is applied symmetrically to _beta_messages.py.

Changes

  • src/anthropic/lib/streaming/_messages.py — add _RAW_EVENT_TYPE_MAP, import concrete event classes and parse_obj, apply fallback in accumulate_event
  • src/anthropic/lib/streaming/_beta_messages.py — same for the beta path
  • tests/lib/streaming/test_messages.py — three regression tests for the non-beta path (single delta accumulates, multiple deltas accumulate, unknown type still errors)
  • tests/lib/streaming/test_partial_json.py — three matching regression tests for the beta path (TestBetaRawDictFallback)

Test plan

  • test_accumulate_event_raw_dict_text_delta_is_fully_typed — passes a raw dict content_block_delta through accumulate_event and asserts snapshot.content[0].text == "hello", which only passes if event.delta.type was accessible (i.e. delta is a typed TextDelta, not a dict)
  • test_accumulate_event_raw_dict_multiple_deltas_accumulate — three raw dict deltas build up "Hello there!"
  • test_accumulate_event_raw_dict_unknown_type_still_raises — unknown type string still raises (TypeError, RuntimeError)
  • Beta variants of all three in TestBetaRawDictFallback (in test_partial_json.py)

Written with Claude Code

…fixes anthropics#941)

When Pydantic's union discriminator silently returns the raw dict instead of
a typed model, accumulate_event raises TypeError on the second isinstance check.
Four community PRs added a type-map fallback but all used model_construct(**raw),
which skips validation and leaves nested fields (e.g. the delta inside a
content_block_delta) as raw dicts — a secondary AttributeError hits the moment
accumulate_event accesses event.delta.type.

This replaces model_construct with parse_obj(), the SDK's existing Pydantic
v1/v2 compat helper, so nested union fields are fully resolved to their typed
objects. The fallback is applied symmetrically to _beta_messages.py.

Three regression tests verify: a single text_delta dict accumulates correctly
into the snapshot (proving event.delta is a typed TextDelta, not a raw dict),
multiple deltas all accumulate, and an unknown event type still raises TypeError.
@Echolonius Echolonius requested a review from a team as a code owner May 14, 2026 06:24
…#941

Add TestBetaRawDictFallback to test_partial_json.py covering the same
raw-dict fallback in _beta_messages.py that the non-beta tests cover in
test_messages.py. Corrects the "unknown type still raises" assertions in
both files to accept (TypeError, RuntimeError) — which exception fires
depends on whether construct_type_unchecked returns the raw dict or a
malformed-but-valid BaseModel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Echolonius
Copy link
Copy Markdown
Author

Comparison with #1467

#1467 uses construct_type_unchecked(type_=RawContentBlockDeltaEvent, value=event) as the fallback. That converts the outer event into a BaseModel, so the first isinstance check passes — but construct_type_unchecked calls model_construct internally, which skips Pydantic validation and leaves nested fields as raw dicts.

Concretely, after #1467's fix, for a raw-dict content_block_delta event:

event.type        # "content_block_delta" ✓
event.delta       # {"type": "text_delta", "text": "hello"}  ← still a dict
event.delta.type  # AttributeError: 'dict' object has no attribute 'type'

accumulate_event then hits this line:

if event.delta.type == "text_delta":   # AttributeError here
    content.text += event.delta.text

So the text is never accumulated — the crash just moves from TypeError to AttributeError.

This PR uses parse_obj(target_cls, raw) instead. parse_obj runs full Pydantic validation, so nested union fields are resolved to their typed objects:

event.type        # "content_block_delta" ✓
event.delta       # TextDelta(type="text_delta", text="hello") ✓
event.delta.type  # "text_delta" ✓  — no AttributeError
content.text += event.delta.text  # works correctly

The regression test test_accumulate_event_raw_dict_text_delta_is_fully_typed specifically catches this: it asserts snapshot.content[0].text == "hello", which only passes if event.delta was a typed object (not a dict) when accumulate_event ran.

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.

1 participant