Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/together/types/chat_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,30 @@ class ChatCompletionResponse(BaseModel):
usage: UsageData | None = None


class ChatCompletionDeltaContent(DeltaContent):
"""Streaming delta for chat completion chunks.

The API returns an explicit ``null`` for ``choices[n].delta.tool_calls`` on
text-only chunks, and for ``function.name`` / ``function.arguments`` inside
tool-call fragments, where the OpenAI streaming format omits those fields
entirely (https://github.com/togethercomputer/together-python/issues/160).

Declaring ``tool_calls`` as a typed optional field makes ``null`` and
*missing* parse identically (to ``None``) and validates the items into
:class:`ToolCalls`, matching the non-streaming
:class:`ChatCompletionMessage`, so ``model_dump(exclude_none=True)``
produces OpenAI-shaped deltas with the nulls omitted.
"""

tool_calls: List[ToolCalls] | None = None


class ChatCompletionChoicesChunk(BaseModel):
index: int | None = None
logprobs: float | None = None
seed: int | None = None
finish_reason: FinishReason | None = None
delta: DeltaContent | None = None
delta: ChatCompletionDeltaContent | None = None


class ChatCompletionChunk(BaseModel):
Expand Down
178 changes: 178 additions & 0 deletions tests/unit/test_chat_completion_stream_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Regression tests for https://github.com/togethercomputer/together-python/issues/160

The Together API returns an explicit ``null`` where the OpenAI streaming format
omits the field entirely, in three known places:

1. ``choices[n].delta.tool_calls`` (text-only chunks)
2. ``choices[n].delta.tool_calls[n].function.arguments`` (first tool-call chunk,
where only the name is given)
3. ``choices[n].delta.tool_calls[n].function.name`` (continuation chunks that
stream the JSON arguments incrementally)

These tests pin down that the parsed models normalize ``null`` to be
indistinguishable from a missing field, so OpenAI-compatible consumers do not
need Together-specific special cases.
"""

from together.types import ChatCompletionChunk, ChatCompletionResponse
from together.types.chat_completions import FunctionCall, ToolCalls


def _chunk(delta: dict) -> ChatCompletionChunk:
"""Build a chunk the way the SDK does: ChatCompletionChunk(**line.data)."""
return ChatCompletionChunk(
**{
"id": "884581f24f0cfdd0-SJC",
"object": "chat.completion.chunk",
"created": 1725561260,
"model": "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
}
)


# Wire payloads as observed in issue #160
TEXT_ONLY_DELTA_WITH_NULL = {
"role": "assistant",
"content": "Hello",
"tool_calls": None,
}
TEXT_ONLY_DELTA_OMITTED = {"role": "assistant", "content": "Hello"}
FIRST_TOOL_CALL_DELTA = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"index": 0,
"id": "call_f7g2h8i9j0",
"type": "function",
"function": {"name": "get_current_weather", "arguments": None},
}
],
}
CONTINUATION_TOOL_CALL_DELTA = {
"tool_calls": [
{
"index": 0,
"function": {"name": None, "arguments": '{"location": "San Fra'},
}
]
}


def _has_no_none_values(obj: object) -> bool:
if obj is None:
return False
if isinstance(obj, dict):
return all(_has_no_none_values(v) for v in obj.values())
if isinstance(obj, list):
return all(_has_no_none_values(v) for v in obj)
return True


def test_null_tool_calls_parses_like_omitted_tool_calls() -> None:
"""`tool_calls: null` (text-only chunks) must behave exactly like a
missing `tool_calls` field: attribute exists and is None in both cases."""
with_null = _chunk(TEXT_ONLY_DELTA_WITH_NULL).choices[0].delta
omitted = _chunk(TEXT_ONLY_DELTA_OMITTED).choices[0].delta

assert with_null is not None and omitted is not None
assert with_null.tool_calls is None
assert omitted.tool_calls is None # was AttributeError before the fix
assert with_null.content == omitted.content == "Hello"


def test_tool_call_delta_items_are_typed_models() -> None:
"""Streaming tool-call fragments parse into the same ToolCalls/FunctionCall
models used by the non-streaming ChatCompletionMessage."""
delta = _chunk(FIRST_TOOL_CALL_DELTA).choices[0].delta
assert delta is not None and delta.tool_calls is not None

(tool_call,) = delta.tool_calls
assert isinstance(tool_call, ToolCalls)
assert isinstance(tool_call.function, FunctionCall)
assert tool_call.id == "call_f7g2h8i9j0"
assert tool_call.type == "function"
assert tool_call.function.name == "get_current_weather"
# null arguments on the first chunk normalizes to None (absent)
assert tool_call.function.arguments is None


def test_null_function_name_on_continuation_chunks() -> None:
"""`function.name: null` on argument-continuation chunks normalizes to
None while the incremental arguments fragment is preserved verbatim."""
delta = _chunk(CONTINUATION_TOOL_CALL_DELTA).choices[0].delta
assert delta is not None and delta.tool_calls is not None

(tool_call,) = delta.tool_calls
assert tool_call.function is not None
assert tool_call.function.name is None
assert tool_call.function.arguments == '{"location": "San Fra'


def test_exclude_none_dump_produces_openai_shaped_deltas() -> None:
"""model_dump(exclude_none=True) must omit every API-provided null,
including the ones nested inside tool_calls[n].function."""
for wire_delta in (
TEXT_ONLY_DELTA_WITH_NULL,
TEXT_ONLY_DELTA_OMITTED,
FIRST_TOOL_CALL_DELTA,
CONTINUATION_TOOL_CALL_DELTA,
):
delta = _chunk(wire_delta).choices[0].delta
assert delta is not None
dumped = delta.model_dump(exclude_none=True)
assert _has_no_none_values(dumped), f"None survived in {dumped!r}"

text_only = _chunk(TEXT_ONLY_DELTA_WITH_NULL).choices[0].delta
assert text_only is not None
assert "tool_calls" not in text_only.model_dump(exclude_none=True)


def test_extra_wire_fields_are_preserved() -> None:
"""Fields the SDK does not declare (delta.role, tool_calls[n].index) must
keep flowing through, as they did before the fix (extra="allow")."""
delta = _chunk(FIRST_TOOL_CALL_DELTA).choices[0].delta
assert delta is not None and delta.tool_calls is not None
assert delta.role == "assistant" # type: ignore[attr-defined]
assert delta.tool_calls[0].index == 0 # type: ignore[attr-defined]
dumped = delta.model_dump(exclude_none=True)
assert dumped["tool_calls"][0]["index"] == 0


def test_non_streaming_tool_calls_unchanged() -> None:
"""Non-streaming responses keep parsing tool_calls into typed models."""
response = ChatCompletionResponse(
**{
"id": "884581f24f0cfdd0-SJC",
"object": "chat.completion",
"created": 1725561260,
"model": "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
"choices": [
{
"index": 0,
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_f7g2h8i9j0",
"type": "function",
"function": {
"name": "get_current_weather",
"arguments": '{"location": "San Francisco, CA"}',
},
}
],
},
}
],
}
)
assert response.choices is not None
message = response.choices[0].message
assert message is not None and message.tool_calls is not None
assert isinstance(message.tool_calls[0], ToolCalls)
assert message.tool_calls[0].function is not None
assert message.tool_calls[0].function.name == "get_current_weather"