diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 2366c9c37..9164a4116 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -57,11 +57,25 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Convert input array to messages if input := root.Get("input"); input.Exists() && input.IsArray() { + pendingToolCalls := make([]interface{}, 0) + flushPendingToolCalls := func() { + if len(pendingToolCalls) == 0 { + return + } + assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) + assistantMessage, _ = sjson.SetBytes(assistantMessage, "tool_calls", pendingToolCalls) + out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) + pendingToolCalls = pendingToolCalls[:0] + } + input.ForEach(func(_, item gjson.Result) bool { itemType := item.Get("type").String() if itemType == "" && item.Get("role").String() != "" { itemType = "message" } + if itemType != "function_call" { + flushPendingToolCalls() + } switch itemType { case "message", "": @@ -112,9 +126,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu out, _ = sjson.SetRawBytes(out, "messages.-1", message) case "function_call": - // Handle function call conversion to assistant message with tool_calls - assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) - + // Buffer consecutive function calls and emit them as one assistant message. toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`) if callId := item.Get("call_id"); callId.Exists() { @@ -128,9 +140,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu if arguments := item.Get("arguments"); arguments.Exists() { toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String()) } - - assistantMessage, _ = sjson.SetRawBytes(assistantMessage, "tool_calls.0", toolCall) - out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) + pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value()) case "function_call_output": // Handle function call output conversion to tool message @@ -149,6 +159,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu return true }) + flushPendingToolCalls() } else if input.Type == gjson.String { msg := []byte(`{}`) msg, _ = sjson.SetBytes(msg, "role", "user") diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go new file mode 100644 index 000000000..e9339753a --- /dev/null +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -0,0 +1,87 @@ +package responses + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/tidwall/gjson" +) + +func prettyJSONForTest(raw []byte) string { + if !gjson.ValidBytes(raw) { + return string(raw) + } + var out bytes.Buffer + if err := json.Indent(&out, raw, "", " "); err != nil { + return string(raw) + } + return out.String() +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MergeConsecutiveFunctionCalls(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"function_call","call_id":"exec_command:0","name":"exec_command","arguments":"{\"cmd\":\"ls\"}"}, + {"type":"function_call","call_id":"exec_command:1","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}, + {"type":"function_call_output","call_id":"exec_command:0","output":"ok0"}, + {"type":"function_call_output","call_id":"exec_command:1","output":"ok1"} + ] + }`) + t.Logf("input json:\n%s", prettyJSONForTest(raw)) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, true) + t.Logf("output json:\n%s", prettyJSONForTest(out)) + + msgs := gjson.GetBytes(out, "messages") + if !msgs.Exists() || !msgs.IsArray() { + t.Fatalf("messages should be an array") + } + if got := len(msgs.Array()); got != 3 { + t.Fatalf("messages count = %d, want %d", got, 3) + } + + if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" { + t.Fatalf("messages.0.role = %q, want %q", got, "assistant") + } + if got := len(gjson.GetBytes(out, "messages.0.tool_calls").Array()); got != 2 { + t.Fatalf("messages.0.tool_calls length = %d, want %d", got, 2) + } + if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "exec_command:0" { + t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "exec_command:0") + } + if got := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String(); got != "exec_command:1" { + t.Fatalf("messages.0.tool_calls.1.id = %q, want %q", got, "exec_command:1") + } + + if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "exec_command:0" { + t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "exec_command:0") + } + if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != "exec_command:1" { + t.Fatalf("messages.2.tool_call_id = %q, want %q", got, "exec_command:1") + } +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SplitFunctionCallsWhenInterrupted(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"function_call","call_id":"call_a","name":"tool_a","arguments":"{}"}, + {"type":"message","role":"user","content":"next"}, + {"type":"function_call","call_id":"call_b","name":"tool_b","arguments":"{}"} + ] + }`) + t.Logf("input json:\n%s", prettyJSONForTest(raw)) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, false) + t.Logf("output json:\n%s", prettyJSONForTest(out)) + + if got := len(gjson.GetBytes(out, "messages").Array()); got != 3 { + t.Fatalf("messages count = %d, want %d", got, 3) + } + if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "call_a" { + t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "call_a") + } + if got := gjson.GetBytes(out, "messages.2.tool_calls.0.id").String(); got != "call_b" { + t.Fatalf("messages.2.tool_calls.0.id = %q, want %q", got, "call_b") + } +}