Skip to content

Commit 1fca942

Browse files
committed
Merge remote-tracking branch 'origin/pr/3239'
2 parents 5e5b1bc + a09a16e commit 1fca942

2 files changed

Lines changed: 204 additions & 11 deletions

File tree

internal/translator/openai/openai/responses/openai_openai-responses_request.go

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,72 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
5757

5858
// Convert input array to messages
5959
if input := root.Get("input"); input.Exists() && input.IsArray() {
60-
input.ForEach(func(_, item gjson.Result) bool {
60+
inputItems := input.Array()
61+
outputCallIDs := make(map[string]struct{})
62+
for _, item := range inputItems {
63+
if item.Get("type").String() != "function_call_output" {
64+
continue
65+
}
66+
callID := strings.TrimSpace(item.Get("call_id").String())
67+
if callID == "" {
68+
continue
69+
}
70+
outputCallIDs[callID] = struct{}{}
71+
}
72+
73+
pendingToolCalls := make([]interface{}, 0)
74+
pendingToolCallIDs := make([]string, 0)
75+
awaitingToolOutputs := make(map[string]struct{})
76+
deferredMessages := make([][]byte, 0)
77+
78+
flushPendingToolCalls := func() {
79+
if len(pendingToolCalls) == 0 {
80+
return
81+
}
82+
assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`)
83+
assistantMessage, _ = sjson.SetBytes(assistantMessage, "tool_calls", pendingToolCalls)
84+
out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage)
85+
for _, id := range pendingToolCallIDs {
86+
if strings.TrimSpace(id) == "" {
87+
continue
88+
}
89+
awaitingToolOutputs[id] = struct{}{}
90+
}
91+
pendingToolCalls = pendingToolCalls[:0]
92+
pendingToolCallIDs = pendingToolCallIDs[:0]
93+
}
94+
flushDeferredMessages := func() {
95+
for _, message := range deferredMessages {
96+
out, _ = sjson.SetRawBytes(out, "messages.-1", message)
97+
}
98+
deferredMessages = deferredMessages[:0]
99+
}
100+
hasAwaitingToolOutput := func() bool {
101+
for id := range awaitingToolOutputs {
102+
if _, ok := outputCallIDs[id]; ok {
103+
return true
104+
}
105+
}
106+
return false
107+
}
108+
appendRegularMessage := func(message []byte) {
109+
// Keep tool-call adjacency strict for providers that require
110+
// assistant(tool_calls) -> tool(tool_call_id) with no message in between.
111+
if hasAwaitingToolOutput() {
112+
deferredMessages = append(deferredMessages, message)
113+
return
114+
}
115+
out, _ = sjson.SetRawBytes(out, "messages.-1", message)
116+
}
117+
118+
for _, item := range inputItems {
61119
itemType := item.Get("type").String()
62120
if itemType == "" && item.Get("role").String() != "" {
63121
itemType = "message"
64122
}
123+
if itemType != "function_call" {
124+
flushPendingToolCalls()
125+
}
65126

66127
switch itemType {
67128
case "message", "":
@@ -109,12 +170,10 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
109170
message, _ = sjson.SetBytes(message, "content", content.String())
110171
}
111172

112-
out, _ = sjson.SetRawBytes(out, "messages.-1", message)
173+
appendRegularMessage(message)
113174

114175
case "function_call":
115-
// Handle function call conversion to assistant message with tool_calls
116-
assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`)
117-
176+
// Buffer consecutive function calls and emit them as one assistant message.
118177
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
119178

120179
if callId := item.Get("call_id"); callId.Exists() {
@@ -128,27 +187,37 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
128187
if arguments := item.Get("arguments"); arguments.Exists() {
129188
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String())
130189
}
131-
132-
assistantMessage, _ = sjson.SetRawBytes(assistantMessage, "tool_calls.0", toolCall)
133-
out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage)
190+
pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value())
191+
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
192+
pendingToolCallIDs = append(pendingToolCallIDs, callID)
193+
}
134194

135195
case "function_call_output":
136196
// Handle function call output conversion to tool message
137197
toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
198+
callID := ""
138199

139200
if callId := item.Get("call_id"); callId.Exists() {
140-
toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callId.String())
201+
callID = strings.TrimSpace(callId.String())
202+
toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callID)
141203
}
142204

143205
if output := item.Get("output"); output.Exists() {
144206
toolMessage, _ = sjson.SetBytes(toolMessage, "content", output.String())
145207
}
146208

147209
out, _ = sjson.SetRawBytes(out, "messages.-1", toolMessage)
210+
if callID != "" {
211+
delete(awaitingToolOutputs, callID)
212+
}
213+
if len(awaitingToolOutputs) == 0 && len(deferredMessages) > 0 {
214+
flushDeferredMessages()
215+
}
148216
}
149217

