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"`