fix(snowflake): unblock Claude function-calling follow-up turns on Cortex - #111
Merged
josemaria-vilaplana merged 1 commit intoJun 8, 2026
Conversation
…rtex
Three independent issues caused Snowflake Cortex `inference:complete` to
reject Claude function-calling conversations with HTTP 400 / `390142
Incoming request does not contain a valid payload` on the second turn
(after a tool result):
1. Streaming `tool_use` chunks for parameterless calls arrive with
`input=""` (empty string) instead of `input={}`. The OpenAI-shaped
delta produced by `SnowflakeStreamingHandler.chunk_parser` inherited
that empty string as `function.arguments`, which downstream consumers
(Agents SDKs, frontend tool runners) accumulate into an invalid JSON
value — `JSON.parse("")` fails and the assistant turn is poisoned
before it ever reaches the next request.
2. Assistant text content blocks carry an `annotations: []` field from
OpenAI-compatible providers (citation parity with the Responses API).
The field survives Agents-SDK replay on every follow-up turn and
Cortex rejects unknown fields inside content blocks.
3. The Agents SDK persists a single tool-using turn as TWO consecutive
assistant messages: one with the pre-call text and one with the
tool_call. Cortex expects one assistant message per turn whose
`content_list` holds both text and tool_use blocks.
Fixes:
- `SnowflakeStreamingHandler.chunk_parser`: when a `tool_use` delta
introduces a name with empty/missing `input`, seed `arguments="{}"`
so accumulation ends up valid JSON. Dict inputs are serialised to a
JSON string; partial-JSON string continuations pass through unchanged.
- `SnowflakeConfig._transform_messages`: strip `annotations` from
pass-through messages; coerce empty/missing `tool_calls[].function.
arguments` to `{}` before JSON-parsing into `tool_use.input`; merge a
preceding text-only assistant message into the current tool-call
assistant's `content_list` so the role-alternation invariant holds.
Adds 20 unit tests under `TestSnowflakeCortex390142Fixes` covering each
fix in isolation plus an end-to-end test that replays the exact failing
payload captured from production traffic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
josemaria-vilaplana
merged commit Jun 8, 2026
effe7cf
into
upstream-sync/v1.83.14-stable
5 checks passed
josemaria-vilaplana
deleted the
fix/sc-554963/snowflake-cortex-tool-call-empty-args
branch
June 8, 2026 16:03
3 tasks
3 tasks
3 tasks
3 tasks
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.
Summary
Three independent issues caused Snowflake Cortex
api/v2/cortex/inference:completeto reject Claude function-calling conversations with HTTP 400 /code: "390142"/message: "Incoming request does not contain a valid payload"on the turn following a tool result. Symptoms in production traffic: the first turn streams a tool_use call, the SDK fails to execute it, and the second turn either hangs (~80s of empty SSE keepalive frames before the upstream error fires) or returns 390142 immediately.This PR fixes all three.
Root causes
1. Parameterless tool_use chunks arrive with
input=""instead ofinput={}Cortex routes Claude through Bedrock under the hood. For a parameterless tool call (e.g.
get_coordinates()with no arguments), the streaming chunk arrives as:{ "type": "tool_use", "name": "get_coordinates", "tool_use_id": "toolu_…", "input": "" }SnowflakeStreamingHandler.chunk_parserwas passing that empty string through asfunction.arguments. Downstream consumers (the OpenAI Agents SDK, browser-side tool runners) accumulate deltas and thenJSON.parse(arguments);JSON.parse("")throwsSyntaxError: Unexpected end of JSON input. The SDK then either errors out or injects a synthetictool_resultcontaining the parse-error message — both of which poison the conversation history that gets replayed to Cortex on the follow-up turn.2. OpenAI
annotations: []leaks inside content blocksOpenAI-compatible chat-completion providers emit
annotations: []on every assistant text block (for parity with the Responses API's citation feature). The field travels back into the conversation history on every follow-up turn. Cortex'sinference:completerejects unknown fields inside content blocks with the same390142payload error.The previous
_transform_messagespass-through branch (else: transformed_messages.append(msg_to_append)) propagated the field unchanged.3. Two consecutive assistant messages per tool-using turn
The Agents SDK records a tool-using turn as two separate output items (text first, then tool_call) and replays them as two separate assistant messages on the next request:
{ "role": "assistant", "content": [{ "type": "text", "text": "I'll call the tool.", "annotations": [] }] }, { "role": "assistant", "content": "", "tool_calls": [ … ] }Cortex (Anthropic-style schema) expects strict role alternation — one assistant message per turn — and rejects consecutive same-role messages. The previous transform produced two separate assistant entries in the request body, one with a plain
contentand one withcontent + content_list.Fixes
litellm/llms/snowflake/chat/transformation.py:SnowflakeStreamingHandler.chunk_parser— when atool_usedelta introduces anamewith empty or missinginput, seedarguments="{}"so SDK accumulation ends with valid JSON. Dict-valuedinputis serialised to a JSON string. Partial-JSON string continuations (name=null, input='{"loc') pass through unchanged so multi-chunk argument streaming still works. Snowflake-specific delta keys (type,tool_use_id,input,name,content_list) are stripped after the OpenAI-shapedtool_callsfield is built._strip_openai_annotations— removesannotationsfrom each content block of a message; no-op on string / None content._content_to_text_blocks— converts an OpenAI-style assistantcontentvalue (string or list of blocks) into[{"type": "text", "text": …}]blocks suitable for inclusion incontent_list; whitespace-only entries are dropped.SnowflakeConfig._transform_messages:tool_calls[].function.argumentsto{}before JSON-parsing intotool_use.input,tool_callsis preceded by a text-only assistant message (nocontent_list, notool_calls), pop the prelude and merge its text into the new message'scontent_listso the request carries one assistant message per turn with[text_block, tool_use_block].The dual
content: "" + content_list: […]shape that the previous implementation emits on assistant messages and tool-result user messages is preserved verbatim — Snowflake's public documentation does not specify which of the two should be authoritative on the legacyinference:completeendpoint, so the existing shape is left untouched to avoid regressing other working flows.Tests
tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py:New
TestSnowflakeCortex390142Fixesclass with 20 tests covering:chunk_parserempty-args seed: empty string input, missing input key, dict input → JSON string, partial-JSON string continuation passthrough, no-seed when name is absent, Snowflake-specific delta-field cleanup._strip_openai_annotations: removes the field from list content; no-op on string / None._content_to_text_blocks: string input, list with mixed text / whitespace / non-text blocks, empty inputs._transform_messagesmerge: positive case (text-only assistant + tool_call assistant → merged); no-merge when previous already hascontent_list/tool_calls; no-merge across non-assistant role boundaries; annotations stripped on plain (non-merged) assistant; empty / missing / non-emptytool_calls.argumentscoerced or preserved correctly.annotationsfield anywhere, andtool_use.inputas an object.Result: 24 existing tests still pass; 20 new tests pass. Total: 44/44.
Validation
Reproduced the failing payload locally via
mitmdumpreverse-proxying the LiteLLM container's outbound Snowflake traffic. The captured request body before this PR shows the three symptoms described above and a400 / 390142response from Cortex. After applying the patch,_transform_messagesproduces a single assistant message per turn, noannotationsfield anywhere, andtool_use.inputas{}for parameterless calls — matching the schema Cortex accepts.