@@ -122,7 +122,11 @@ class TextModelOutput(
122122 """Structured output from a text model.
123123
124124 Supports main output, optional reasoning (e.g. chain-of-thought), and tool calls.
125- Each field may be a string (non-streaming) or tuple of strings (streaming chunks).
125+ Text fields are strings for non-streaming responses or tuples for streaming.
126+ Entry zero in every streamed field belongs to the same first meaningful delta.
127+ Fields that start later use an empty first entry ("" for text, () for tools).
128+ Remaining entries hold the tail; text entries may be aggregated. Tool calls
129+ are a flat tuple for non-streaming or a tuple of delta batches for streaming.
126130
127131 AT-RISK (gc=False): Has mutable container field `tool_calls`. Any change that
128132 mutates `tool_calls` after construction or stores cyclic references in it
@@ -182,38 +186,13 @@ def text_after_first_chunk(self) -> str:
182186 For non-streaming (str fields), there is no "first chunk" concept so
183187 this returns an empty string.
184188
185- Streamed tool-call chunks are merged after dropping the first tool-call
186- chunk when the response starts with a pure tool-call delta.
189+ All streamed fields exclude entry zero, including tool-call batches.
187190 """
188- parts : list [str ] = []
189- if self .reasoning :
190- if isinstance (self .reasoning , tuple ) and len (self .reasoning ) > 1 :
191- parts .extend (self .reasoning [1 :])
192- # str reasoning: single chunk, skip entirely (it IS the first chunk)
193- if self .output :
194- if isinstance (self .output , str ):
195- # Non-streaming: if reasoning was present and was the first chunk,
196- # include the full output. Otherwise no first chunk to skip.
197- if parts or (self .reasoning and isinstance (self .reasoning , tuple )):
198- parts .append (self .output )
199- elif isinstance (self .output , tuple ):
200- if parts or self .reasoning :
201- # First chunk was in reasoning; include all output chunks.
202- parts .extend (self .output )
203- elif len (self .output ) > 1 :
204- # No reasoning; first chunk is output[0], skip it.
205- parts .extend (self .output [1 :])
206- tool_calls = self .tool_calls
207- if (
208- tool_calls
209- and isinstance (tool_calls [0 ], list | tuple )
210- and not (self .output or self .reasoning )
211- ):
212- tool_calls = tool_calls [1 :]
213- merged_tool_calls = merge_tool_calls (tool_calls )
214- if merged_tool_calls :
215- parts .append (msgspec .json .encode (list (merged_tool_calls )).decode ())
216- return "" .join (parts )
191+ content , reasoning , tool_calls = self .as_message_parts_after_first_chunk ()
192+ text = (reasoning or "" ) + content
193+ if tool_calls :
194+ text += msgspec .json .encode (list (tool_calls )).decode ()
195+ return text
217196
218197 def as_message_parts (
219198 self ,
@@ -236,38 +215,17 @@ def as_message_parts(
236215 def as_message_parts_after_first_chunk (
237216 self ,
238217 ) -> tuple [str , str | None , TOOL_CALLS_TYPE | None ]:
239- """Return message parts emitted after the first stream chunk."""
240- reasoning_after : str | None = None
241- if isinstance (self .reasoning , tuple ) and len (self .reasoning ) > 1 :
242- reasoning_after = "" .join (self .reasoning [1 :])
243-
244- # has_reasoning_tail: reasoning[1:] is non-empty (mirrors `parts` logic in text_after_first_chunk)
245- has_reasoning_tail = reasoning_after is not None
246-
247- content_after = ""
248- if self .output :
249- if isinstance (self .output , str ):
250- # Include if reasoning is any non-empty tuple (it was the first chunk)
251- if has_reasoning_tail or (
252- self .reasoning and isinstance (self .reasoning , tuple )
253- ):
254- content_after = self .output
255- elif isinstance (self .output , tuple ):
256- if has_reasoning_tail or self .reasoning :
257- # First chunk was in reasoning; include all output chunks.
258- content_after = "" .join (self .output )
259- elif len (self .output ) > 1 :
260- # No reasoning; first chunk is output[0], skip it.
261- content_after = "" .join (self .output [1 :])
262-
263- tool_calls = self .tool_calls
264- if (
265- tool_calls
266- and isinstance (tool_calls [0 ], list | tuple )
267- and not (self .output or self .reasoning )
268- ):
269- tool_calls = tool_calls [1 :]
270- return content_after , reasoning_after , merge_tool_calls (tool_calls )
218+ """Drop the aligned first entry in each streamed field for TPOT."""
219+ content = "" .join (self .output [1 :]) if isinstance (self .output , tuple ) else ""
220+ reasoning = (
221+ "" .join (self .reasoning [1 :]) or None
222+ if isinstance (self .reasoning , tuple )
223+ else None
224+ )
225+ tool_calls = None
226+ if self .tool_calls and isinstance (self .tool_calls [0 ], list | tuple ):
227+ tool_calls = merge_tool_calls (self .tool_calls [1 :])
228+ return content , reasoning , tool_calls
271229
272230
273231OUTPUT_TYPE = TextModelOutput
0 commit comments