fix: strip extra_content from tool_calls for strict APIs (Fireworks, Mistral)

Fireworks/Mistral reject HTTP 400 'Extra inputs are not permitted, field:
messages[N].tool_calls[M].extra_content' on any session whose history
contains prior Gemini tool calls. Gemini 3 thinking models attach
extra_content (thought_signature) to tool_calls; it survived to the wire
because the sanitize paths only stripped call_id/response_item_id.

Strip extra_content from the outgoing wire copy in both sanitize paths
(ChatCompletionsTransport.convert_messages + _sanitize_tool_calls_for_strict_api),
but gate it on the target model: keep extra_content for Gemini-family
targets (the thought_signature MUST be replayed or Gemini 400s), strip it
for everyone else — including non-Gemini models that inherit a stale Gemini
signature earlier in a mixed-provider session. Native Gemini is unaffected
(GeminiNativeClient bypasses these paths).

Original stored history is never mutated (only the per-call copy).

Fixes #17986.
This commit is contained in:
Nate George
2026-06-03 16:42:52 -07:00
committed by Teknium
parent ec69c767ff
commit e8c3ac2f5c
6 changed files with 173 additions and 9 deletions
+11 -2
View File
@@ -4627,7 +4627,7 @@ class AIAgent:
return reapply_reasoning_echo_for_provider(self, api_messages)
@staticmethod
def _sanitize_tool_calls_for_strict_api(api_msg: dict) -> dict:
def _sanitize_tool_calls_for_strict_api(api_msg: dict, model: "str | None" = None) -> dict:
"""Strip Codex Responses API fields from tool_calls for strict providers.
Providers like Mistral, Fireworks, and other strict OpenAI-compatible APIs
@@ -4636,17 +4636,26 @@ class AIAgent:
the internal message history — this method only modifies the outgoing
API copy.
``extra_content`` (Gemini thought_signature) is also stripped — strict
providers reject it with "Extra inputs are not permitted" — UNLESS the
outgoing ``model`` is itself Gemini-family, in which case it must be
replayed (Gemini 3 thinking models 400 without it). Defaults to
stripping when no model is supplied.
Creates new tool_call dicts rather than mutating in-place, so the
original messages list retains call_id/response_item_id for Codex
Responses API compatibility (e.g. if the session falls back to a
Codex provider later).
Fields stripped: call_id, response_item_id
Fields stripped: call_id, response_item_id, extra_content (model-gated)
"""
tool_calls = api_msg.get("tool_calls")
if not isinstance(tool_calls, list):
return api_msg
from agent.transports.chat_completions import _model_consumes_thought_signature
_STRIP_KEYS = {"call_id", "response_item_id"}
if not _model_consumes_thought_signature(model):
_STRIP_KEYS = _STRIP_KEYS | {"extra_content"}
api_msg["tool_calls"] = [
{k: v for k, v in tc.items() if k not in _STRIP_KEYS}
if isinstance(tc, dict) else tc