From e1416c26dca64d91909ec16f9c48c36bbb3d4a0e Mon Sep 17 00:00:00 2001 From: Jayden Ireland Date: Fri, 25 Sep 2026 15:06:12 +0200 Subject: [PATCH] Translate Chat Completions tool_choice to the Responses API shape Closes #128. Forced tool choices sent as {"type":"function","function":{"name":...}} were forwarded to the Responses API untouched, which rejects them with "Missing required parameter: 'tool_choice.name'". "required" was also outside the allowed string list and silently downgraded to "auto". Add convert_tool_choice_chat_to_responses in utils.py, use it in both the OpenAI and Ollama routes (including the responses_tools retry path), and accept "required" in start_upstream_request. --- chatmock/routes_ollama.py | 10 +++++--- chatmock/routes_openai.py | 5 ++-- chatmock/upstream.py | 2 +- chatmock/utils.py | 22 +++++++++++++++++ tests/test_routes.py | 52 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/chatmock/routes_ollama.py b/chatmock/routes_ollama.py index 70981eea..72031ab0 100644 --- a/chatmock/routes_ollama.py +++ b/chatmock/routes_ollama.py @@ -18,7 +18,11 @@ ) from .transform import convert_ollama_messages, normalize_ollama_tools from .upstream import normalize_model_name, start_upstream_request -from .utils import convert_chat_messages_to_responses_input, convert_tools_chat_to_responses +from .utils import ( + convert_chat_messages_to_responses_input, + convert_tool_choice_chat_to_responses, + convert_tools_chat_to_responses, +) ollama_bp = Blueprint("ollama", __name__) @@ -195,7 +199,7 @@ def ollama_chat() -> Response: stream_req = bool(stream_req) tools_req = payload.get("tools") if isinstance(payload.get("tools"), list) else [] tools_responses = convert_tools_chat_to_responses(normalize_ollama_tools(tools_req)) - tool_choice = payload.get("tool_choice", "auto") + tool_choice = convert_tool_choice_chat_to_responses(payload.get("tool_choice", "auto")) parallel_tool_calls = bool(payload.get("parallel_tool_calls", False)) # Passthrough Responses API tools (web_search) via ChatMock extension fields @@ -297,7 +301,7 @@ def ollama_chat() -> Response: if verbose: print("[Passthrough] Upstream rejected tools; retrying without extras (args redacted)") base_tools_only = convert_tools_chat_to_responses(normalize_ollama_tools(tools_req)) - safe_choice = payload.get("tool_choice", "auto") + safe_choice = convert_tool_choice_chat_to_responses(payload.get("tool_choice", "auto")) upstream2, err2 = start_upstream_request( normalize_model_name(model, current_app.config.get("DEBUG_MODEL")), input_items, diff --git a/chatmock/routes_openai.py b/chatmock/routes_openai.py index 673e22fb..49c54384 100644 --- a/chatmock/routes_openai.py +++ b/chatmock/routes_openai.py @@ -32,6 +32,7 @@ from .upstream import normalize_model_name, start_upstream_raw_request, start_upstream_request from .utils import ( convert_chat_messages_to_responses_input, + convert_tool_choice_chat_to_responses, convert_tools_chat_to_responses, sse_translate_chat, sse_translate_text, @@ -147,7 +148,7 @@ def chat_completions() -> Response: include_usage = bool(stream_options.get("include_usage", False)) tools_responses = convert_tools_chat_to_responses(payload.get("tools")) - tool_choice = payload.get("tool_choice", "auto") + tool_choice = convert_tool_choice_chat_to_responses(payload.get("tool_choice", "auto")) parallel_tool_calls = bool(payload.get("parallel_tool_calls", False)) responses_tools_payload = payload.get("responses_tools") if isinstance(payload.get("responses_tools"), list) else [] extra_tools: List[Dict[str, Any]] = [] @@ -246,7 +247,7 @@ def chat_completions() -> Response: if verbose: print("[Passthrough] Upstream rejected tools; retrying without extra tools (args redacted)") base_tools_only = convert_tools_chat_to_responses(payload.get("tools")) - safe_choice = payload.get("tool_choice", "auto") + safe_choice = convert_tool_choice_chat_to_responses(payload.get("tool_choice", "auto")) upstream2, err2 = start_upstream_request( model, input_items, diff --git a/chatmock/upstream.py b/chatmock/upstream.py index 9ad7941e..5201bc28 100644 --- a/chatmock/upstream.py +++ b/chatmock/upstream.py @@ -71,7 +71,7 @@ def start_upstream_request( "model": model, "input": input_items, "tools": tools or [], - "tool_choice": tool_choice if tool_choice in ("auto", "none") or isinstance(tool_choice, dict) else "auto", + "tool_choice": tool_choice if tool_choice in ("auto", "none", "required") or isinstance(tool_choice, dict) else "auto", "parallel_tool_calls": bool(parallel_tool_calls), "store": False, "stream": True, diff --git a/chatmock/utils.py b/chatmock/utils.py index 96dd3143..94fa921d 100644 --- a/chatmock/utils.py +++ b/chatmock/utils.py @@ -218,6 +218,28 @@ def _normalize_image_data_url(url: str) -> str: return input_items +def convert_tool_choice_chat_to_responses(tool_choice: Any) -> Any: + """Translate a Chat Completions ``tool_choice`` into the Responses API shape. + + Chat Completions forces a function with ``{"type": "function", "function": {"name": ...}}`` + while the Responses API expects ``{"type": "function", "name": ...}``. String modes + (``auto``/``none``/``required``) pass through unchanged and anything unrecognised + falls back to ``auto``. + """ + if isinstance(tool_choice, str): + mode = tool_choice.strip().lower() + return mode if mode in ("auto", "none", "required") else "auto" + if isinstance(tool_choice, dict): + if tool_choice.get("type") == "function": + fn = tool_choice.get("function") + name = fn.get("name") if isinstance(fn, dict) else tool_choice.get("name") + if isinstance(name, str) and name.strip(): + return {"type": "function", "name": name.strip()} + return "auto" + return tool_choice + return "auto" + + def convert_tools_chat_to_responses(tools: Any) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] if not isinstance(tools, list): diff --git a/tests/test_routes.py b/tests/test_routes.py index a4906705..fb1a48da 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -97,6 +97,58 @@ def test_chat_completions(self, mock_start) -> None: self.assertEqual(body["choices"][0]["message"]["content"], "hello") self.assertEqual(body["model"], "gpt5.4-mini") + @patch("chatmock.routes_openai.start_upstream_request") + def test_chat_completions_translates_forced_tool_choice(self, mock_start) -> None: + mock_start.return_value = ( + FakeUpstream([{"type": "response.completed", "response": {"id": "resp-openai"}}]), + None, + ) + tools = [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}] + response = self.client.post( + "/v1/chat/completions", + json={ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "hi"}], + "tools": tools, + "tool_choice": {"type": "function", "function": {"name": "lookup"}}, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_start.call_args.kwargs["tool_choice"], {"type": "function", "name": "lookup"}) + + @patch("chatmock.routes_openai.start_upstream_request") + def test_chat_completions_passes_required_tool_choice(self, mock_start) -> None: + mock_start.return_value = ( + FakeUpstream([{"type": "response.completed", "response": {"id": "resp-openai"}}]), + None, + ) + response = self.client.post( + "/v1/chat/completions", + json={"model": "gpt-5.5", "messages": [{"role": "user", "content": "hi"}], "tool_choice": "required"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_start.call_args.kwargs["tool_choice"], "required") + + @patch("chatmock.routes_ollama.start_upstream_request") + def test_ollama_chat_translates_forced_tool_choice(self, mock_start) -> None: + mock_start.return_value = ( + FakeUpstream([{"type": "response.completed", "response": {"id": "resp-ollama"}}]), + None, + ) + tools = [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}] + response = self.client.post( + "/api/chat", + json={ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "hi"}], + "tools": tools, + "tool_choice": {"type": "function", "function": {"name": "lookup"}}, + "stream": False, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_start.call_args.kwargs["tool_choice"], {"type": "function", "name": "lookup"}) + @patch("chatmock.routes_openai.start_upstream_request") def test_chat_completions_honors_debug_model_override(self, mock_start) -> None: app = create_app(debug_model="gpt-5.4", model_sync=False)