diff --git a/cmd/desktop/CHANGELOG.json b/cmd/desktop/CHANGELOG.json index 1965045d..1cc7413c 100644 --- a/cmd/desktop/CHANGELOG.json +++ b/cmd/desktop/CHANGELOG.json @@ -1,4 +1,11 @@ [ + { + "version": "v4.13.1", + "date": "2026-03-07", + "changes": [ + "Fixed cc_openai2 converter transformation issue" + ] + }, { "version": "v4.13.0", "date": "2026-01-06", diff --git a/cmd/desktop/CHANGELOG_CN.json b/cmd/desktop/CHANGELOG_CN.json index e2b1ab85..c6d574f1 100644 --- a/cmd/desktop/CHANGELOG_CN.json +++ b/cmd/desktop/CHANGELOG_CN.json @@ -1,4 +1,11 @@ [ + { + "version": "v4.13.1", + "date": "2026-03-07", + "changes": [ + "修复 cc_openai2 转换器转换异常问题" + ] + }, { "version": "v4.13.0", "date": "2026-01-06", diff --git a/cmd/desktop/build/darwin/Info.plist b/cmd/desktop/build/darwin/Info.plist index 45ebcc56..291e51a8 100644 --- a/cmd/desktop/build/darwin/Info.plist +++ b/cmd/desktop/build/darwin/Info.plist @@ -10,11 +10,11 @@ CFBundleIdentifier com.ccnexus.app CFBundleVersion - 4.13.0 + 4.13.1 CFBundleGetInfoString ccNexus CFBundleShortVersionString - 4.13.0 + 4.13.1 CFBundleIconFile iconfile LSUIElement diff --git a/cmd/desktop/build/windows/wails.exe.manifest b/cmd/desktop/build/windows/wails.exe.manifest index d96e9bb8..32bfa58e 100644 --- a/cmd/desktop/build/windows/wails.exe.manifest +++ b/cmd/desktop/build/windows/wails.exe.manifest @@ -1,6 +1,6 @@ - + A smart API endpoint rotation proxy for Claude Code and Codex CLI diff --git a/cmd/desktop/wails.json b/cmd/desktop/wails.json index c94418a1..ec88c293 100644 --- a/cmd/desktop/wails.json +++ b/cmd/desktop/wails.json @@ -23,7 +23,7 @@ "info": { "companyName": "ccNexus", "productName": "ccNexus", - "productVersion": "4.13.0", + "productVersion": "4.13.1", "copyright": "Copyright © 2024 Chuck", "comments": "A smart API endpoint rotation proxy for Claude Code" } diff --git a/internal/transformer/convert/claude_openai2.go b/internal/transformer/convert/claude_openai2.go index 67d925dd..41ead3ab 100644 --- a/internal/transformer/convert/claude_openai2.go +++ b/internal/transformer/convert/claude_openai2.go @@ -28,23 +28,25 @@ func ClaudeReqToOpenAI2(claudeReq []byte, model string) ([]byte, error) { // Convert messages to input var input []map[string]interface{} for _, msg := range req.Messages { - item := map[string]interface{}{ - "type": "message", - "role": msg.Role, - } - - var contentParts []map[string]interface{} switch content := msg.Content.(type) { case string: - contentParts = append(contentParts, map[string]interface{}{ - "type": "input_text", - "text": content, + textType := "input_text" + if msg.Role == "assistant" { + textType = "output_text" + } + input = append(input, map[string]interface{}{ + "type": "message", + "role": msg.Role, + "content": []map[string]interface{}{ + { + "type": textType, + "text": content, + }, + }, }) case []interface{}: - contentParts = convertClaudeContentToOpenAI2(content, msg.Role) + input = append(input, convertClaudeMessageToOpenAI2Items(content, msg.Role)...) } - item["content"] = contentParts - input = append(input, item) } openai2Req["input"] = input @@ -63,11 +65,79 @@ func ClaudeReqToOpenAI2(claudeReq []byte, model string) ([]byte, error) { }) } openai2Req["tools"] = tools + + // Preserve tool forcing semantics for Responses API backends. + if mapped := mapClaudeToolChoiceToOpenAI2(req.ToolChoice); mapped != nil { + openai2Req["tool_choice"] = mapped + } else { + // For first turn, prefer required to avoid "plan-only" responses. + // After at least one tool_result exists, switch to auto to prevent + // forced repeated tool calls in later turns. + if hasClaudeToolResult(req.Messages) { + openai2Req["tool_choice"] = "auto" + } else { + openai2Req["tool_choice"] = "required" + } + } } return json.Marshal(openai2Req) } +func mapClaudeToolChoiceToOpenAI2(toolChoice interface{}) interface{} { + if toolChoice == nil { + return nil + } + + switch tc := toolChoice.(type) { + case map[string]interface{}: + choiceType, _ := tc["type"].(string) + switch choiceType { + case "tool": + if name, ok := tc["name"].(string); ok && name != "" { + return map[string]interface{}{ + "type": "function", + "name": name, + } + } + case "any": + return "required" + case "auto": + return "auto" + case "none": + return "none" + } + case string: + switch tc { + case "any": + return "required" + default: + return tc + } + } + + return nil +} + +func hasClaudeToolResult(messages []transformer.ClaudeMessage) bool { + for _, msg := range messages { + blocks, ok := msg.Content.([]interface{}) + if !ok { + continue + } + for _, block := range blocks { + m, ok := block.(map[string]interface{}) + if !ok { + continue + } + if t, _ := m["type"].(string); t == "tool_result" { + return true + } + } + } + return false +} + // OpenAI2ReqToClaude converts OpenAI Responses API request to Claude request func OpenAI2ReqToClaude(openai2Req []byte, model string) ([]byte, error) { var req transformer.OpenAI2Request @@ -210,9 +280,13 @@ func OpenAI2RespToClaude(openai2Resp []byte) ([]byte, error) { case "function_call": var args map[string]interface{} json.Unmarshal([]byte(item.Arguments), &args) + toolID := item.CallID + if toolID == "" { + toolID = item.ID + } content = append(content, map[string]interface{}{ "type": "tool_use", - "id": item.CallID, + "id": toolID, "name": item.Name, "input": args, }) @@ -429,7 +503,10 @@ func OpenAI2StreamToClaude(event []byte, ctx *transformer.StreamContext) ([]byte result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ToolIndex})...) ctx.ToolBlockStarted = false } - result = append(result, buildClaudeEvent("message_stop", map[string]interface{}{})...) + if !ctx.FinishReasonSent { + result = append(result, buildClaudeEvent("message_stop", map[string]interface{}{})...) + ctx.FinishReasonSent = true + } return result, nil } return nil, nil @@ -498,6 +575,9 @@ func OpenAI2StreamToClaude(event []byte, ctx *transformer.StreamContext) ([]byte ctx.ToolBlockStarted = true ctx.ToolIndex = ctx.ContentIndex ctx.CurrentToolID = evt.Item.CallID + if ctx.CurrentToolID == "" { + ctx.CurrentToolID = evt.Item.ID + } ctx.CurrentToolName = evt.Item.Name ctx.ToolArguments = "" result = append(result, buildClaudeEvent("content_block_start", map[string]interface{}{ @@ -541,6 +621,8 @@ func OpenAI2StreamToClaude(event []byte, ctx *transformer.StreamContext) ([]byte "delta": map[string]interface{}{"stop_reason": stopReason, "stop_sequence": nil}, "usage": map[string]interface{}{"output_tokens": 0}, })...) + result = append(result, buildClaudeEvent("message_stop", map[string]interface{}{})...) + ctx.FinishReasonSent = true } return result, nil @@ -548,11 +630,24 @@ func OpenAI2StreamToClaude(event []byte, ctx *transformer.StreamContext) ([]byte // Helper functions -func convertClaudeContentToOpenAI2(content []interface{}, role string) []map[string]interface{} { - var parts []map[string]interface{} - contentType := "input_text" +func convertClaudeMessageToOpenAI2Items(content []interface{}, role string) []map[string]interface{} { + var items []map[string]interface{} + var messageParts []map[string]interface{} + textType := "input_text" if role == "assistant" { - contentType = "output_text" + textType = "output_text" + } + + flushMessage := func() { + if len(messageParts) == 0 { + return + } + items = append(items, map[string]interface{}{ + "type": "message", + "role": role, + "content": messageParts, + }) + messageParts = nil } for _, block := range content { @@ -560,26 +655,52 @@ func convertClaudeContentToOpenAI2(content []interface{}, role string) []map[str if !ok { continue } - switch m["type"] { + + blockType, _ := m["type"].(string) + switch blockType { case "text": - parts = append(parts, map[string]interface{}{"type": contentType, "text": m["text"]}) + text, _ := m["text"].(string) + messageParts = append(messageParts, map[string]interface{}{"type": textType, "text": text}) case "thinking": // Skip thinking blocks - they are Claude's internal reasoning continue case "tool_use": + flushMessage() + callID, _ := m["id"].(string) + name, _ := m["name"].(string) args, _ := json.Marshal(m["input"]) - parts = append(parts, map[string]interface{}{ - "type": "output_text", - "text": fmt.Sprintf("[Tool Call: %s(%s)]", m["name"], string(args)), + items = append(items, map[string]interface{}{ + "type": "function_call", + "call_id": callID, + "name": name, + "arguments": string(args), }) case "tool_result": - parts = append(parts, map[string]interface{}{ - "type": "input_text", - "text": fmt.Sprintf("[Tool Result: %v]", m["content"]), + flushMessage() + callID, _ := m["tool_use_id"].(string) + items = append(items, map[string]interface{}{ + "type": "function_call_output", + "call_id": callID, + "output": toolResultToString(m["content"]), }) } } - return parts + flushMessage() + + return items +} + +func toolResultToString(content interface{}) string { + switch v := content.(type) { + case string: + return v + default: + data, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + return string(data) + } } func convertOpenAI2InputToClaude(input interface{}) []map[string]interface{} { @@ -619,6 +740,9 @@ func convertOpenAI2InputToClaude(input interface{}) []map[string]interface{} { case "function_call": // Convert to Claude tool_use callID, _ := itemMap["call_id"].(string) + if callID == "" { + callID, _ = itemMap["id"].(string) + } name, _ := itemMap["name"].(string) argsStr, _ := itemMap["arguments"].(string) var args interface{} diff --git a/internal/transformer/convert/claude_openai2_test.go b/internal/transformer/convert/claude_openai2_test.go index 27dccdab..5a752229 100644 --- a/internal/transformer/convert/claude_openai2_test.go +++ b/internal/transformer/convert/claude_openai2_test.go @@ -89,3 +89,238 @@ func TestOpenAI2StreamToClaudeWithThinking(t *testing.T) { t.Fatalf("Unexpected think tags leaked into output") } } + +func TestOpenAI2StreamToClaudeCompletesWithoutDone(t *testing.T) { + ctx := transformer.NewStreamContext() + ctx.ModelName = "claude-3-sonnet-20240229" + + chunks := []string{ + `data: {"type":"response.created","response":{"id":"resp_1","object":"response","status":"in_progress"}}`, + `data: {"type":"response.output_text.delta","delta":"hello"}`, + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed"}}`, + } + + var allEvents []string + for _, chunk := range chunks { + events, err := OpenAI2StreamToClaude([]byte(chunk), ctx) + if err != nil { + t.Fatalf("OpenAI2StreamToClaude failed: %v", err) + } + if events != nil { + allEvents = append(allEvents, string(events)) + } + } + + fullEvents := strings.Join(allEvents, "") + if !strings.Contains(fullEvents, "\"type\":\"message_delta\"") { + t.Fatalf("Expected message_delta in transformed events, got: %s", fullEvents) + } + if !strings.Contains(fullEvents, "event: message_stop") { + t.Fatalf("Expected message_stop when response.completed arrives without [DONE], got: %s", fullEvents) + } +} + +func TestClaudeReqToOpenAI2PreservesToolChain(t *testing.T) { + claudeReq := `{ + "model": "claude-sonnet-4-20250514", + "stream": false, + "messages": [ + {"role":"user","content":"请写文件"}, + {"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Write","input":{"file_path":"/tmp/a.txt","content":"hello"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]} + ], + "tools": [ + {"name":"Write","description":"Write file","input_schema":{"type":"object","properties":{"file_path":{"type":"string"},"content":{"type":"string"}},"required":["file_path","content"]}} + ] + }` + + reqBytes, err := ClaudeReqToOpenAI2([]byte(claudeReq), "gpt-4.1") + if err != nil { + t.Fatalf("ClaudeReqToOpenAI2 failed: %v", err) + } + + var req map[string]interface{} + if err := json.Unmarshal(reqBytes, &req); err != nil { + t.Fatalf("unmarshal transformed req failed: %v", err) + } + + input, ok := req["input"].([]interface{}) + if !ok { + t.Fatalf("input should be array, got %T", req["input"]) + } + if len(input) != 3 { + t.Fatalf("expected 3 input items, got %d", len(input)) + } + + functionCall, ok := input[1].(map[string]interface{}) + if !ok || functionCall["type"] != "function_call" { + t.Fatalf("expected input[1] function_call, got %#v", input[1]) + } + if functionCall["call_id"] != "toolu_1" { + t.Fatalf("expected call_id toolu_1, got %#v", functionCall["call_id"]) + } + if _, hasID := functionCall["id"]; hasID { + t.Fatalf("function_call.id should not be set for upstream compatibility, got %#v", functionCall["id"]) + } + + argsStr, _ := functionCall["arguments"].(string) + var args map[string]interface{} + if err := json.Unmarshal([]byte(argsStr), &args); err != nil { + t.Fatalf("function arguments is not valid json: %v, raw=%s", err, argsStr) + } + if args["file_path"] != "/tmp/a.txt" { + t.Fatalf("unexpected function arguments: %#v", args) + } + + functionOutput, ok := input[2].(map[string]interface{}) + if !ok || functionOutput["type"] != "function_call_output" { + t.Fatalf("expected input[2] function_call_output, got %#v", input[2]) + } + if functionOutput["call_id"] != "toolu_1" { + t.Fatalf("expected output call_id toolu_1, got %#v", functionOutput["call_id"]) + } + if functionOutput["output"] != "ok" { + t.Fatalf("expected output ok, got %#v", functionOutput["output"]) + } + + if strings.Contains(string(reqBytes), "[Tool Call:") || strings.Contains(string(reqBytes), "[Tool Result:") { + t.Fatalf("found legacy pseudo tool text in transformed request: %s", string(reqBytes)) + } +} + +func TestOpenAI2RespToClaudeFallbackToItemID(t *testing.T) { + openai2Resp := `{ + "id":"resp_1", + "object":"response", + "status":"completed", + "output":[{"type":"function_call","id":"fc_123","name":"Write","arguments":"{\"file_path\":\"/tmp/a.txt\"}"}], + "usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3} + }` + + claudeRespBytes, err := OpenAI2RespToClaude([]byte(openai2Resp)) + if err != nil { + t.Fatalf("OpenAI2RespToClaude failed: %v", err) + } + + var claudeResp map[string]interface{} + if err := json.Unmarshal(claudeRespBytes, &claudeResp); err != nil { + t.Fatalf("unmarshal claude resp failed: %v", err) + } + + content, ok := claudeResp["content"].([]interface{}) + if !ok || len(content) != 1 { + t.Fatalf("unexpected content: %#v", claudeResp["content"]) + } + + toolUse, ok := content[0].(map[string]interface{}) + if !ok { + t.Fatalf("tool_use item type invalid: %#v", content[0]) + } + if toolUse["type"] != "tool_use" { + t.Fatalf("expected tool_use type, got %#v", toolUse["type"]) + } + if toolUse["id"] != "fc_123" { + t.Fatalf("expected tool_use id from item.id fallback, got %#v", toolUse["id"]) + } +} + +func TestClaudeReqToOpenAI2MapsToolChoiceAnyToRequired(t *testing.T) { + claudeReq := `{ + "model": "claude-sonnet-4-20250514", + "stream": true, + "messages": [{"role":"user","content":"test"}], + "tools": [{"name":"Write","description":"Write file","input_schema":{"type":"object"}}], + "tool_choice": {"type":"any"} + }` + + reqBytes, err := ClaudeReqToOpenAI2([]byte(claudeReq), "gpt-4.1") + if err != nil { + t.Fatalf("ClaudeReqToOpenAI2 failed: %v", err) + } + + var req map[string]interface{} + if err := json.Unmarshal(reqBytes, &req); err != nil { + t.Fatalf("unmarshal transformed req failed: %v", err) + } + + if req["tool_choice"] != "required" { + t.Fatalf("expected tool_choice=required, got %#v", req["tool_choice"]) + } +} + +func TestClaudeReqToOpenAI2MapsNamedToolChoice(t *testing.T) { + claudeReq := `{ + "model": "claude-sonnet-4-20250514", + "stream": true, + "messages": [{"role":"user","content":"test"}], + "tools": [{"name":"Write","description":"Write file","input_schema":{"type":"object"}}], + "tool_choice": {"type":"tool","name":"Write"} + }` + + reqBytes, err := ClaudeReqToOpenAI2([]byte(claudeReq), "gpt-4.1") + if err != nil { + t.Fatalf("ClaudeReqToOpenAI2 failed: %v", err) + } + + var req map[string]interface{} + if err := json.Unmarshal(reqBytes, &req); err != nil { + t.Fatalf("unmarshal transformed req failed: %v", err) + } + + toolChoice, ok := req["tool_choice"].(map[string]interface{}) + if !ok { + t.Fatalf("expected object tool_choice, got %#v", req["tool_choice"]) + } + if toolChoice["type"] != "function" || toolChoice["name"] != "Write" { + t.Fatalf("unexpected tool_choice mapping: %#v", toolChoice) + } +} + +func TestClaudeReqToOpenAI2DefaultsToolChoiceRequiredWhenToolsPresent(t *testing.T) { + claudeReq := `{ + "model": "claude-sonnet-4-20250514", + "stream": true, + "messages": [{"role":"user","content":"test"}], + "tools": [{"name":"Write","description":"Write file","input_schema":{"type":"object"}}] + }` + + reqBytes, err := ClaudeReqToOpenAI2([]byte(claudeReq), "gpt-4.1") + if err != nil { + t.Fatalf("ClaudeReqToOpenAI2 failed: %v", err) + } + + var req map[string]interface{} + if err := json.Unmarshal(reqBytes, &req); err != nil { + t.Fatalf("unmarshal transformed req failed: %v", err) + } + + if req["tool_choice"] != "required" { + t.Fatalf("expected tool_choice=required, got %#v", req["tool_choice"]) + } +} + +func TestClaudeReqToOpenAI2DefaultsToolChoiceAutoAfterToolResult(t *testing.T) { + claudeReq := `{ + "model": "claude-sonnet-4-20250514", + "stream": true, + "messages": [ + {"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/tmp/a"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]} + ], + "tools": [{"name":"Read","description":"Read file","input_schema":{"type":"object"}}] + }` + + reqBytes, err := ClaudeReqToOpenAI2([]byte(claudeReq), "gpt-4.1") + if err != nil { + t.Fatalf("ClaudeReqToOpenAI2 failed: %v", err) + } + + var req map[string]interface{} + if err := json.Unmarshal(reqBytes, &req); err != nil { + t.Fatalf("unmarshal transformed req failed: %v", err) + } + + if req["tool_choice"] != "auto" { + t.Fatalf("expected tool_choice=auto after tool_result, got %#v", req["tool_choice"]) + } +} diff --git a/internal/transformer/convert/openai_openai2.go b/internal/transformer/convert/openai_openai2.go index f75d8d9b..b923ca18 100644 --- a/internal/transformer/convert/openai_openai2.go +++ b/internal/transformer/convert/openai_openai2.go @@ -61,6 +61,15 @@ func OpenAIReqToOpenAI2(openaiReq []byte, model string) ([]byte, error) { } } openai2Req["tools"] = tools + + // Preserve explicit tool routing semantics when moving to Responses API. + if mapped := mapOpenAIToolChoiceToOpenAI2(req.ToolChoice); mapped != nil { + openai2Req["tool_choice"] = mapped + } else { + // Keep explicit default for compatibility with providers that do not + // treat omitted tool_choice as "auto". + openai2Req["tool_choice"] = "auto" + } } return json.Marshal(openai2Req) @@ -178,9 +187,68 @@ func OpenAI2ReqToOpenAI(openai2Req []byte, model string) ([]byte, error) { } } + if req.ToolChoice != nil { + openaiReq.ToolChoice = mapOpenAI2ToolChoiceToOpenAI(req.ToolChoice) + } + return json.Marshal(openaiReq) } +func mapOpenAIToolChoiceToOpenAI2(toolChoice interface{}) interface{} { + if toolChoice == nil { + return nil + } + + switch tc := toolChoice.(type) { + case string: + return tc + case map[string]interface{}: + choiceType, _ := tc["type"].(string) + if choiceType != "function" { + return nil + } + + // Chat Completions shape: {"type":"function","function":{"name":"..."}} + if fn, ok := tc["function"].(map[string]interface{}); ok { + if name, ok := fn["name"].(string); ok && name != "" { + return map[string]interface{}{"type": "function", "name": name} + } + } + + // Responses-compatible shape already. + if name, ok := tc["name"].(string); ok && name != "" { + return map[string]interface{}{"type": "function", "name": name} + } + } + + return nil +} + +func mapOpenAI2ToolChoiceToOpenAI(toolChoice interface{}) interface{} { + if toolChoice == nil { + return nil + } + + switch tc := toolChoice.(type) { + case string: + return tc + case map[string]interface{}: + choiceType, _ := tc["type"].(string) + if choiceType == "function" { + if name, ok := tc["name"].(string); ok && name != "" { + return map[string]interface{}{ + "type": "function", + "function": map[string]string{ + "name": name, + }, + } + } + } + } + + return nil +} + // OpenAIRespToOpenAI2 converts OpenAI Chat response to OpenAI Responses response func OpenAIRespToOpenAI2(openaiResp []byte) ([]byte, error) { var resp transformer.OpenAIResponse diff --git a/internal/transformer/convert/openai_openai2_test.go b/internal/transformer/convert/openai_openai2_test.go new file mode 100644 index 00000000..200ea9f0 --- /dev/null +++ b/internal/transformer/convert/openai_openai2_test.go @@ -0,0 +1,29 @@ +package convert + +import ( + "encoding/json" + "testing" +) + +func TestOpenAIReqToOpenAI2DefaultsToolChoiceAutoWhenToolsPresent(t *testing.T) { + openaiReq := `{ + "model":"gpt-4.1", + "stream":true, + "messages":[{"role":"user","content":"test"}], + "tools":[{"type":"function","function":{"name":"Write","description":"Write file","parameters":{"type":"object"}}}] + }` + + reqBytes, err := OpenAIReqToOpenAI2([]byte(openaiReq), "gpt-4.1") + if err != nil { + t.Fatalf("OpenAIReqToOpenAI2 failed: %v", err) + } + + var req map[string]interface{} + if err := json.Unmarshal(reqBytes, &req); err != nil { + t.Fatalf("unmarshal transformed req failed: %v", err) + } + + if req["tool_choice"] != "auto" { + t.Fatalf("expected tool_choice=auto, got %#v", req["tool_choice"]) + } +} diff --git a/internal/transformer/types.go b/internal/transformer/types.go index f7648c0d..45af632e 100644 --- a/internal/transformer/types.go +++ b/internal/transformer/types.go @@ -358,6 +358,7 @@ type OpenAI2Request struct { Input interface{} `json:"input"` // string or []OpenAI2InputItem Instructions string `json:"instructions,omitempty"` // system prompt Tools []OpenAI2Tool `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` Stream bool `json:"stream,omitempty"` MaxOutputTokens int `json:"max_output_tokens,omitempty"` Temperature *float64 `json:"temperature,omitempty"`