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:
@@ -46,6 +46,44 @@ class TestChatCompletionsBasic:
|
||||
assert "codex_reasoning_items" in msgs[0]
|
||||
assert "codex_message_items" in msgs[0]
|
||||
|
||||
def _msg_with_extra_content(self):
|
||||
return [
|
||||
{"role": "assistant", "content": "ok",
|
||||
"tool_calls": [{"id": "call_1", "type": "function",
|
||||
"extra_content": {"google": {"thought_signature": "SIG_123"}},
|
||||
"function": {"name": "t", "arguments": "{}"}}]},
|
||||
]
|
||||
|
||||
def test_convert_messages_strips_extra_content_for_strict_provider(self, transport):
|
||||
"""Strict providers (Fireworks, Mistral) reject extra_content on
|
||||
tool_calls with HTTP 400. When the outgoing model is NOT Gemini-family,
|
||||
the Gemini thought_signature must be stripped — including stale
|
||||
signatures inherited from earlier in a mixed-provider session.
|
||||
"""
|
||||
msgs = self._msg_with_extra_content()
|
||||
result = transport.convert_messages(msgs, model="accounts/fireworks/models/llama-v3p1-70b")
|
||||
assert "extra_content" not in result[0]["tool_calls"][0]
|
||||
# Original list untouched (deepcopy-on-demand)
|
||||
assert "extra_content" in msgs[0]["tool_calls"][0]
|
||||
|
||||
def test_convert_messages_strips_extra_content_when_model_unknown(self, transport):
|
||||
"""Default (no model supplied) is to strip — safe for strict providers."""
|
||||
msgs = self._msg_with_extra_content()
|
||||
result = transport.convert_messages(msgs)
|
||||
assert "extra_content" not in result[0]["tool_calls"][0]
|
||||
|
||||
def test_convert_messages_keeps_extra_content_for_gemini(self, transport):
|
||||
"""Gemini 3 thinking models require the thought_signature replayed on
|
||||
every turn — stripping it would 400. Keep extra_content for Gemini
|
||||
targets (including aggregator slugs like google/gemini-3-pro).
|
||||
"""
|
||||
for model in ("gemini-3-pro", "google/gemini-3-pro-preview", "gemma-3-27b"):
|
||||
msgs = self._msg_with_extra_content()
|
||||
result = transport.convert_messages(msgs, model=model)
|
||||
assert result[0]["tool_calls"][0]["extra_content"] == {
|
||||
"google": {"thought_signature": "SIG_123"}
|
||||
}, model
|
||||
|
||||
def test_convert_messages_strips_tool_name(self, transport):
|
||||
"""Internal `tool_name` (used for FTS indexing in the SQLite store) is
|
||||
not part of the OpenAI Chat Completions schema. Strict providers like
|
||||
|
||||
@@ -148,14 +148,57 @@ class TestBuildApiKwargsOpenRouter:
|
||||
assert "codex_reasoning_items" not in assistant_msg
|
||||
assert tool_call["id"] == "call_123"
|
||||
assert tool_call["function"]["name"] == "terminal"
|
||||
assert tool_call["extra_content"] == {"thought_signature": "opaque"}
|
||||
# extra_content (Gemini thought_signature) is stripped for non-Gemini
|
||||
# targets — strict providers like Fireworks 400 on it. The agent here
|
||||
# is not a Gemini model, so it must be dropped.
|
||||
assert "extra_content" not in tool_call
|
||||
assert "call_id" not in tool_call
|
||||
assert "response_item_id" not in tool_call
|
||||
|
||||
# Original stored history must remain unchanged (only the outgoing copy
|
||||
# is sanitized) — Codex/Responses replay relies on these fields.
|
||||
assert messages[1]["tool_calls"][0]["call_id"] == "call_123"
|
||||
assert messages[1]["tool_calls"][0]["response_item_id"] == "fc_123"
|
||||
assert "codex_reasoning_items" in messages[1]
|
||||
assert messages[1]["tool_calls"][0]["extra_content"] == {"thought_signature": "opaque"}
|
||||
|
||||
def test_keeps_extra_content_for_gemini_target(self, monkeypatch):
|
||||
"""Gemini-family targets must keep extra_content (thought_signature) —
|
||||
Gemini 3 thinking models 400 without it replayed on the next turn.
|
||||
"""
|
||||
agent = _make_agent(monkeypatch, "openrouter", model="google/gemini-3-pro-preview")
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Checking now.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"call_id": "call_123",
|
||||
"response_item_id": "fc_123",
|
||||
"type": "function",
|
||||
"function": {"name": "terminal", "arguments": "{\"command\":\"pwd\"}"},
|
||||
"extra_content": {"google": {"thought_signature": "opaque"}},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_123", "content": "/tmp"},
|
||||
]
|
||||
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
tool_call = kwargs["messages"][1]["tool_calls"][0]
|
||||
assert tool_call["extra_content"] == {"google": {"thought_signature": "opaque"}}
|
||||
# call_id/response_item_id still stripped regardless of model
|
||||
assert "call_id" not in tool_call
|
||||
assert "response_item_id" not in tool_call
|
||||
|
||||
# Original stored history must remain unchanged for Responses replay mode.
|
||||
assert messages[1]["tool_calls"][0]["call_id"] == "call_123"
|
||||
assert messages[1]["tool_calls"][0]["response_item_id"] == "fc_123"
|
||||
assert "codex_reasoning_items" in messages[1]
|
||||
assert messages[1]["tool_calls"][0]["extra_content"] == {
|
||||
"google": {"thought_signature": "opaque"}
|
||||
}
|
||||
|
||||
def test_gemini_native_passes_base_url_for_top_level_thinking_config(self, monkeypatch):
|
||||
agent = _make_agent(
|
||||
@@ -204,6 +247,47 @@ class TestBuildApiKwargsOpenRouter:
|
||||
anthropic_agent.api_mode = "anthropic_messages"
|
||||
assert anthropic_agent._should_sanitize_tool_calls() is True
|
||||
|
||||
def _api_msg_with_extra_content(self):
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": "call_1", "call_id": "call_1", "type": "function",
|
||||
"extra_content": {"google": {"thought_signature": "SIG_123"}},
|
||||
"function": {"name": "t", "arguments": "{}"}},
|
||||
],
|
||||
}
|
||||
|
||||
def test_sanitize_tool_calls_strips_extra_content_for_strict_model(self, monkeypatch):
|
||||
"""Strict providers reject extra_content; strip it for non-Gemini models."""
|
||||
agent = _make_agent(monkeypatch, "openrouter")
|
||||
api_msg = self._api_msg_with_extra_content()
|
||||
result = agent._sanitize_tool_calls_for_strict_api(
|
||||
api_msg, model="accounts/fireworks/models/llama-v3p1-70b"
|
||||
)
|
||||
assert "extra_content" not in result["tool_calls"][0]
|
||||
assert "call_id" not in result["tool_calls"][0]
|
||||
|
||||
def test_sanitize_tool_calls_strips_extra_content_when_model_none(self, monkeypatch):
|
||||
"""Default (no model) strips extra_content — safe for strict providers."""
|
||||
agent = _make_agent(monkeypatch, "openrouter")
|
||||
api_msg = self._api_msg_with_extra_content()
|
||||
result = agent._sanitize_tool_calls_for_strict_api(api_msg)
|
||||
assert "extra_content" not in result["tool_calls"][0]
|
||||
|
||||
def test_sanitize_tool_calls_keeps_extra_content_for_gemini(self, monkeypatch):
|
||||
"""Gemini thinking models 400 without the replayed thought_signature."""
|
||||
agent = _make_agent(monkeypatch, "openrouter")
|
||||
api_msg = self._api_msg_with_extra_content()
|
||||
result = agent._sanitize_tool_calls_for_strict_api(
|
||||
api_msg, model="google/gemini-3-pro-preview"
|
||||
)
|
||||
assert result["tool_calls"][0]["extra_content"] == {
|
||||
"google": {"thought_signature": "SIG_123"}
|
||||
}
|
||||
# call_id/response_item_id still stripped regardless of model
|
||||
assert "call_id" not in result["tool_calls"][0]
|
||||
|
||||
|
||||
class TestDeveloperRoleSwap:
|
||||
"""GPT-5 and Codex models should get 'developer' instead of 'system' role."""
|
||||
|
||||
Reference in New Issue
Block a user