150-
return true
151-
})
218+
}
219+
flushPendingToolCalls()
220+
flushDeferredMessages()
152221
} else if input.Type == gjson.String {
153222
msg := []byte(`{}`)
154223
msg, _ = sjson.SetBytes(msg, "role", "user")
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package responses
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/tidwall/gjson"
9+
)
10+
11+
func prettyJSONForTest(raw []byte) string {
12+
if !gjson.ValidBytes(raw) {
13+
return string(raw)
14+
}
15+
var out bytes.Buffer
16+
if err := json.Indent(&out, raw, "", " "); err != nil {
17+
return string(raw)
18+
}
19+
return out.String()
20+
}
21+
22+
func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MergeConsecutiveFunctionCalls(t *testing.T) {
23+
raw := []byte(`{
24+
"input": [
25+
{"type":"function_call","call_id":"exec_command:0","name":"exec_command","arguments":"{\"cmd\":\"ls\"}"},
26+
{"type":"function_call","call_id":"exec_command:1","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"},
27+
{"type":"function_call_output","call_id":"exec_command:0","output":"ok0"},
28+
{"type":"function_call_output","call_id":"exec_command:1","output":"ok1"}
29+
]
30+
}`)
31+
t.Logf("input json:\n%s", prettyJSONForTest(raw))
32+
33+
out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, true)
34+
t.Logf("output json:\n%s", prettyJSONForTest(out))
35+
36+
msgs := gjson.GetBytes(out, "messages")
37+
if !msgs.Exists() || !msgs.IsArray() {
38+
t.Fatalf("messages should be an array")
39+
}
40+
if got := len(msgs.Array()); got != 3 {
41+
t.Fatalf("messages count = %d, want %d", got, 3)
42+
}
43+
44+
if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
45+
t.Fatalf("messages.0.role = %q, want %q", got, "assistant")
46+
}
47+
if got := len(gjson.GetBytes(out, "messages.0.tool_calls").Array()); got != 2 {
48+
t.Fatalf("messages.0.tool_calls length = %d, want %d", got, 2)
49+
}
50+
if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "exec_command:0" {
51+
t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "exec_command:0")
52+
}
53+
if got := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String(); got != "exec_command:1" {
54+
t.Fatalf("messages.0.tool_calls.1.id = %q, want %q", got, "exec_command:1")
55+
}
56+
57+
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "exec_command:0" {
58+
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "exec_command:0")
59+
}
60+
if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != "exec_command:1" {
61+
t.Fatalf("messages.2.tool_call_id = %q, want %q", got, "exec_command:1")
62+
}
63+
}
64+
65+
func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SplitFunctionCallsWhenInterrupted(t *testing.T) {
66+
raw := []byte(`{
67+
"input": [
68+
{"type":"function_call","call_id":"call_a","name":"tool_a","arguments":"{}"},
69+
{"type":"message","role":"user","content":"next"},
70+
{"type":"function_call","call_id":"call_b","name":"tool_b","arguments":"{}"}
71+
]
72+
}`)
73+
t.Logf("input json:\n%s", prettyJSONForTest(raw))
74+
75+
out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, false)
76+
t.Logf("output json:\n%s", prettyJSONForTest(out))
77+
78+
if got := len(gjson.GetBytes(out, "messages").Array()); got != 3 {
79+
t.Fatalf("messages count = %d, want %d", got, 3)
80+
}
81+
if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "call_a" {
82+
t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "call_a")
83+
}
84+
if got := gjson.GetBytes(out, "messages.2.tool_calls.0.id").String(); got != "call_b" {
85+
t.Fatalf("messages.2.tool_calls.0.id = %q, want %q", got, "call_b")
86+
}
87+
}
88+
89+
func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DefersMessageUntilToolOutput(t *testing.T) {
90+
raw := []byte(`{
91+
"input": [
92+
{"type":"function_call","call_id":"call_x","name":"exec_command","arguments":"{\"cmd\":\"echo hi\"}"},
93+
{"type":"message","role":"user","content":"Approved command prefix saved"},
94+
{"type":"function_call_output","call_id":"call_x","output":"ok"},
95+
{"type":"message","role":"user","content":"next"}
96+
]
97+
}`)
98+
t.Logf("input json:\n%s", prettyJSONForTest(raw))
99+
100+
out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, true)
101+
t.Logf("output json:\n%s", prettyJSONForTest(out))
102+
103+
if got := len(gjson.GetBytes(out, "messages").Array()); got != 4 {
104+
t.Fatalf("messages count = %d, want %d", got, 4)
105+
}
106+
if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
107+
t.Fatalf("messages.0.role = %q, want %q", got, "assistant")
108+
}
109+
if got := gjson.GetBytes(out, "messages.1.role").String(); got != "tool" {
110+
t.Fatalf("messages.1.role = %q, want %q", got, "tool")
111+
}
112+
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "call_x" {
113+
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_x")
114+
}
115+
if got := gjson.GetBytes(out, "messages.2.role").String(); got != "user" {
116+
t.Fatalf("messages.2.role = %q, want %q", got, "user")
117+
}
118+
if got := gjson.GetBytes(out, "messages.2.content").String(); got != "Approved command prefix saved" {
119+
t.Fatalf("messages.2.content = %q, want %q", got, "Approved command prefix saved")
120+
}
121+
if got := gjson.GetBytes(out, "messages.3.content").String(); got != "next" {
122+
t.Fatalf("messages.3.content = %q, want %q", got, "next")
123+
}
124+
}

0 commit comments

Comments
 (0)