Skip to content

Commit b71b593

Browse files
committed
fix(openai): align first-chunk fields and cover response combinations
1 parent 8ec2d58 commit b71b593

12 files changed

Lines changed: 502 additions & 141 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ See [Development Guide](docs/DEVELOPMENT.md) for full setup and workflow details
351351
### Data Types & Serialization
352352

353353
- **Core types** (`Query`, `QueryResult`, `StreamChunk`): `msgspec.Struct` with `frozen=True`, `array_like=True`, `gc=False`, `omit_defaults=True`
354+
- **Streaming response boundary**: Entry zero of each streamed `TextModelOutput` field represents the same first meaningful delta. Fields that start later need an empty first entry (`""` for text, `()` for tool-call batches). TPOT drops entry zero independently in every field; full-output consumers combine all entries. Text tails may be aggregated; tool-call batches retain their fragment indices for merging.
354355
- **Config types**: `pydantic.BaseModel` for validation
355356
- **Enums**: `str, Enum` pattern for serializable enums (e.g., `LoadPatternType`, `APIType`)
356357
- **Serialization**: `msgspec.json` for hot-path (ZMQ transport), `pydantic` for config

‎src/inference_endpoint/core/types.py‎

Lines changed: 22 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -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

273231
OUTPUT_TYPE = TextModelOutput

‎src/inference_endpoint/metrics/steady_state_diagnostics.py‎

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -586,9 +586,8 @@ def build_natl_result(
586586
def text_after_first_chunk(data: object) -> str:
587587
"""Return output text after the first streamed chunk.
588588
589-
This is the text used as the TPOT numerator. ``data`` is the COMPLETE event
590-
payload: ``[tag, output, reasoning?, tool_calls?]`` with trailing defaults
591-
omitted by msgspec ``array_like`` and ``omit_defaults``.
589+
This text is tokenized for the TPOT denominator. ``data`` is the COMPLETE
590+
event payload: ``[tag, output, reasoning?, tool_calls?]``.
592591
593592
``output`` and ``reasoning`` are each either a string for non-streaming
594593
output or a list of streamed chunks. The logic mirrors
@@ -599,21 +598,10 @@ def text_after_first_chunk(data: object) -> str:
599598
output = data[1] if len(data) > 1 else ""
600599
reasoning = data[2] if len(data) > 2 else None
601600
parts: list[str] = []
602-
if reasoning:
603-
if isinstance(reasoning, list) and len(reasoning) > 1:
604-
parts.extend(reasoning[1:])
605-
# str reasoning is a single (first) chunk -> skip entirely
606-
if output:
607-
if isinstance(output, str):
608-
# Non-streaming output: keep it only if a first chunk already lived in a
609-
# (streaming) reasoning trace; otherwise the str output IS the first chunk.
610-
if parts or (reasoning and isinstance(reasoning, list)):
611-
parts.append(output)
612-
elif isinstance(output, list):
613-
if parts or reasoning:
614-
parts.extend(output)
615-
elif len(output) > 1:
616-
parts.extend(output[1:])
601+
if isinstance(reasoning, list):
602+
parts.extend(reasoning[1:])
603+
if isinstance(output, list):
604+
parts.extend(output[1:])
617605
# Tool-call reconstruction is intentionally omitted: tool-call samples use a
618606
# chat-template tokenization path this diagnostic does not replicate.
619607
return "".join(parts)
@@ -1353,7 +1341,7 @@ def cov_pass_row(
13531341
# CoV is undefined with fewer than two points. Report inconclusive, not
13541342
# PASS, so a short or empty window cannot masquerade as steady.
13551343
if len(values) < 2:
1356-
return {b: None for b in bounds}
1344+
return dict.fromkeys(bounds)
13571345
c = cov(values)
13581346
return {b: c <= b for b in bounds}
13591347

‎src/inference_endpoint/openai/accumulator.py‎

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from inference_endpoint.endpoint_client.accumulator_protocol import (
2222
SSEAccumulatorProtocol,
2323
)
24-
from inference_endpoint.openai.types import SSEChoice
24+
from inference_endpoint.openai.types import SSEChoice, SSEDelta
2525

2626

2727
class OpenAISSEAccumulator(SSEAccumulatorProtocol):
@@ -33,6 +33,7 @@ def __init__(self, query_id: str, stream_all_chunks: bool):
3333
self.tool_call_chunks: list[tuple[dict[str, Any], ...]] = []
3434
self._finish_reason: str | None = None
3535

36+
self._first_delta: SSEDelta | None = None
3637
self.first_chunk_sent = False
3738
self.query_id = query_id
3839
self.stream_all_chunks = stream_all_chunks
@@ -57,6 +58,8 @@ def add_chunk(self, choice: SSEChoice | None) -> StreamChunk | None:
5758
if delta.content:
5859
self.output_chunks.append(delta.content)
5960
content = (rc or "") + (delta.content or "")
61+
if not self.first_chunk_sent and (content or delta.tool_calls):
62+
self._first_delta = delta
6063
if not content and delta.tool_calls and not self.first_chunk_sent:
6164
# Pure tool-call delta with no text: emit a zero-length sentinel so
6265
# RECV_FIRST / TTFT fires for agentic responses that have no content.
@@ -86,32 +89,22 @@ def add_chunk(self, choice: SSEChoice | None) -> StreamChunk | None:
8689
return None
8790

8891
def get_final_output(self) -> QueryResult:
89-
tool_calls = tuple(self.tool_call_chunks) if self.tool_call_chunks else None
90-
91-
if self.reasoning_chunks:
92-
resp_reasoning: list[str] = [self.reasoning_chunks[0]]
93-
if len(self.reasoning_chunks) > 1:
94-
resp_reasoning.append("".join(self.reasoning_chunks[1:]))
95-
text_output = TextModelOutput(
96-
output="".join(self.output_chunks),
97-
reasoning=resp_reasoning,
98-
tool_calls=tool_calls,
99-
)
100-
elif self.output_chunks:
101-
resp_output: list[str] = [self.output_chunks[0]]
102-
if len(self.output_chunks) > 1:
103-
resp_output.append("".join(self.output_chunks[1:]))
104-
text_output = TextModelOutput(
105-
output=resp_output,
106-
reasoning=None,
107-
tool_calls=tool_calls,
108-
)
109-
else:
110-
text_output = TextModelOutput(
111-
output=[],
112-
reasoning=None,
113-
tool_calls=tool_calls,
114-
)
92+
first = self._first_delta
93+
first_content = (first.content or "") if first else ""
94+
first_reasoning = (
95+
(first.reasoning_content or first.reasoning or "") if first else ""
96+
)
97+
tool_calls = None
98+
if self.tool_call_chunks:
99+
tool_calls = tuple(self.tool_call_chunks)
100+
if first is not None and not first.tool_calls:
101+
tool_calls = ((), *tool_calls)
102+
103+
text_output = TextModelOutput(
104+
output=_with_first_chunk(self.output_chunks, first_content),
105+
reasoning=_with_first_chunk(self.reasoning_chunks, first_reasoning) or None,
106+
tool_calls=tool_calls,
107+
)
115108

116109
metadata: dict[str, Any] = {
117110
"first_chunk": not self.first_chunk_sent,
@@ -128,3 +121,13 @@ def get_final_output(self) -> QueryResult:
128121
response_output=text_output,
129122
metadata=metadata,
130123
)
124+
125+
126+
def _with_first_chunk(chunks: list[str], first: str) -> tuple[str, ...]:
127+
"""Keep the first delta's contribution separate from the accumulated tail."""
128+
if not chunks:
129+
return ()
130+
tail_start = 1 if first else 0
131+
if len(chunks) == tail_start:
132+
return (first,)
133+
return first, "".join(chunks[tail_start:])

‎tests/unit/async_utils/services/metrics_aggregator/test_aggregator.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1389,7 +1389,9 @@ async def test_tpot_osl_for_tool_call_complete(self, tmp_path):
13891389
SampleEventType.COMPLETE,
13901390
"s1",
13911391
ts=5000,
1392-
data=TextModelOutput(output="ok", tool_calls=(tool_call,)),
1392+
data=TextModelOutput(
1393+
output=("ok",), tool_calls=((), (tool_call,))
1394+
),
13931395
),
13941396
]
13951397
)

‎tests/unit/async_utils/services/metrics_aggregator/test_metrics_table.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ async def count_batch_async(self, inputs, _loop, live=False):
402402
class TestTpotTriggerToolCalls:
403403
"""TpotTrigger routes to message path when tool_calls are present."""
404404

405-
async def test_tpot_tool_calls_only_response(self):
405+
async def test_tpot_tools_after_first_content_delta(self):
406406
"""TpotTrigger includes tool_calls in TPOT denominator for agentic responses."""
407407
from inference_endpoint.async_utils.services.metrics_aggregator.metrics_table import (
408408
SampleField,
@@ -428,7 +428,7 @@ async def test_tpot_tool_calls_only_response(self):
428428
"function": {"name": "f", "arguments": "{}"},
429429
},
430430
)
431-
tmo = TextModelOutput(output=[], tool_calls=tool_calls)
431+
tmo = TextModelOutput(output=("checking",), tool_calls=((), tool_calls))
432432
ev = EventRecord(
433433
event_type=SampleEventType.COMPLETE,
434434
timestamp_ns=2000,

0 commit comments

Comments
 (0)