fix: normalize null tool_calls fields in streaming chat deltas#454
Draft
Ranoobaba wants to merge 1 commit into
Draft
fix: normalize null tool_calls fields in streaming chat deltas#454Ranoobaba wants to merge 1 commit into
Ranoobaba wants to merge 1 commit into
Conversation
Broly Security ScanNote ✅ Clean scan Note Re-scan this PR anytime with
|
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 (togethercomputer#160). Type tool_calls on a chat delta subclass so both parse to None and fragments become ToolCalls models, like openai-python. Fixes togethercomputer#160
Ranoobaba
force-pushed
the
fix/normalize-null-tool-calls-160
branch
from
July 21, 2026 02:01
7b94526 to
36751d6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Have you read the Contributing Guidelines? Yes.
Issue #160
Describe your changes
What and why. Issue #160 reports that the API returns an explicit
nullwhere the OpenAI streaming format omits the field entirely, in three places:choices[n].delta.tool_calls = nullon text only chunks.choices[n].delta.tool_calls[n].function.arguments = nullon the first tool call chunk, where the name is given.choices[n].delta.tool_calls[n].function.name = nullon argument continuation chunks.The root cause is on the API side, but the SDK today amplifies it instead of absorbing it, because the streaming delta model (
DeltaContent) only declarescontent. Everything else rides onextra="allow", so on currentmain:delta.tool_callsisNonewhen the API sendsnull, but it raisesAttributeErrorwhen the API omits the field. Null and missing are observably different to SDK users, which is the exact problem the issue describes, one level up.ToolCallsandFunctionCallmodels that the non streamingChatCompletionMessageuses.model_dump(exclude_none=True)does not strip the nulls nested inside those raw dicts, so{"function": {"arguments": None}}and{"function": {"name": None}}leak into OpenAI compatible consumers.The fix. Add
ChatCompletionDeltaContent(DeltaContent)insrc/together/types/chat_completions.pydeclaringtool_calls: List[ToolCalls] | None = None, and use it forChatCompletionChoicesChunk.delta. That single typed field means:nulland missing both parse toNone, indistinguishable, matching OpenAI semantics for all three reported instances.FunctionCall.nameand.argumentswere alreadystr | None, so the nested nulls normalize the same way once the items are validated.ToolCallsmodels used by the non streaming path, so typing is consistent across streaming and non streaming, and identical to howopenai-pythontypes its deltas (ChoiceDeltaToolCallFunction.nameand.argumentsareOptional[str]).model_dump(exclude_none=True)now recursively omits the API provided nulls, producing OpenAI shaped deltas with no Together special cases.Deliberate non decisions:
Noneinto an empty string forarguments. openai-python also leaves absent fields asNone; coercing would diverge from it and silently changeis Nonechecks.DeltaContentis shared withCompletionChunk, so the new field is added on a chat specific subclass rather than the shared class.ChatCompletionRequest.model_dump(exclude_none=True)) is unaffected.indexon tool call fragments stays undeclared, kept intentionally out of scope. It still flows through viaextra="allow"and is asserted in tests; declaring it would add anindex: Nonekey to non streamingChatCompletionMessage.tool_callsdumps.Sync and async clients share these models (
ChatCompletionChunk(**line.data)at both call sites inresources/chat/completions.py), so both are covered.Known limitations
.nullable()overrides that cite this exact issue (for example big-AGI'sopenai.wiretypes.ts, which markstool_calls,function.name, andfunction.argumentsnullable with a "[TogetherAI]" note linking to Non-standard OpenAI-like response format on tool_calls. #160). I did not have an API key available to re-probe the live wire, so that evidence is corroborative rather than a fresh capture. The SDK level behavior fixed here reproduces deterministically from the issue's own payloads either way.delta.tool_callson a chunk with no tool calls changes fromAttributeErrortoNone, which is strictly fewer crashes; (2) tool call fragments change from rawdictto typedToolCallsmodels, sodelta.tool_calls[0]["function"]becomesdelta.tool_calls[0].function, which matches the long standing typed non streaming path, and anyone who wants dicts gets the same shape back frommodel_dump(); (3) a plainmodel_dump()withoutexclude_none=Trueof a text only delta now includestool_calls: Noneeven when the API omitted the key, which is standard Pydantic optional field behavior and matchesChatCompletionMessageand openai-python.together-py), so this is scoped as a small maintenance bug fix. V2's Stainless generatedChoiceDelta.tool_calls: Optional[List[ToolChoice]]already types the field, but V2'sFunction.nameandargumentsare declared as requiredstr, so if the API still emits the nulls, V2's lenient response parsing will surfaceNonethere too. The underlying API cleanup tracked by this issue is still worth doing server side.Testing
New file
tests/unit/test_chat_completion_stream_types.py, 6 tests using chunk fixtures shaped exactly like the issue's three instances (text onlytool_calls: null, first fragmentarguments: null, continuationname: null), plus an omitted field control, an extra field passthrough check (delta.role,tool_calls[n].index), anexclude_nonerecursive dump assertion, and a non streaming regression control.Fail then pass, demonstrated by restoring the un-fixed file with
git restore --source=main -- src/together/types/chat_completions.py:Lint, format, and typing:
ruff checkclean,black --checkclean, andmypy(using the repo'smypy.ini) reports no errors in the changed files. The 4 pre-existing errors elsewhere are identical onmain.Precedent
This is the same category of change as #401 ("fix: Avoid crashing when models return the incorrect object const") and #239 (accept an empty string
finish_reason): small type level hardening of the response models against API format quirks, with no wire changes.