Skip to content

Commit d5f0f77

Browse files
committed
fix(translator): normalize Cursor Anthropic tool blocks on OpenAI endpoint
Cursor in agent/tool mode sends Anthropic-native content blocks (tool_use / tool_result) and bare tool definitions to the OpenAI Chat Completions endpoint. These are not valid OpenAI format, so ConvertOpenAIRequestToClaude produced an empty/invalid Claude request, causing errors such as "messages.N: user messages must have non-empty content" and silently broken tool calling (see #1165). Add normalizeAnthropicRequestBlocks as a pre-processing pass that rewrites: - user content tool_result blocks -> standalone role:"tool" messages - assistant content tool_use blocks -> assistant tool_calls[].function - bare {name, description, input_schema} tools -> {type:"function", function:{...}} Standard OpenAI payloads are detected and pass through untouched. This removes the need for an external request-rewriting proxy/shim when using Cursor agent mode with Claude via OAuth.
1 parent ac4017e commit d5f0f77

2 files changed

Lines changed: 402 additions & 1 deletion

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package chat_completions
2+
3+
import (
4+
"testing"
5+
6+
"github.com/tidwall/gjson"
7+
)
8+
9+
// Cursor agent mode sends Anthropic-native tool_use / tool_result blocks and
10+
// bare tool definitions to the OpenAI Chat Completions endpoint. These tests
11+
// verify normalizeAnthropicRequestBlocks rewrites them into standard OpenAI
12+
// shapes so the existing translation produces a valid Claude request.
13+
14+
func TestConvertOpenAIRequestToClaude_CursorToolResultBlock(t *testing.T) {
15+
inputJSON := `{
16+
"model": "claude-sonnet-4-5",
17+
"messages": [
18+
{"role": "user", "content": "list files"},
19+
{
20+
"role": "assistant",
21+
"content": [
22+
{"type": "text", "text": "Let me check."},
23+
{"type": "tool_use", "id": "toolu_1", "name": "list_dir", "input": {"path": "."}}
24+
]
25+
},
26+
{
27+
"role": "user",
28+
"content": [
29+
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "a.txt\nb.txt"}
30+
]
31+
}
32+
]
33+
}`
34+
35+
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
36+
resultJSON := gjson.ParseBytes(result)
37+
messages := resultJSON.Get("messages").Array()
38+
39+
if len(messages) != 3 {
40+
t.Fatalf("Expected 3 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
41+
}
42+
43+
// assistant message must carry a tool_use block
44+
asst := messages[1]
45+
if got := asst.Get("role").String(); got != "assistant" {
46+
t.Fatalf("Expected messages[1].role assistant, got %q", got)
47+
}
48+
toolUse := asst.Get("content.#(type==tool_use)")
49+
if !toolUse.Exists() {
50+
t.Fatalf("Expected a tool_use block in assistant message, got: %s", asst.Raw)
51+
}
52+
if got := toolUse.Get("id").String(); got != "toolu_1" {
53+
t.Fatalf("Expected tool_use id toolu_1, got %q", got)
54+
}
55+
if got := toolUse.Get("name").String(); got != "list_dir" {
56+
t.Fatalf("Expected tool_use name list_dir, got %q", got)
57+
}
58+
if got := toolUse.Get("input.path").String(); got != "." {
59+
t.Fatalf("Expected tool_use input.path '.', got %q", got)
60+
}
61+
62+
// the tool_result must become a user message with a tool_result block
63+
toolResultMsg := messages[2]
64+
if got := toolResultMsg.Get("role").String(); got != "user" {
65+
t.Fatalf("Expected messages[2].role user, got %q", got)
66+
}
67+
tr := toolResultMsg.Get("content.0")
68+
if got := tr.Get("type").String(); got != "tool_result" {
69+
t.Fatalf("Expected tool_result block, got %q (%s)", got, toolResultMsg.Raw)
70+
}
71+
if got := tr.Get("tool_use_id").String(); got != "toolu_1" {
72+
t.Fatalf("Expected tool_use_id toolu_1, got %q", got)
73+
}
74+
if got := tr.Get("content").String(); got != "a.txt\nb.txt" {
75+
t.Fatalf("Expected tool_result content, got %q", got)
76+
}
77+
}
78+
79+
func TestConvertOpenAIRequestToClaude_CursorToolResultWithText(t *testing.T) {
80+
// A user turn that mixes a tool_result with a follow-up text instruction.
81+
inputJSON := `{
82+
"model": "claude-sonnet-4-5",
83+
"messages": [
84+
{
85+
"role": "user",
86+
"content": [
87+
{"type": "tool_result", "tool_use_id": "toolu_9", "content": "done"},
88+
{"type": "text", "text": "now summarize"}
89+
]
90+
}
91+
]
92+
}`
93+
94+
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
95+
resultJSON := gjson.ParseBytes(result)
96+
messages := resultJSON.Get("messages").Array()
97+
98+
if len(messages) != 2 {
99+
t.Fatalf("Expected 2 messages (tool_result + user text), got %d: %s", len(messages), resultJSON.Get("messages").Raw)
100+
}
101+
102+
if got := messages[0].Get("content.0.type").String(); got != "tool_result" {
103+
t.Fatalf("Expected first message tool_result, got %q", got)
104+
}
105+
if got := messages[1].Get("content.0.text").String(); got != "now summarize" {
106+
t.Fatalf("Expected trailing user text 'now summarize', got %q", got)
107+
}
108+
}
109+
110+
func TestConvertOpenAIRequestToClaude_BareAnthropicTools(t *testing.T) {
111+
inputJSON := `{
112+
"model": "claude-sonnet-4-5",
113+
"messages": [{"role": "user", "content": "hi"}],
114+
"tools": [
115+
{
116+
"name": "read_file",
117+
"description": "Read a file",
118+
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}}
119+
}
120+
]
121+
}`
122+
123+
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
124+
resultJSON := gjson.ParseBytes(result)
125+
tools := resultJSON.Get("tools").Array()
126+
127+
if len(tools) != 1 {
128+
t.Fatalf("Expected 1 tool, got %d: %s", len(tools), resultJSON.Get("tools").Raw)
129+
}
130+
tool := tools[0]
131+
if got := tool.Get("name").String(); got != "read_file" {
132+
t.Fatalf("Expected tool name read_file, got %q", got)
133+
}
134+
if got := tool.Get("description").String(); got != "Read a file" {
135+
t.Fatalf("Expected tool description, got %q", got)
136+
}
137+
if got := tool.Get("input_schema.properties.path.type").String(); got != "string" {
138+
t.Fatalf("Expected input_schema preserved, got: %s", tool.Raw)
139+
}
140+
}
141+
142+
func TestConvertOpenAIRequestToClaude_StandardOpenAIUnchanged(t *testing.T) {
143+
// A normal OpenAI payload must pass through normalization untouched.
144+
inputJSON := `{
145+
"model": "claude-sonnet-4-5",
146+
"messages": [
147+
{
148+
"role": "assistant",
149+
"content": "",
150+
"tool_calls": [
151+
{"id": "call_1", "type": "function", "function": {"name": "do_work", "arguments": "{\"a\":1}"}}
152+
]
153+
},
154+
{"role": "tool", "tool_call_id": "call_1", "content": "ok"}
155+
],
156+
"tools": [
157+
{"type": "function", "function": {"name": "do_work", "description": "d", "parameters": {"type": "object"}}}
158+
]
159+
}`
160+
161+
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
162+
resultJSON := gjson.ParseBytes(result)
163+
messages := resultJSON.Get("messages").Array()
164+
165+
if len(messages) != 2 {
166+
t.Fatalf("Expected 2 messages, got %d: %s", len(messages), resultJSON.Get("messages").Raw)
167+
}
168+
if got := messages[0].Get("content.0.type").String(); got != "tool_use" {
169+
t.Fatalf("Expected assistant tool_use, got %q", got)
170+
}
171+
if got := messages[0].Get("content.0.id").String(); got != "call_1" {
172+
t.Fatalf("Expected tool_use id call_1, got %q", got)
173+
}
174+
if got := messages[1].Get("content.0.type").String(); got != "tool_result" {
175+
t.Fatalf("Expected tool_result, got %q", got)
176+
}
177+
if got := resultJSON.Get("tools.0.name").String(); got != "do_work" {
178+
t.Fatalf("Expected tool do_work, got %q", got)
179+
}
180+
}

0 commit comments

Comments
 (0)