Skip to content

Replay OpenAI tool calls as plain, stable JSON - #325

Open
gtokman wants to merge 3 commits into
huggingface:mainfrom
gtokman:fix/openai-tool-call-arguments-json
Open

gtokman wants to merge 3 commits into
huggingface:mainfrom
gtokman:fix/openai-tool-call-arguments-json

Conversation

@gtokman

@gtokman gtokman commented Oct 13, 2026 •

Copy link
Copy Markdown

Fixes #324, and a prompt-caching miss it uncovered.

1. Replayed arguments were GeneratedContent's Codable form

OpenAILanguageModel encoded each earlier tool call's arguments with JSONEncoder().encode(call.arguments), which writes {"kind":{"type":"structure","properties":…}} rather than the arguments' JSON. Models that see that shape in the history copy it into their next tool calls, which then fail to decode and end the response. The .responses variant builds its function_call items from the same message, so it was affected too.

2. A tool round was sent differently in flight and in history

During a turn, the assistant message carrying tool calls was the model's raw message (its own argument text, "content": ""). On the next turn, the same round was rebuilt from the transcript (re-encoded arguments, "content": null). The request stopped matching the previous one at the first tool call, so a provider's prompt cache (Anthropic's is an exact-prefix match) missed everything after it on every later turn. We measured Claude reading only the system prompt from cache on turn 2 of a conversation, instead of all of turn 1.

Both are now built by one helper from the transcript's calls (Chat Completions; the Responses variant replays output items and is unchanged). Arguments are serialized with sorted keys, because jsonString goes through an unordered [String: Any], so the same arguments could otherwise come out in a different key order from one request to the next. That is also why the second test failed intermittently before the sort, depending on which suites ran alongside it.

Tests

OpenAIToolCallHistoryTests (own URLProtocol, like OllamaRequestHistoryTests):

  • replayedToolCallArgumentsArePlainJSON: both API variants replay {"city":"Paris"}. Fails on main.
  • toolCallMessageIsTheSameInFlightAndInHistory: streaming and non-streaming, a tool whose arguments the model writes in the opposite order from its schema. The tool-call message in the in-flight request equals the one in the next turn's request. Fails without the change.

swift test --filter "OpenAI|Transcript|ProviderUsage|ProviderSchema" passes (114 tests), and swift format lint --strict is clean on the changed files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B4WBsc1Jp8fGufe89MhQFB

gtokman and others added 2 commits October 13, 2026 15:09
Chat Completions history encoded each earlier tool call's arguments with
JSONEncoder, which writes GeneratedContent's Codable form ({"kind":…})
instead of the arguments' JSON. Models that see that shape in the history
copy it into their next tool calls, which then fail to decode. The
Responses variant converts from the same message, so it was affected too.

Use call.arguments.jsonString, as OpenResponsesLanguageModel does.

Fixes huggingface#324

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chat Completions sent a round of tool calls one way while the turn was in
flight (the model's raw message: its argument text, content "") and
another when the next turn replayed the transcript (re-encoded arguments,
content null). The prompt stopped matching at the first tool call, so a
provider's prompt cache (Anthropic's is an exact prefix match) missed
everything after it on every later turn.

Build both from the transcript's calls with one helper. Arguments are
serialized with sorted keys: jsonString goes through an unordered
dictionary, so the same arguments could come out in a different order
from one request to the next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B4WBsc1Jp8fGufe89MhQFB
@gtokman gtokman changed the title Replay OpenAI tool-call arguments as plain JSON Replay OpenAI tool calls as plain, stable JSON Oct 13, 2026
@mattt
mattt requested a balanced review from Copilot October 14, 2026 12:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Non-empty tool-call text still differs between in-flight and replayed messages, defeating prompt-cache stability.

1 open finding
What changed in this PR

Fixes OpenAI tool-call replay by using stable plain JSON and aligning in-flight/history message construction.

Changes:

  • Canonicalizes replayed tool arguments with sorted JSON keys.
  • Adds history and prompt-cache regression tests.
File Description
OpenAILanguageModel.swift Unifies tool-call message serialization.
OpenAIToolCallHistoryTests.swift Tests plain JSON replay and message consistency.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +1343 to +1344
/// `text` is what the model wrote before calling the tools in this round; the transcript
/// keeps it in the final response instead, so replayed rounds pass `nil`.
@mattt

mattt commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

Thanks, @gtokman. CI is green. Copilot caught one more case (here): when the model writes text alongside its tool calls, the in-flight message sends that text as content, but the replay passes nil, so the two still differ. Could you either keep that text for replay or send null in both paths, and add a test with non-empty content?

When the model writes text alongside its tool calls, the in-flight
message sent that text as content, but transcript replay passed nil.
Store the per-round text on ToolCalls.providerMetadata so both paths
use the same helper and send the same content (or null when empty).

Cover the non-empty case in the streaming and non-streaming history
tests.

Co-authored-by: Gary Tokman  <yo@garytokman.com>
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.

OpenAILanguageModel replays tool-call arguments as GeneratedContent's Codable form, not JSON

4 participants