Skip to content

Commit 36751d6

Browse files
committed
fix: normalize null tool_calls fields in streaming chat deltas
The API sends null where OpenAI omits the field, so an explicit null and a missing field behaved differently on the streaming delta, and fragments stayed raw dicts (#160). Type tool_calls on a chat delta subclass so both parse to None and fragments become ToolCalls models, like openai-python. Fixes #160
1 parent cc9f253 commit 36751d6

2 files changed

Lines changed: 197 additions & 1 deletion

File tree

src/together/types/chat_completions.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,30 @@ class ChatCompletionResponse(BaseModel):
185185
usage: UsageData | None = None
186186

187187

188+
class ChatCompletionDeltaContent(DeltaContent):
189+
"""Streaming delta for chat completion chunks.
190+
191+
The API returns an explicit ``null`` for ``choices[n].delta.tool_calls`` on
192+
text-only chunks, and for ``function.name`` / ``function.arguments`` inside
193+
tool-call fragments, where the OpenAI streaming format omits those fields
194+
entirely (https://github.com/togethercomputer/together-python/issues/160).
195+
196+
Declaring ``tool_calls`` as a typed optional field makes ``null`` and
197+
*missing* parse identically (to ``None``) and validates the items into
198+
:class:`ToolCalls`, matching the non-streaming
199+
:class:`ChatCompletionMessage`, so ``model_dump(exclude_none=True)``
200+
produces OpenAI-shaped deltas with the nulls omitted.
201+
"""
202+
203+
tool_calls: List[ToolCalls] | None = None
204+
205+
188206
class ChatCompletionChoicesChunk(BaseModel):
189207
index: int | None = None
190208
logprobs: float | None = None
191209
seed: int | None = None
192210
finish_reason: FinishReason | None = None
193-
delta: DeltaContent | None = None
211+
delta: ChatCompletionDeltaContent | None = None
194212

195213

196214
class ChatCompletionChunk(BaseModel):
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Regression tests for https://github.com/togethercomputer/together-python/issues/160
2+
3+
The Together API returns an explicit ``null`` where the OpenAI streaming format
4+
omits the field entirely, in three known places:
5+
6+
1. ``choices[n].delta.tool_calls`` (text-only chunks)
7+
2. ``choices[n].delta.tool_calls[n].function.arguments`` (first tool-call chunk,
8+
where only the name is given)
9+
3. ``choices[n].delta.tool_calls[n].function.name`` (continuation chunks that
10+
stream the JSON arguments incrementally)
11+
12+
These tests pin down that the parsed models normalize ``null`` to be
13+
indistinguishable from a missing field, so OpenAI-compatible consumers do not
14+
need Together-specific special cases.
15+
"""
16+
17+
from together.types import ChatCompletionChunk, ChatCompletionResponse
18+
from together.types.chat_completions import FunctionCall, ToolCalls
19+
20+
21+
def _chunk(delta: dict) -> ChatCompletionChunk:
22+
"""Build a chunk the way the SDK does: ChatCompletionChunk(**line.data)."""
23+
return ChatCompletionChunk(
24+
**{
25+
"id": "884581f24f0cfdd0-SJC",
26+
"object": "chat.completion.chunk",
27+
"created": 1725561260,
28+
"model": "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
29+
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
30+
}
31+
)
32+
33+
34+
# Wire payloads as observed in issue #160
35+
TEXT_ONLY_DELTA_WITH_NULL = {
36+
"role": "assistant",
37+
"content": "Hello",
38+
"tool_calls": None,
39+
}
40+
TEXT_ONLY_DELTA_OMITTED = {"role": "assistant", "content": "Hello"}
41+
FIRST_TOOL_CALL_DELTA = {
42+
"role": "assistant",
43+
"content": None,
44+
"tool_calls": [
45+
{
46+
"index": 0,
47+
"id": "call_f7g2h8i9j0",
48+
"type": "function",
49+
"function": {"name": "get_current_weather", "arguments": None},
50+
}
51+
],
52+
}
53+
CONTINUATION_TOOL_CALL_DELTA = {
54+
"tool_calls": [
55+
{
56+
"index": 0,
57+
"function": {"name": None, "arguments": '{"location": "San Fra'},
58+
}
59+
]
60+
}
61+
62+
63+
def _has_no_none_values(obj: object) -> bool:
64+
if obj is None:
65+
return False
66+
if isinstance(obj, dict):
67+
return all(_has_no_none_values(v) for v in obj.values())
68+
if isinstance(obj, list):
69+
return all(_has_no_none_values(v) for v in obj)
70+
return True
71+
72+
73+
def test_null_tool_calls_parses_like_omitted_tool_calls() -> None:
74+
"""`tool_calls: null` (text-only chunks) must behave exactly like a
75+
missing `tool_calls` field: attribute exists and is None in both cases."""
76+
with_null = _chunk(TEXT_ONLY_DELTA_WITH_NULL).choices[0].delta
77+
omitted = _chunk(TEXT_ONLY_DELTA_OMITTED).choices[0].delta
78+
79+
assert with_null is not None and omitted is not None
80+
assert with_null.tool_calls is None
81+
assert omitted.tool_calls is None # was AttributeError before the fix
82+
assert with_null.content == omitted.content == "Hello"
83+
84+
85+
def test_tool_call_delta_items_are_typed_models() -> None:
86+
"""Streaming tool-call fragments parse into the same ToolCalls/FunctionCall
87+
models used by the non-streaming ChatCompletionMessage."""
88+
delta = _chunk(FIRST_TOOL_CALL_DELTA).choices[0].delta
89+
assert delta is not None and delta.tool_calls is not None
90+
91+
(tool_call,) = delta.tool_calls
92+
assert isinstance(tool_call, ToolCalls)
93+
assert isinstance(tool_call.function, FunctionCall)
94+
assert tool_call.id == "call_f7g2h8i9j0"
95+
assert tool_call.type == "function"
96+
assert tool_call.function.name == "get_current_weather"
97+
# null arguments on the first chunk normalizes to None (absent)
98+
assert tool_call.function.arguments is None
99+
100+
101+
def test_null_function_name_on_continuation_chunks() -> None:
102+
"""`function.name: null` on argument-continuation chunks normalizes to
103+
None while the incremental arguments fragment is preserved verbatim."""
104+
delta = _chunk(CONTINUATION_TOOL_CALL_DELTA).choices[0].delta
105+
assert delta is not None and delta.tool_calls is not None
106+
107+
(tool_call,) = delta.tool_calls
108+
assert tool_call.function is not None
109+
assert tool_call.function.name is None
110+
assert tool_call.function.arguments == '{"location": "San Fra'
111+
112+
113+
def test_exclude_none_dump_produces_openai_shaped_deltas() -> None:
114+
"""model_dump(exclude_none=True) must omit every API-provided null,
115+
including the ones nested inside tool_calls[n].function."""
116+
for wire_delta in (
117+
TEXT_ONLY_DELTA_WITH_NULL,
118+
TEXT_ONLY_DELTA_OMITTED,
119+
FIRST_TOOL_CALL_DELTA,
120+
CONTINUATION_TOOL_CALL_DELTA,
121+
):
122+
delta = _chunk(wire_delta).choices[0].delta
123+
assert delta is not None
124+
dumped = delta.model_dump(exclude_none=True)
125+
assert _has_no_none_values(dumped), f"None survived in {dumped!r}"
126+
127+
text_only = _chunk(TEXT_ONLY_DELTA_WITH_NULL).choices[0].delta
128+
assert text_only is not None
129+
assert "tool_calls" not in text_only.model_dump(exclude_none=True)
130+
131+
132+
def test_extra_wire_fields_are_preserved() -> None:
133+
"""Fields the SDK does not declare (delta.role, tool_calls[n].index) must
134+
keep flowing through, as they did before the fix (extra="allow")."""
135+
delta = _chunk(FIRST_TOOL_CALL_DELTA).choices[0].delta
136+
assert delta is not None and delta.tool_calls is not None
137+
assert delta.role == "assistant" # type: ignore[attr-defined]
138+
assert delta.tool_calls[0].index == 0 # type: ignore[attr-defined]
139+
dumped = delta.model_dump(exclude_none=True)
140+
assert dumped["tool_calls"][0]["index"] == 0
141+
142+
143+
def test_non_streaming_tool_calls_unchanged() -> None:
144+
"""Non-streaming responses keep parsing tool_calls into typed models."""
145+
response = ChatCompletionResponse(
146+
**{
147+
"id": "884581f24f0cfdd0-SJC",
148+
"object": "chat.completion",
149+
"created": 1725561260,
150+
"model": "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
151+
"choices": [
152+
{
153+
"index": 0,
154+
"finish_reason": "tool_calls",
155+
"message": {
156+
"role": "assistant",
157+
"content": None,
158+
"tool_calls": [
159+
{
160+
"id": "call_f7g2h8i9j0",
161+
"type": "function",
162+
"function": {
163+
"name": "get_current_weather",
164+
"arguments": '{"location": "San Francisco, CA"}',
165+
},
166+
}
167+
],
168+
},
169+
}
170+
],
171+
}
172+
)
173+
assert response.choices is not None
174+
message = response.choices[0].message
175+
assert message is not None and message.tool_calls is not None
176+
assert isinstance(message.tool_calls[0], ToolCalls)
177+
assert message.tool_calls[0].function is not None
178+
assert message.tool_calls[0].function.name == "get_current_weather"

0 commit comments

Comments
 (0)