7.3 KiB
Parts Implementation Notes
Notes from implementing Phase 1 and Phase 2 (2026-04-04).
Design decisions made during implementation
_process_chunk() as shared method on _BaseResponse
Both sync and async Response need the same logic for handling str | StreamEvent from execute(). Rather than duplicating, I put _process_chunk() and _build_parts() on _BaseResponse. _process_chunk returns the text str to yield (or None to filter), and as a side effect populates both _chunks and _stream_events.
stream_events() is a separate code path from __iter__
stream_events() on Response runs its own iteration of execute() (it doesn't delegate to __iter__). This is because __iter__ filters non-text events, but stream_events() needs to yield everything. They share _process_chunk() logic but manage the generator independently. This means you can call either __iter__ OR stream_events() on a response, but not both during live streaming — the first one to run consumes the generator.
After completion, both work fine: __iter__ replays _chunks, stream_events() replays _stream_events (or synthesizes from text if no events were recorded).
Parts property is synchronous even on AsyncResponse
response.parts is a @property (not async) on both Response and AsyncResponse. On AsyncResponse, it raises ValueError if not yet awaited. This keeps the API simple — you await the response first, then inspect .parts. The alternative (an async method) would make the common case of "get response, then look at parts" more verbose.
Live testing observations (gpt-5.4-mini)
-
OpenAI streams an empty first chunk: The first chunk from the streaming API is
""(empty string). This is the chunk that carries therole: "assistant"delta. Our assembler handles this fine — it just becomes an empty text event. -
Chunk count varies: "Count from 1 to 5" produced 10 chunks for "1 2 3 4 5" — roughly one token per chunk, including spaces as separate tokens.
-
Usage data still works:
response.usage()returnsUsage(input=10, output=4)for simple prompts. Thedetailsfield is an empty dict{}for gpt-5.4-mini (no breakdown like reasoning tokens). -
Current OpenAI plugin yields only
str: Since we haven't updated the OpenAI plugin to emitStreamEventobjects yet (that's Phase 3), all chunks come through as plain strings. The backward compat path (_has_stream_events = False) handles this correctly. Parts are synthesized as a singleTextPartby_build_parts().
Things to watch for in Phase 3
-
Empty chunks from OpenAI: When updating the OpenAI plugin to emit StreamEvents, need to handle the empty first chunk. Could either skip it or let it through (it's harmless).
-
Tool call streaming: OpenAI streams tool call arguments as incremental JSON string fragments. The
tool_call_argsevent type with accumulating chunks and JSON parse on finalize should handle this, but needs testing with actual tool calls. -
Reasoning tokens: OpenAI's o-series models don't expose reasoning text (just token counts). Will need to emit
ReasoningPart(redacted=True, token_count=N)— this is data from the usage response, not from streaming chunks. -
stream_events()vs__iter__mutual exclusion: Currently if you callstream_events()the response gets consumed there. If someone then tries to iterate withfor chunk in response, it'll see_done=Trueand replay_chunks. This works but is subtle. May want to document this.
Phase 3 observations
OpenAI plugin
- set_usage mutates the dict:
set_usage()callspop()which destroyscompletion_tokens_details. Must extractreasoning_tokensBEFORE callingset_usage(). - Reasoning tokens are opaque: gpt-5.4-mini reports
reasoning_tokensin usage but never streams reasoning content. Only appears withreasoning_effort='high'or harder problems. - Tool call part_index tracking: When OpenAI streams tool calls after text, need to track whether text was emitted to assign correct part indices.
Anthropic plugin (llm-anthropic)
- Thinking tokens are streamed: Unlike OpenAI/Gemini, Anthropic streams actual thinking text as
thinking_deltaevents. This gives us realReasoningPart(text=...)content, not redacted. - Server-side tools have distinct types:
server_tool_usevs regulartool_usein content block types.web_search_tool_resultis a separate block type with nested content. - Web search results only in final message: The
web_search_tool_resultblocks aren't streamed — they appear in the final message. We extract them post-stream. - Schema streaming uses partial_json: When a schema is set, Anthropic sends
partial_jsondeltas instead of text deltas. Currently we treat these as text events (they build up JSON that gets parsed by the schema handler).
Gemini plugin (llm-gemini)
- Thinking tokens are opaque: Like OpenAI, Gemini reports
thoughtsTokenCountin usage but doesn't stream thinking text. Uses_reasoning_token_countpattern. - Complete parts per chunk: Gemini doesn't stream partial tool arguments — each chunk contains complete parts. This simplifies the tool call event emission.
- Code execution as server-side tool:
executableCodeandcodeExecutionResultparts becometool_resultStreamEvents withserver_executed=True. - Thought signatures: Gemini 3 models include
thoughtSignatureon tool call parts, required for round-tripping. Stored as attribute on ToolCall object.
StreamEvent additions
- Added
server_executedandtool_namefields to StreamEvent dataclass for plugins with server-side tool execution.
Implementation stats
- Phase 1 + 2: ~300 lines of new code in
llm/parts.pyandllm/models.py - Phase 3: ~500 lines changed across 3 plugins (OpenAI, Anthropic, Gemini)
- 41 unit tests in llm core, 513 total tests passing
- llm-anthropic: 22 tests passing (4 new)
- llm-gemini: 37 tests passing (3 new)
- Live tested against gpt-5.4-mini, claude-haiku-4.5, gemini-3-flash-preview
Phase 4 observations
prompt=is sugar forTextPart(role="user", text=...)ininput_partssystem=becomesTextPart(role="system", text=...)ininput_partsparts=andprompt=combine (parts first, then system, then prompt, then attachments)- Existing model plugins still read
prompt.promptandprompt.system— they don't readinput_partsyet. Plugin migration to read frominput_partsis a future step. input_partsis computed on-demand (property), not stored on the Prompt
Phase 5 observations
- Parts table uses
directioncolumn ("input"/"output") to distinguish prompt vs response parts - Both input parts and output parts stored in the same table with a unique index on
(response_id, direction, order) from_row()only loads output parts into_loaded_parts— input parts are still reconstructed from the existing prompt/system/attachments columns- The
partsproperty checks for_loaded_partsfirst (from DB), then falls back to_build_parts()(from stream events) - Old databases without the parts table still work —
log_to_dbchecks for table existence before writing
Final stats
- 524 total tests in llm core, all passing
- 22 tests in llm-anthropic, all passing
- 37 tests in llm-gemini, all passing
- 12 commits across all repos