From 258965663c46d406a324356274f529586f7ce22c Mon Sep 17 00:00:00 2001 From: Savanne Kham Date: Tue, 19 May 2026 23:58:55 +0200 Subject: [PATCH] fix(chat_completions): strip tool_name from messages for strict providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'tool_name' key on role=tool messages is an internal Hermes field (stored in the messages.tool_name SQLite column for FTS indexing) that is not part of the OpenAI Chat Completions schema. Strict OpenAI-compatible providers — notably Moonshot AI (Kimi) — reject it with HTTP 400: Error from provider: Extra inputs are not permitted, field: 'messages[N].tool_name', value: 'execute_code' Add 'tool_name' to the sanitize block in ChatCompletionsTransport.convert_messages alongside the existing Codex Responses API fields (codex_reasoning_items, codex_message_items) so it is popped before the request is sent. Reproducer: hermes chat --model kimi-k2.6 > list the top 5 Hacker News stories -> assistant emits tool_call(execute_code) -> tool result message gets tool_name='execute_code' -> next turn's payload includes messages[N].tool_name -> 400 Permissive backends (MiniMax, OpenRouter on most routes) ignore the extra field and were masking the bug. --- agent/transports/chat_completions.py | 7 ++++++- .../agent/transports/test_chat_completions.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 7edb69e42c7..ed5d8b0ba43 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -122,7 +122,11 @@ class ChatCompletionsTransport(ProviderTransport): for msg in messages: if not isinstance(msg, dict): continue - if "codex_reasoning_items" in msg or "codex_message_items" in msg: + if ( + "codex_reasoning_items" in msg + or "codex_message_items" in msg + or "tool_name" in msg + ): needs_sanitize = True break tool_calls = msg.get("tool_calls") @@ -145,6 +149,7 @@ class ChatCompletionsTransport(ProviderTransport): continue msg.pop("codex_reasoning_items", None) msg.pop("codex_message_items", None) + msg.pop("tool_name", None) tool_calls = msg.get("tool_calls") if isinstance(tool_calls, list): for tc in tool_calls: diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 7ed0d4da634..2e7b9da2f8d 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -46,6 +46,26 @@ class TestChatCompletionsBasic: assert "codex_reasoning_items" in msgs[0] assert "codex_message_items" in msgs[0] + 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 + Moonshot/Kimi reject it with HTTP 400 'Extra inputs are not permitted'. + """ + msgs = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "call_1", "type": "function", + "function": {"name": "execute_code", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call_1", "tool_name": "execute_code", + "content": "result"}, + ] + result = transport.convert_messages(msgs) + assert "tool_name" not in result[2] + assert result[2]["content"] == "result" + assert result[2]["tool_call_id"] == "call_1" + # Original list untouched (deepcopy-on-demand) + assert msgs[2]["tool_name"] == "execute_code" + class TestChatCompletionsBuildKwargs: