From c0cf0cc9a90700cabe1fc69cc788b7ec21476205 Mon Sep 17 00:00:00 2001 From: davidyaaw <93667251+davidyaaw@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:06:50 +0300 Subject: [PATCH] fix(transform): drop tool types DeepSeek cannot accept When a GPT-named model is selected, Cursor sends OpenAI-only free-form tools (`{"type": "custom", ...}`). DeepSeek rejects the whole request with `tools[i].type: unknown variant custom, expected function`. - keep only `type: "function"` tools that have a function name; drop the rest and log a warning with their names - remove `tools` entirely when nothing survives - remove `tool_choice` when it points at a dropped tool or is not a recognized function choice --- src/deepseek_cursor_proxy/transform.py | 86 +++++++++++++++++++---- tests/test_transform.py | 95 ++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 12 deletions(-) diff --git a/src/deepseek_cursor_proxy/transform.py b/src/deepseek_cursor_proxy/transform.py index 0053d98..3d8a9e6 100644 --- a/src/deepseek_cursor_proxy/transform.py +++ b/src/deepseek_cursor_proxy/transform.py @@ -184,20 +184,65 @@ def normalize_tool_call(tool_call: Any) -> dict[str, Any]: return normalized -def normalize_tool(tool: Any) -> dict[str, Any]: +def normalize_tool(tool: Any) -> dict[str, Any] | None: + """Return a DeepSeek-compatible function tool, or ``None`` if unsupported. + + DeepSeek only accepts ``{"type": "function", ...}`` entries. OpenAI-format + clients also send ``{"type": "custom", ...}`` free-form tools (Cursor does + this for GPT-named models), which DeepSeek rejects with + ``tools[i].type: unknown variant custom, expected function``. + """ if not isinstance(tool, dict): - return { - "type": "function", - "function": {"name": "", "description": "", "parameters": {}}, - } + return None + tool_type = tool.get("type") or "function" + if tool_type != "function": + return None + function = tool.get("function") + if not isinstance(function, dict) or not function.get("name"): + return None normalized = dict(tool) - normalized["type"] = normalized.get("type") or "function" - function = normalized.get("function") - if isinstance(function, dict): - normalized["function"] = function + normalized["type"] = "function" return normalized +def tool_label(tool: Any) -> str: + if not isinstance(tool, dict): + return "unnamed" + function = tool.get("function") + if isinstance(function, dict) and function.get("name"): + return str(function["name"]) + if tool.get("name"): + return str(tool["name"]) + tool_type = tool.get("type") + return f"{tool_type} tool" if tool_type else "unnamed" + + +def normalize_tools(tools: Any) -> tuple[list[dict[str, Any]], list[str]]: + """Split client tools into DeepSeek-compatible ones and dropped labels.""" + kept: list[dict[str, Any]] = [] + dropped: list[str] = [] + if not isinstance(tools, list): + return kept, dropped + for tool in tools: + normalized = normalize_tool(tool) + if normalized is None: + dropped.append(tool_label(tool)) + else: + kept.append(normalized) + return kept, dropped + + +def tool_names(tools: Any) -> set[str]: + names: set[str] = set() + if isinstance(tools, list): + for tool in tools: + if isinstance(tool, dict): + function = tool.get("function") + if isinstance(function, dict) and function.get("name"): + names.add(str(function["name"])) + return names + + def legacy_function_to_tool(function: Any) -> dict[str, Any]: if not isinstance(function, dict): function = {} @@ -230,8 +275,8 @@ def normalize_tool_choice(tool_choice: Any) -> Any: "type": "function", "function": {"name": str(function["name"])}, } - return tool_choice - return tool_choice + return None + return None def normalize_message( @@ -770,7 +815,16 @@ def prepare_upstream_request( prepared["stream_options"] = stream_options if "tools" in prepared and isinstance(prepared["tools"], list): - prepared["tools"] = [normalize_tool(tool) for tool in prepared["tools"]] + normalized_tools, dropped_tools = normalize_tools(prepared["tools"]) + if dropped_tools: + LOG.warning( + "dropping tool(s) DeepSeek cannot accept: %s", + ", ".join(dropped_tools), + ) + if normalized_tools: + prepared["tools"] = normalized_tools + else: + prepared.pop("tools", None) elif isinstance(payload.get("functions"), list): prepared["tools"] = [ legacy_function_to_tool(function) for function in payload["functions"] @@ -778,6 +832,14 @@ def prepare_upstream_request( if "tool_choice" in prepared: tool_choice = normalize_tool_choice(prepared["tool_choice"]) + available_tools = tool_names(prepared.get("tools")) + if ( + isinstance(tool_choice, dict) + and tool_choice["function"]["name"] not in available_tools + ): + # A forced tool that was just dropped (e.g. OpenAI-only `custom`) + # can no longer be honored; fall back to the model's own choice. + tool_choice = None if tool_choice is None: prepared.pop("tool_choice", None) else: diff --git a/tests/test_transform.py b/tests/test_transform.py index ef67574..c717d00 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -122,6 +122,101 @@ def test_named_function_call_becomes_named_tool_choice(self) -> None: {"type": "function", "function": {"name": "lookup"}}, ) + def test_custom_tool_type_is_dropped_with_warning(self) -> None: + # Cursor sends OpenAI-only free-form tools (`type: "custom"`) when the + # selected model looks like a GPT model; DeepSeek rejects the whole + # request with `tools[i].type: unknown variant custom, expected + # function`, so those entries have to be filtered out. + with self.assertLogs("deepseek_cursor_proxy", level="WARNING") as captured: + prepared = prepare_upstream_request( + { + "model": "deepseek-flash", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "type": "function", + "function": { + "name": "read_file", + "parameters": {"type": "object"}, + }, + }, + { + "type": "custom", + "name": "apply_patch", + "description": "Free-form patch tool", + }, + ], + }, + ProxyConfig(), + self.store, + ) + self.assertEqual( + [tool["function"]["name"] for tool in prepared.payload["tools"]], + ["read_file"], + ) + self.assertIn("apply_patch", "\n".join(captured.output)) + + def test_tools_and_tool_choice_are_removed_when_nothing_survives(self) -> None: + prepared = prepare_upstream_request( + { + "model": "deepseek-flash", + "messages": [{"role": "user", "content": "hi"}], + "tools": [{"type": "custom", "name": "apply_patch"}], + "tool_choice": {"type": "custom", "name": "apply_patch"}, + }, + ProxyConfig(), + self.store, + ) + self.assertNotIn("tools", prepared.payload) + self.assertNotIn("tool_choice", prepared.payload) + + def test_tool_choice_to_a_dropped_tool_is_removed(self) -> None: + prepared = prepare_upstream_request( + { + "model": "deepseek-flash", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "type": "function", + "function": {"name": "read_file", "parameters": {}}, + }, + {"type": "custom", "name": "apply_patch"}, + ], + "tool_choice": { + "type": "function", + "function": {"name": "apply_patch"}, + }, + }, + ProxyConfig(), + self.store, + ) + self.assertEqual( + [tool["function"]["name"] for tool in prepared.payload["tools"]], + ["read_file"], + ) + self.assertNotIn("tool_choice", prepared.payload) + + def test_function_tool_without_name_is_dropped(self) -> None: + prepared = prepare_upstream_request( + { + "model": "deepseek-flash", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + {"type": "function"}, + { + "type": "function", + "function": {"name": "read_file", "parameters": {}}, + }, + ], + }, + ProxyConfig(), + self.store, + ) + self.assertEqual( + [tool["function"]["name"] for tool in prepared.payload["tools"]], + ["read_file"], + ) + def test_max_completion_tokens_is_aliased_to_max_tokens(self) -> None: prepared = prepare_upstream_request( {