Skip to content

fix(snowflake): unblock Claude function-calling follow-up turns on Cortex - #111

Merged
josemaria-vilaplana merged 1 commit into
upstream-sync/v1.83.14-stablefrom
fix/sc-554963/snowflake-cortex-tool-call-empty-args
Jun 8, 2026
Merged

fix(snowflake): unblock Claude function-calling follow-up turns on Cortex#111
josemaria-vilaplana merged 1 commit into
upstream-sync/v1.83.14-stablefrom
fix/sc-554963/snowflake-cortex-tool-call-empty-args

Conversation

@josemaria-vilaplana

Copy link
Copy Markdown

Summary

Three independent issues caused Snowflake Cortex api/v2/cortex/inference:complete to 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 of input={}

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_parser was passing that empty string through as function.arguments. Downstream consumers (the OpenAI Agents SDK, browser-side tool runners) accumulate deltas and then JSON.parse(arguments); JSON.parse("") throws SyntaxError: Unexpected end of JSON input. The SDK then either errors out or injects a synthetic tool_result containing 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 blocks

OpenAI-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's inference:complete rejects unknown fields inside content blocks with the same 390142 payload error.

The previous _transform_messages pass-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 content and one with content + content_list.

Fixes

litellm/llms/snowflake/chat/transformation.py:

  • SnowflakeStreamingHandler.chunk_parser — when a tool_use delta introduces a name with empty or missing input, seed arguments="{}" so SDK accumulation ends with valid JSON. Dict-valued input is 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-shaped tool_calls field is built.
  • Module helper _strip_openai_annotations — removes annotations from each content block of a message; no-op on string / None content.
  • Module helper _content_to_text_blocks — converts an OpenAI-style assistant content value (string or list of blocks) into [{"type": "text", "text": …}] blocks suitable for inclusion in content_list; whitespace-only entries are dropped.
  • SnowflakeConfig._transform_messages:
    • strip annotations on every pass-through message,
    • defensively coerce empty/missing tool_calls[].function.arguments to {} before JSON-parsing into tool_use.input,
    • when an assistant message with tool_calls is preceded by a text-only assistant message (no content_list, no tool_calls), pop the prelude and merge its text into the new message's content_list so 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 legacy inference:complete endpoint, 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 TestSnowflakeCortex390142Fixes class with 20 tests covering:

  • chunk_parser empty-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_messages merge: positive case (text-only assistant + tool_call assistant → merged); no-merge when previous already has content_list / tool_calls; no-merge across non-assistant role boundaries; annotations stripped on plain (non-merged) assistant; empty / missing / non-empty tool_calls.arguments coerced or preserved correctly.
  • An end-to-end test that replays the exact failing payload captured from production traffic and asserts the post-transform message list has no consecutive same-role messages, no annotations field anywhere, and tool_use.input as an object.

Result: 24 existing tests still pass; 20 new tests pass. Total: 44/44.

$ pytest tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py
======================== 44 passed, 2 warnings in 0.16s ========================

Validation

Reproduced the failing payload locally via mitmdump reverse-proxying the LiteLLM container's outbound Snowflake traffic. The captured request body before this PR shows the three symptoms described above and a 400 / 390142 response from Cortex. After applying the patch, _transform_messages produces a single assistant message per turn, no annotations field anywhere, and tool_use.input as {} for parameterless calls — matching the schema Cortex accepts.

…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
josemaria-vilaplana merged commit effe7cf into upstream-sync/v1.83.14-stable Jun 8, 2026
5 checks passed
@josemaria-vilaplana
josemaria-vilaplana deleted the fix/sc-554963/snowflake-cortex-tool-call-empty-args branch June 8, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant