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:
@@ -1296,7 +1296,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
||||
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
|
||||
api_msg.pop(internal_key, None)
|
||||
if _needs_sanitize:
|
||||
agent._sanitize_tool_calls_for_strict_api(api_msg)
|
||||
agent._sanitize_tool_calls_for_strict_api(api_msg, model=agent.model)
|
||||
api_messages.append(api_msg)
|
||||
|
||||
effective_system = agent._cached_system_prompt or ""
|
||||
|
||||
@@ -982,7 +982,7 @@ def run_conversation(
|
||||
# Uses new dicts so the internal messages list retains the fields
|
||||
# for Codex Responses compatibility.
|
||||
if agent._should_sanitize_tool_calls():
|
||||
agent._sanitize_tool_calls_for_strict_api(api_msg)
|
||||
agent._sanitize_tool_calls_for_strict_api(api_msg, model=agent.model)
|
||||
# Keep 'reasoning_details' - OpenRouter uses this for multi-turn reasoning context
|
||||
# The signature field helps maintain reasoning continuity
|
||||
api_messages.append(api_msg)
|
||||
|
||||
@@ -99,6 +99,22 @@ def _is_gemini_openai_compat_base_url(base_url: Any) -> bool:
|
||||
return normalized.endswith("/openai")
|
||||
|
||||
|
||||
def _model_consumes_thought_signature(model: Any) -> bool:
|
||||
"""True when the outgoing model is a Gemini family model that requires
|
||||
``extra_content`` (thought_signature) to be replayed on tool calls.
|
||||
|
||||
Gemini 3 thinking models attach ``extra_content`` to each tool call and
|
||||
reject subsequent requests with HTTP 400 if it is missing. Every other
|
||||
strict OpenAI-compatible provider (Fireworks, Mistral, ...) rejects the
|
||||
request with 400 if ``extra_content`` *is* present. So the field must be
|
||||
kept only when the target model is itself Gemini-family, and stripped
|
||||
otherwise — including when a non-Gemini model inherits stale Gemini
|
||||
``extra_content`` from earlier in a mixed-provider session.
|
||||
"""
|
||||
m = str(model or "").lower()
|
||||
return "gemini" in m or "gemma" in m
|
||||
|
||||
|
||||
class ChatCompletionsTransport(ProviderTransport):
|
||||
"""Transport for api_mode='chat_completions'.
|
||||
|
||||
@@ -119,6 +135,14 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
- Codex Responses API fields: ``codex_reasoning_items`` /
|
||||
``codex_message_items`` on the message, ``call_id`` /
|
||||
``response_item_id`` on ``tool_calls`` entries.
|
||||
- ``extra_content`` on ``tool_calls`` (Gemini thought_signature) —
|
||||
stripped unless the outgoing ``model`` is itself Gemini-family.
|
||||
Gemini 3 thinking models attach it for replay, but strict providers
|
||||
(Fireworks, Mistral) reject any payload containing it with
|
||||
``Extra inputs are not permitted, field: 'messages[N].tool_calls[M].extra_content'``.
|
||||
It must be kept for Gemini targets (replay required) and dropped for
|
||||
everyone else, including non-Gemini models that inherited stale
|
||||
Gemini ``extra_content`` earlier in a mixed-provider session.
|
||||
- ``tool_name`` on tool-result messages — written by
|
||||
``make_tool_result_message()`` for the SQLite FTS index, but not
|
||||
part of the Chat Completions schema. Strict providers (Fireworks,
|
||||
@@ -137,6 +161,9 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
``Extra inputs are not permitted, field: 'messages[N]._empty_recovery_synthetic'``,
|
||||
which then poisons every subsequent request in the session.
|
||||
"""
|
||||
strip_extra_content = not _model_consumes_thought_signature(
|
||||
kwargs.get("model")
|
||||
)
|
||||
needs_sanitize = False
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
@@ -155,7 +182,9 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if isinstance(tool_calls, list):
|
||||
for tc in tool_calls:
|
||||
if isinstance(tc, dict) and (
|
||||
"call_id" in tc or "response_item_id" in tc
|
||||
"call_id" in tc
|
||||
or "response_item_id" in tc
|
||||
or (strip_extra_content and "extra_content" in tc)
|
||||
):
|
||||
needs_sanitize = True
|
||||
break
|
||||
@@ -183,6 +212,8 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if isinstance(tc, dict):
|
||||
tc.pop("call_id", None)
|
||||
tc.pop("response_item_id", None)
|
||||
if strip_extra_content:
|
||||
tc.pop("extra_content", None)
|
||||
return sanitized
|
||||
|
||||
def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
@@ -240,8 +271,10 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
anthropic_max_output: int | None
|
||||
extra_body_additions: dict | None
|
||||
"""
|
||||
# Codex sanitization: drop reasoning_items / call_id / response_item_id
|
||||
sanitized = self.convert_messages(messages)
|
||||
# Codex sanitization: drop reasoning_items / call_id / response_item_id.
|
||||
# Pass model so the Gemini thought_signature (extra_content) is kept for
|
||||
# Gemini targets and stripped for strict non-Gemini providers.
|
||||
sanitized = self.convert_messages(messages, model=model)
|
||||
|
||||
# ── Provider profile: single-path when present ──────────────────
|
||||
_profile = params.get("provider_profile")
|
||||
|
||||
Reference in New Issue
Block a user