Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 74 additions & 12 deletions src/deepseek_cursor_proxy/transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -770,14 +815,31 @@ 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"]
]

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:
Expand Down
95 changes: 95 additions & 0 deletions tests/test_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
Loading