fix(streaming): use parse_obj fallback to preserve nested event types (fixes #941)#1542
fix(streaming): use parse_obj fallback to preserve nested event types (fixes #941)#1542Echolonius wants to merge 2 commits into
Conversation
…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.
…#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>
Comparison with #1467#1467 uses Concretely, after #1467's fix, for a raw-dict 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'
if event.delta.type == "text_delta": # AttributeError here
content.text += event.delta.textSo the text is never accumulated — the crash just moves from This PR uses 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 correctlyThe regression test |
Problem
When Pydantic's union discriminator silently returns the raw dict instead of a typed model,
accumulate_eventraisesTypeErrorat the secondisinstancecheck: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 — soevent.deltainside acontent_block_deltaevent remains{"type": "text_delta", "text": "..."}. The momentaccumulate_eventaccessesevent.delta.type, a secondaryAttributeErroris raised and the text is never accumulated.PR #1167 fixes the root cause in
_models.pybut changes core model-construction logic in a way that is hard to verify will not have side effects elsewhere.This fix
Replace
model_constructwithparse_obj()— the SDK's existing Pydantic v1/v2 compatibility helper (in_compat.py).parse_objruns full validation, so nested union fields likedeltaare properly resolved to their typed objects (TextDelta,InputJSONDelta, etc.), not left as raw dicts. Ifparse_objitself raises, the originalTypeErroris 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 andparse_obj, apply fallback inaccumulate_eventsrc/anthropic/lib/streaming/_beta_messages.py— same for the beta pathtests/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 dictcontent_block_deltathroughaccumulate_eventand assertssnapshot.content[0].text == "hello", which only passes ifevent.delta.typewas accessible (i.e. delta is a typedTextDelta, 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)TestBetaRawDictFallback(intest_partial_json.py)Written with Claude Code