History

Merge pull request #2231 from 7RPH/fix/responses-stream-multi-tool-calls

Luis Pater committed 7 months ago

Commit 779e6c22 parents 73c8317 cc32f5f2 files · +440 −132Browse files

Unified · Split

2 files
  1. internal/translator/openai/openai/responses/openai_openai-responses_response.go +135 −132
  2. internal/translator/openai/openai/responses/openai_openai-responses_response_test.go +305 −0
internal/translator/openai/openai/responses/openai_openai-responses_response.go+135−132
@@ -4,6 +4,7 @@ import ( · 3 unmodified lines
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "fmt"6 "fmt"
7 "sort"
7 "strings"8 "strings"
8 "sync/atomic"9 "sync/atomic"
9 "time"10 "time"
@@ -16,6 +17,7 @@ import ( · 6 unmodified lines
16type oaiToResponsesStateReasoning struct {17type oaiToResponsesStateReasoning struct {
17 ReasoningID string18 ReasoningID string
18 ReasoningData string19 ReasoningData string
20 OutputIndex int
19}21}
20type oaiToResponsesState struct {22type oaiToResponsesState struct {
21 Seq int23 Seq int
@@ -29,16 +31,19 @@ type oaiToResponsesState struct { · 7 unmodified lines
29 MsgTextBuf map[int]*strings.Builder31 MsgTextBuf map[int]*strings.Builder
30 ReasoningBuf strings.Builder32 ReasoningBuf strings.Builder
31 Reasonings []oaiToResponsesStateReasoning33 Reasonings []oaiToResponsesStateReasoning
32 FuncArgsBuf map[int]*strings.Builder // index -> args34 FuncArgsBuf map[string]*strings.Builder
33 FuncNames map[int]string // index -> name35 FuncNames map[string]string
34 FuncCallIDs map[int]string // index -> call_id36 FuncCallIDs map[string]string
37 FuncOutputIx map[string]int
38 MsgOutputIx map[int]int
39 NextOutputIx int
35 // message item state per output index40 // message item state per output index
36 MsgItemAdded map[int]bool // whether response.output_item.added emitted for message41 MsgItemAdded map[int]bool // whether response.output_item.added emitted for message
37 MsgContentAdded map[int]bool // whether response.content_part.added emitted for message42 MsgContentAdded map[int]bool // whether response.content_part.added emitted for message
38 MsgItemDone map[int]bool // whether message done events were emitted43 MsgItemDone map[int]bool // whether message done events were emitted
39 // function item done state44 // function item done state
40 FuncArgsDone map[int]bool45 FuncArgsDone map[string]bool
41 FuncItemDone map[int]bool46 FuncItemDone map[string]bool
42 // usage aggregation47 // usage aggregation
43 PromptTokens int6448 PromptTokens int64
44 CachedTokens int6449 CachedTokens int64
@@ -60,15 +65,17 @@ func emitRespEvent(event string, payload []byte) []byte { · 15 unmodified lines
60func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {65func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
61 if *param == nil {66 if *param == nil {
62 *param = &oaiToResponsesState{67 *param = &oaiToResponsesState{
63 FuncArgsBuf: make(map[int]*strings.Builder),68 FuncArgsBuf: make(map[string]*strings.Builder),
64 FuncNames: make(map[int]string),69 FuncNames: make(map[string]string),
65 FuncCallIDs: make(map[int]string),70 FuncCallIDs: make(map[string]string),
71 FuncOutputIx: make(map[string]int),
72 MsgOutputIx: make(map[int]int),
66 MsgTextBuf: make(map[int]*strings.Builder),73 MsgTextBuf: make(map[int]*strings.Builder),
67 MsgItemAdded: make(map[int]bool),74 MsgItemAdded: make(map[int]bool),
68 MsgContentAdded: make(map[int]bool),75 MsgContentAdded: make(map[int]bool),
69 MsgItemDone: make(map[int]bool),76 MsgItemDone: make(map[int]bool),
70 FuncArgsDone: make(map[int]bool),77 FuncArgsDone: make(map[string]bool),
71 FuncItemDone: make(map[int]bool),78 FuncItemDone: make(map[string]bool),
72 Reasonings: make([]oaiToResponsesStateReasoning, 0),79 Reasonings: make([]oaiToResponsesStateReasoning, 0),
73 }80 }
74 }81 }
@@ -125,6 +132,12 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 50 unmodified lines
125 }132 }
126 133
127 nextSeq := func() int { st.Seq++; return st.Seq }134 nextSeq := func() int { st.Seq++; return st.Seq }
135 allocOutputIndex := func() int {
136 ix := st.NextOutputIx
137 st.NextOutputIx++
138 return ix
139 }
140 toolStateKey := func(outputIndex, toolIndex int) string { return fmt.Sprintf("%d:%d", outputIndex, toolIndex) }
128 var out [][]byte141 var out [][]byte
129 142
130 if !st.Started {143 if !st.Started {
@@ -135,14 +148,17 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 4 unmodified lines
135 st.ReasoningBuf.Reset()148 st.ReasoningBuf.Reset()
136 st.ReasoningID = ""149 st.ReasoningID = ""
137 st.ReasoningIndex = 0150 st.ReasoningIndex = 0
138 st.FuncArgsBuf = make(map[int]*strings.Builder)151 st.FuncArgsBuf = make(map[string]*strings.Builder)
139 st.FuncNames = make(map[int]string)152 st.FuncNames = make(map[string]string)
140 st.FuncCallIDs = make(map[int]string)153 st.FuncCallIDs = make(map[string]string)
154 st.FuncOutputIx = make(map[string]int)
155 st.MsgOutputIx = make(map[int]int)
156 st.NextOutputIx = 0
141 st.MsgItemAdded = make(map[int]bool)157 st.MsgItemAdded = make(map[int]bool)
142 st.MsgContentAdded = make(map[int]bool)158 st.MsgContentAdded = make(map[int]bool)
143 st.MsgItemDone = make(map[int]bool)159 st.MsgItemDone = make(map[int]bool)
144 st.FuncArgsDone = make(map[int]bool)160 st.FuncArgsDone = make(map[string]bool)
145 st.FuncItemDone = make(map[int]bool)161 st.FuncItemDone = make(map[string]bool)
146 st.PromptTokens = 0162 st.PromptTokens = 0
147 st.CachedTokens = 0163 st.CachedTokens = 0
148 st.CompletionTokens = 0164 st.CompletionTokens = 0
@@ -185,7 +201,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 36 unmodified lines
185 outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.summary.text", text)201 outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.summary.text", text)
186 out = append(out, emitRespEvent("response.output_item.done", outputItemDone))202 out = append(out, emitRespEvent("response.output_item.done", outputItemDone))
187 203
188 st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text})204 st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text, OutputIndex: st.ReasoningIndex})
189 st.ReasoningID = ""205 st.ReasoningID = ""
190 }206 }
191 207
@@ -201,10 +217,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 9 unmodified lines
201 stopReasoning(st.ReasoningBuf.String())217 stopReasoning(st.ReasoningBuf.String())
202 st.ReasoningBuf.Reset()218 st.ReasoningBuf.Reset()
203 }219 }
220 if _, exists := st.MsgOutputIx[idx]; !exists {
221 st.MsgOutputIx[idx] = allocOutputIndex()
222 }
223 msgOutputIndex := st.MsgOutputIx[idx]
204 if !st.MsgItemAdded[idx] {224 if !st.MsgItemAdded[idx] {
205 item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)225 item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
206 item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())226 item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
207 item, _ = sjson.SetBytes(item, "output_index", idx)227 item, _ = sjson.SetBytes(item, "output_index", msgOutputIndex)
208 item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))228 item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
209 out = append(out, emitRespEvent("response.output_item.added", item))229 out = append(out, emitRespEvent("response.output_item.added", item))
210 st.MsgItemAdded[idx] = true230 st.MsgItemAdded[idx] = true
@@ -213,7 +233,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 2 unmodified lines
213 part := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)233 part := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
214 part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())234 part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())
215 part, _ = sjson.SetBytes(part, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))235 part, _ = sjson.SetBytes(part, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
216 part, _ = sjson.SetBytes(part, "output_index", idx)236 part, _ = sjson.SetBytes(part, "output_index", msgOutputIndex)
217 part, _ = sjson.SetBytes(part, "content_index", 0)237 part, _ = sjson.SetBytes(part, "content_index", 0)
218 out = append(out, emitRespEvent("response.content_part.added", part))238 out = append(out, emitRespEvent("response.content_part.added", part))
219 st.MsgContentAdded[idx] = true239 st.MsgContentAdded[idx] = true
@@ -222,7 +242,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 2 unmodified lines
222 msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)242 msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
223 msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())243 msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
224 msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))244 msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
225 msg, _ = sjson.SetBytes(msg, "output_index", idx)245 msg, _ = sjson.SetBytes(msg, "output_index", msgOutputIndex)
226 msg, _ = sjson.SetBytes(msg, "content_index", 0)246 msg, _ = sjson.SetBytes(msg, "content_index", 0)
227 msg, _ = sjson.SetBytes(msg, "delta", c.String())247 msg, _ = sjson.SetBytes(msg, "delta", c.String())
228 out = append(out, emitRespEvent("response.output_text.delta", msg))248 out = append(out, emitRespEvent("response.output_text.delta", msg))
@@ -238,10 +258,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 9 unmodified lines
238 // On first appearance, add reasoning item and part258 // On first appearance, add reasoning item and part
239 if st.ReasoningID == "" {259 if st.ReasoningID == "" {
240 st.ReasoningID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx)260 st.ReasoningID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx)
241 st.ReasoningIndex = idx261 st.ReasoningIndex = allocOutputIndex()
242 item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`)262 item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`)
243 item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())263 item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
244 item, _ = sjson.SetBytes(item, "output_index", idx)264 item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex)
245 item, _ = sjson.SetBytes(item, "item.id", st.ReasoningID)265 item, _ = sjson.SetBytes(item, "item.id", st.ReasoningID)
246 out = append(out, emitRespEvent("response.output_item.added", item))266 out = append(out, emitRespEvent("response.output_item.added", item))
247 part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)267 part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
@@ -269,6 +289,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 21 unmodified lines
269 // Before emitting any function events, if a message is open for this index,289 // Before emitting any function events, if a message is open for this index,
270 // close its text/content to match Codex expected ordering.290 // close its text/content to match Codex expected ordering.
271 if st.MsgItemAdded[idx] && !st.MsgItemDone[idx] {291 if st.MsgItemAdded[idx] && !st.MsgItemDone[idx] {
292 msgOutputIndex := st.MsgOutputIx[idx]
272 fullText := ""293 fullText := ""
273 if b := st.MsgTextBuf[idx]; b != nil {294 if b := st.MsgTextBuf[idx]; b != nil {
274 fullText = b.String()295 fullText = b.String()
@@ -276,7 +297,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 1 unmodified line
276 done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)297 done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
277 done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())298 done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
278 done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))299 done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
279 done, _ = sjson.SetBytes(done, "output_index", idx)300 done, _ = sjson.SetBytes(done, "output_index", msgOutputIndex)
280 done, _ = sjson.SetBytes(done, "content_index", 0)301 done, _ = sjson.SetBytes(done, "content_index", 0)
281 done, _ = sjson.SetBytes(done, "text", fullText)302 done, _ = sjson.SetBytes(done, "text", fullText)
282 out = append(out, emitRespEvent("response.output_text.done", done))303 out = append(out, emitRespEvent("response.output_text.done", done))
@@ -284,69 +305,72 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 1 unmodified line
284 partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)305 partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
285 partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())306 partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
286 partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))307 partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
287 partDone, _ = sjson.SetBytes(partDone, "output_index", idx)308 partDone, _ = sjson.SetBytes(partDone, "output_index", msgOutputIndex)
288 partDone, _ = sjson.SetBytes(partDone, "content_index", 0)309 partDone, _ = sjson.SetBytes(partDone, "content_index", 0)
289 partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)310 partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
290 out = append(out, emitRespEvent("response.content_part.done", partDone))311 out = append(out, emitRespEvent("response.content_part.done", partDone))
291 312
292 itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)313 itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)
293 itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())314 itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
294 itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)315 itemDone, _ = sjson.SetBytes(itemDone, "output_index", msgOutputIndex)
295 itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))316 itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
296 itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText)317 itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText)
297 out = append(out, emitRespEvent("response.output_item.done", itemDone))318 out = append(out, emitRespEvent("response.output_item.done", itemDone))
298 st.MsgItemDone[idx] = true319 st.MsgItemDone[idx] = true
299 }320 }
300 321
301 // Only emit item.added once per tool call and preserve call_id across chunks.322 tcs.ForEach(func(_, tc gjson.Result) bool {
302 newCallID := tcs.Get("0.id").String()323 toolIndex := int(tc.Get("index").Int())
303 nameChunk := tcs.Get("0.function.name").String()324 key := toolStateKey(idx, toolIndex)
304 if nameChunk != "" {325 newCallID := tc.Get("id").String()
305 st.FuncNames[idx] = nameChunk326 nameChunk := tc.Get("function.name").String()
306 }327 if nameChunk != "" {
307 existingCallID := st.FuncCallIDs[idx]328 st.FuncNames[key] = nameChunk
308 effectiveCallID := existingCallID
309 shouldEmitItem := false
310 if existingCallID == "" && newCallID != "" {
311 // First time seeing a valid call_id for this index
312 effectiveCallID = newCallID
313 st.FuncCallIDs[idx] = newCallID
314 shouldEmitItem = true
315 }
316
317 if shouldEmitItem && effectiveCallID != "" {
318 o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
319 o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
320 o, _ = sjson.SetBytes(o, "output_index", idx)
321 o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", effectiveCallID))
322 o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID)
323 name := st.FuncNames[idx]
324 o, _ = sjson.SetBytes(o, "item.name", name)
325 out = append(out, emitRespEvent("response.output_item.added", o))
326 }
327
328 // Ensure args buffer exists for this index
329 if st.FuncArgsBuf[idx] == nil {
330 st.FuncArgsBuf[idx] = &strings.Builder{}
331 }
332
333 // Append arguments delta if available and we have a valid call_id to reference
334 if args := tcs.Get("0.function.arguments"); args.Exists() && args.String() != "" {
335 // Prefer an already known call_id; fall back to newCallID if first time
336 refCallID := st.FuncCallIDs[idx]
337 if refCallID == "" {
338 refCallID = newCallID
339 }329 }
340 if refCallID != "" {330
341 ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)331 existingCallID := st.FuncCallIDs[key]
342 ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq())332 effectiveCallID := existingCallID
343 ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", refCallID))333 shouldEmitItem := false
344 ad, _ = sjson.SetBytes(ad, "output_index", idx)334 if existingCallID == "" && newCallID != "" {
345 ad, _ = sjson.SetBytes(ad, "delta", args.String())335 effectiveCallID = newCallID
346 out = append(out, emitRespEvent("response.function_call_arguments.delta", ad))336 st.FuncCallIDs[key] = newCallID
337 st.FuncOutputIx[key] = allocOutputIndex()
338 shouldEmitItem = true
347 }339 }
348 st.FuncArgsBuf[idx].WriteString(args.String())340
349 }341 if shouldEmitItem && effectiveCallID != "" {
342 outputIndex := st.FuncOutputIx[key]
343 o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
344 o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
345 o, _ = sjson.SetBytes(o, "output_index", outputIndex)
346 o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", effectiveCallID))
347 o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID)
348 o, _ = sjson.SetBytes(o, "item.name", st.FuncNames[key])
349 out = append(out, emitRespEvent("response.output_item.added", o))
350 }
351
352 if st.FuncArgsBuf[key] == nil {
353 st.FuncArgsBuf[key] = &strings.Builder{}
354 }
355
356 if args := tc.Get("function.arguments"); args.Exists() && args.String() != "" {
357 refCallID := st.FuncCallIDs[key]
358 if refCallID == "" {
359 refCallID = newCallID
360 }
361 if refCallID != "" {
362 outputIndex := st.FuncOutputIx[key]
363 ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
364 ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq())
365 ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", refCallID))
366 ad, _ = sjson.SetBytes(ad, "output_index", outputIndex)
367 ad, _ = sjson.SetBytes(ad, "delta", args.String())
368 out = append(out, emitRespEvent("response.function_call_arguments.delta", ad))
369 }
370 st.FuncArgsBuf[key].WriteString(args.String())
371 }
372 return true
373 })
350 }374 }
351 }375 }
352 376
@@ -360,15 +384,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 7 unmodified lines
360 for i := range st.MsgItemAdded {384 for i := range st.MsgItemAdded {
361 idxs = append(idxs, i)385 idxs = append(idxs, i)
362 }386 }
363 for i := 0; i < len(idxs); i++ {387 sort.Slice(idxs, func(i, j int) bool { return st.MsgOutputIx[idxs[i]] < st.MsgOutputIx[idxs[j]] })
364 for j := i + 1; j < len(idxs); j++ {
365 if idxs[j] < idxs[i] {
366 idxs[i], idxs[j] = idxs[j], idxs[i]
367 }
368 }
369 }
370 for _, i := range idxs {388 for _, i := range idxs {
371 if st.MsgItemAdded[i] && !st.MsgItemDone[i] {389 if st.MsgItemAdded[i] && !st.MsgItemDone[i] {
390 msgOutputIndex := st.MsgOutputIx[i]
372 fullText := ""391 fullText := ""
373 if b := st.MsgTextBuf[i]; b != nil {392 if b := st.MsgTextBuf[i]; b != nil {
374 fullText = b.String()393 fullText = b.String()
@@ -376,7 +395,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 1 unmodified line
376 done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)395 done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
377 done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())396 done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
378 done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))397 done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
379 done, _ = sjson.SetBytes(done, "output_index", i)398 done, _ = sjson.SetBytes(done, "output_index", msgOutputIndex)
380 done, _ = sjson.SetBytes(done, "content_index", 0)399 done, _ = sjson.SetBytes(done, "content_index", 0)
381 done, _ = sjson.SetBytes(done, "text", fullText)400 done, _ = sjson.SetBytes(done, "text", fullText)
382 out = append(out, emitRespEvent("response.output_text.done", done))401 out = append(out, emitRespEvent("response.output_text.done", done))
@@ -384,14 +403,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 1 unmodified line
384 partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)403 partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
385 partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())404 partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
386 partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))405 partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
387 partDone, _ = sjson.SetBytes(partDone, "output_index", i)406 partDone, _ = sjson.SetBytes(partDone, "output_index", msgOutputIndex)
388 partDone, _ = sjson.SetBytes(partDone, "content_index", 0)407 partDone, _ = sjson.SetBytes(partDone, "content_index", 0)
389 partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)408 partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
390 out = append(out, emitRespEvent("response.content_part.done", partDone))409 out = append(out, emitRespEvent("response.content_part.done", partDone))
391 410
392 itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)411 itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)
393 itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())412 itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
394 itemDone, _ = sjson.SetBytes(itemDone, "output_index", i)413 itemDone, _ = sjson.SetBytes(itemDone, "output_index", msgOutputIndex)
395 itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))414 itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
396 itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText)415 itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText)
397 out = append(out, emitRespEvent("response.output_item.done", itemDone))416 out = append(out, emitRespEvent("response.output_item.done", itemDone))
@@ -407,43 +426,42 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 9 unmodified lines
407 426
408 // Emit function call done events for any active function calls427 // Emit function call done events for any active function calls
409 if len(st.FuncCallIDs) > 0 {428 if len(st.FuncCallIDs) > 0 {
410 idxs := make([]int, 0, len(st.FuncCallIDs))429 keys := make([]string, 0, len(st.FuncCallIDs))
411 for i := range st.FuncCallIDs {430 for key := range st.FuncCallIDs {
412 idxs = append(idxs, i)431 keys = append(keys, key)
413 }432 }
414 for i := 0; i < len(idxs); i++ {433 sort.Slice(keys, func(i, j int) bool {
415 for j := i + 1; j < len(idxs); j++ {434 left := st.FuncOutputIx[keys[i]]
416 if idxs[j] < idxs[i] {435 right := st.FuncOutputIx[keys[j]]
417 idxs[i], idxs[j] = idxs[j], idxs[i]436 return left < right || (left == right && keys[i] < keys[j])
418 }437 })
419 }438 for _, key := range keys {
420 }439 callID := st.FuncCallIDs[key]
421 for _, i := range idxs {440 if callID == "" || st.FuncItemDone[key] {
422 callID := st.FuncCallIDs[i]
423 if callID == "" || st.FuncItemDone[i] {
424 continue441 continue
425 }442 }
443 outputIndex := st.FuncOutputIx[key]
426 args := "{}"444 args := "{}"
427 if b := st.FuncArgsBuf[i]; b != nil && b.Len() > 0 {445 if b := st.FuncArgsBuf[key]; b != nil && b.Len() > 0 {
428 args = b.String()446 args = b.String()
429 }447 }
430 fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)448 fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
431 fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())449 fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())
432 fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", callID))450 fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", callID))
433 fcDone, _ = sjson.SetBytes(fcDone, "output_index", i)451 fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex)
434 fcDone, _ = sjson.SetBytes(fcDone, "arguments", args)452 fcDone, _ = sjson.SetBytes(fcDone, "arguments", args)
435 out = append(out, emitRespEvent("response.function_call_arguments.done", fcDone))453 out = append(out, emitRespEvent("response.function_call_arguments.done", fcDone))
436 454
437 itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)455 itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
438 itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())456 itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
439 itemDone, _ = sjson.SetBytes(itemDone, "output_index", i)457 itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
440 itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID))458 itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID))
441 itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)459 itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)
442 itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)460 itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
443 itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[i])461 itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[key])
444 out = append(out, emitRespEvent("response.output_item.done", itemDone))462 out = append(out, emitRespEvent("response.output_item.done", itemDone))
445 st.FuncItemDone[i] = true463 st.FuncItemDone[key] = true
446 st.FuncArgsDone[i] = true464 st.FuncArgsDone[key] = true
447 }465 }
448 }466 }
449 completed := []byte(`{"type":"response.completed","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null}}`)467 completed := []byte(`{"type":"response.completed","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null}}`)
@@ -516,28 +534,21 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 66 unmodified lines
516 }534 }
517 // Build response.output using aggregated buffers535 // Build response.output using aggregated buffers
518 outputsWrapper := []byte(`{"arr":[]}`)536 outputsWrapper := []byte(`{"arr":[]}`)
537 type completedOutputItem struct {
538 index int
539 raw []byte
540 }
541 outputItems := make([]completedOutputItem, 0, len(st.Reasonings)+len(st.MsgItemAdded)+len(st.FuncArgsBuf))
519 if len(st.Reasonings) > 0 {542 if len(st.Reasonings) > 0 {
520 for _, r := range st.Reasonings {543 for _, r := range st.Reasonings {
521 item := []byte(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`)544 item := []byte(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`)
522 item, _ = sjson.SetBytes(item, "id", r.ReasoningID)545 item, _ = sjson.SetBytes(item, "id", r.ReasoningID)
523 item, _ = sjson.SetBytes(item, "summary.0.text", r.ReasoningData)546 item, _ = sjson.SetBytes(item, "summary.0.text", r.ReasoningData)
524 outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)547 outputItems = append(outputItems, completedOutputItem{index: r.OutputIndex, raw: item})
525 }548 }
526 }549 }
527 // Append message items in ascending index order
528 if len(st.MsgItemAdded) > 0 {550 if len(st.MsgItemAdded) > 0 {
529 midxs := make([]int, 0, len(st.MsgItemAdded))
530 for i := range st.MsgItemAdded {551 for i := range st.MsgItemAdded {
531 midxs = append(midxs, i)
532 }
533 for i := 0; i < len(midxs); i++ {
534 for j := i + 1; j < len(midxs); j++ {
535 if midxs[j] < midxs[i] {
536 midxs[i], midxs[j] = midxs[j], midxs[i]
537 }
538 }
539 }
540 for _, i := range midxs {
541 txt := ""552 txt := ""
542 if b := st.MsgTextBuf[i]; b != nil {553 if b := st.MsgTextBuf[i]; b != nil {
543 txt = b.String()554 txt = b.String()
@@ -545,37 +556,29 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 1 unmodified line
545 item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)556 item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
546 item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))557 item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
547 item, _ = sjson.SetBytes(item, "content.0.text", txt)558 item, _ = sjson.SetBytes(item, "content.0.text", txt)
548 outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)559 outputItems = append(outputItems, completedOutputItem{index: st.MsgOutputIx[i], raw: item})
549 }560 }
550 }561 }
551 if len(st.FuncArgsBuf) > 0 {562 if len(st.FuncArgsBuf) > 0 {
552 idxs := make([]int, 0, len(st.FuncArgsBuf))563 for key := range st.FuncArgsBuf {
553 for i := range st.FuncArgsBuf {
554 idxs = append(idxs, i)
555 }
556 // small-N sort without extra imports
557 for i := 0; i < len(idxs); i++ {
558 for j := i + 1; j < len(idxs); j++ {
559 if idxs[j] < idxs[i] {
560 idxs[i], idxs[j] = idxs[j], idxs[i]
561 }
562 }
563 }
564 for _, i := range idxs {
565 args := ""564 args := ""
566 if b := st.FuncArgsBuf[i]; b != nil {565 if b := st.FuncArgsBuf[key]; b != nil {
567 args = b.String()566 args = b.String()
568 }567 }
569 callID := st.FuncCallIDs[i]568 callID := st.FuncCallIDs[key]
570 name := st.FuncNames[i]569 name := st.FuncNames[key]
571 item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)570 item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
572 item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))571 item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
573 item, _ = sjson.SetBytes(item, "arguments", args)572 item, _ = sjson.SetBytes(item, "arguments", args)
574 item, _ = sjson.SetBytes(item, "call_id", callID)573 item, _ = sjson.SetBytes(item, "call_id", callID)
575 item, _ = sjson.SetBytes(item, "name", name)574 item, _ = sjson.SetBytes(item, "name", name)
576 outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)575 outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item})
577 }576 }
578 }577 }
578 sort.Slice(outputItems, func(i, j int) bool { return outputItems[i].index < outputItems[j].index })
579 for _, item := range outputItems {
580 outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item.raw)
581 }
579 if gjson.GetBytes(outputsWrapper, "arr.#").Int() > 0 {582 if gjson.GetBytes(outputsWrapper, "arr.#").Int() > 0 {
580 completed, _ = sjson.SetRawBytes(completed, "response.output", []byte(gjson.GetBytes(outputsWrapper, "arr").Raw))583 completed, _ = sjson.SetRawBytes(completed, "response.output", []byte(gjson.GetBytes(outputsWrapper, "arr").Raw))
581 }584 }
internal/translator/openai/openai/responses/openai_openai-responses_response_test.go+305−0
@@ -0,0 +1,305 @@
1package responses
2
3import (
4 "context"
5 "strings"
6 "testing"
7
8 "github.com/tidwall/gjson"
9)
10
11func parseOpenAIResponsesSSEEvent(t *testing.T, chunk []byte) (string, gjson.Result) {
12 t.Helper()
13
14 lines := strings.Split(string(chunk), "\n")
15 if len(lines) < 2 {
16 t.Fatalf("unexpected SSE chunk: %q", chunk)
17 }
18
19 event := strings.TrimSpace(strings.TrimPrefix(lines[0], "event:"))
20 dataLine := strings.TrimSpace(strings.TrimPrefix(lines[1], "data:"))
21 if !gjson.Valid(dataLine) {
22 t.Fatalf("invalid SSE data JSON: %q", dataLine)
23 }
24 return event, gjson.Parse(dataLine)
25}
26
27func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_MultipleToolCallsRemainSeparate(t *testing.T) {
28 in := []string{
29 `data: {"id":"resp_test","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_read","type":"function","function":{"name":"read","arguments":""}}]},"finish_reason":null}]}`,
30 `data: {"id":"resp_test","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"filePath\":\"C:\\\\repo\",\"limit\":400,\"offset\":1}"}}]},"finish_reason":null}]}`,
31 `data: {"id":"resp_test","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":1,"id":"call_glob","type":"function","function":{"name":"glob","arguments":""}}]},"finish_reason":null}]}`,
32 `data: {"id":"resp_test","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":1,"function":{"arguments":"{\"path\":\"C:\\\\repo\",\"pattern\":\"*.{yml,yaml}\"}"}}]},"finish_reason":null}]}`,
33 `data: {"id":"resp_test","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":"tool_calls"}],"usage":{"completion_tokens":10,"total_tokens":20,"prompt_tokens":10}}`,
34 }
35
36 request := []byte(`{"model":"gpt-5.4","tool_choice":"auto","parallel_tool_calls":true}`)
37
38 var param any
39 var out [][]byte
40 for _, line := range in {
41 out = append(out, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), &param)...)
42 }
43
44 addedNames := map[string]string{}
45 doneArgs := map[string]string{}
46 doneNames := map[string]string{}
47 outputItems := map[string]gjson.Result{}
48
49 for _, chunk := range out {
50 ev, data := parseOpenAIResponsesSSEEvent(t, chunk)
51 switch ev {
52 case "response.output_item.added":
53 if data.Get("item.type").String() != "function_call" {
54 continue
55 }
56 addedNames[data.Get("item.call_id").String()] = data.Get("item.name").String()
57 case "response.output_item.done":
58 if data.Get("item.type").String() != "function_call" {
59 continue
60 }
61 callID := data.Get("item.call_id").String()
62 doneArgs[callID] = data.Get("item.arguments").String()
63 doneNames[callID] = data.Get("item.name").String()
64 case "response.completed":
65 output := data.Get("response.output")
66 for _, item := range output.Array() {
67 if item.Get("type").String() == "function_call" {
68 outputItems[item.Get("call_id").String()] = item
69 }
70 }
71 }
72 }
73
74 if len(addedNames) != 2 {
75 t.Fatalf("expected 2 function_call added events, got %d", len(addedNames))
76 }
77 if len(doneArgs) != 2 {
78 t.Fatalf("expected 2 function_call done events, got %d", len(doneArgs))
79 }
80
81 if addedNames["call_read"] != "read" {
82 t.Fatalf("unexpected added name for call_read: %q", addedNames["call_read"])
83 }
84 if addedNames["call_glob"] != "glob" {
85 t.Fatalf("unexpected added name for call_glob: %q", addedNames["call_glob"])
86 }
87
88 if !gjson.Valid(doneArgs["call_read"]) {
89 t.Fatalf("invalid JSON args for call_read: %q", doneArgs["call_read"])
90 }
91 if !gjson.Valid(doneArgs["call_glob"]) {
92 t.Fatalf("invalid JSON args for call_glob: %q", doneArgs["call_glob"])
93 }
94 if strings.Contains(doneArgs["call_read"], "}{") {
95 t.Fatalf("call_read args were concatenated: %q", doneArgs["call_read"])
96 }
97 if strings.Contains(doneArgs["call_glob"], "}{") {
98 t.Fatalf("call_glob args were concatenated: %q", doneArgs["call_glob"])
99 }
100
101 if doneNames["call_read"] != "read" {
102 t.Fatalf("unexpected done name for call_read: %q", doneNames["call_read"])
103 }
104 if doneNames["call_glob"] != "glob" {
105 t.Fatalf("unexpected done name for call_glob: %q", doneNames["call_glob"])
106 }
107
108 if got := gjson.Get(doneArgs["call_read"], "filePath").String(); got != `C:\repo` {
109 t.Fatalf("unexpected filePath for call_read: %q", got)
110 }
111 if got := gjson.Get(doneArgs["call_glob"], "path").String(); got != `C:\repo` {
112 t.Fatalf("unexpected path for call_glob: %q", got)
113 }
114 if got := gjson.Get(doneArgs["call_glob"], "pattern").String(); got != "*.{yml,yaml}" {
115 t.Fatalf("unexpected pattern for call_glob: %q", got)
116 }
117
118 if len(outputItems) != 2 {
119 t.Fatalf("expected 2 function_call items in response.output, got %d", len(outputItems))
120 }
121 if outputItems["call_read"].Get("name").String() != "read" {
122 t.Fatalf("unexpected response.output name for call_read: %q", outputItems["call_read"].Get("name").String())
123 }
124 if outputItems["call_glob"].Get("name").String() != "glob" {
125 t.Fatalf("unexpected response.output name for call_glob: %q", outputItems["call_glob"].Get("name").String())
126 }
127}
128
129func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_MultiChoiceToolCallsUseDistinctOutputIndexes(t *testing.T) {
130 in := []string{
131 `data: {"id":"resp_multi_choice","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_choice0","type":"function","function":{"name":"glob","arguments":""}}]},"finish_reason":null},{"index":1,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_choice1","type":"function","function":{"name":"read","arguments":""}}]},"finish_reason":null}]}`,
132 `data: {"id":"resp_multi_choice","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":\"C:\\\\repo\",\"pattern\":\"*.go\"}"}}]},"finish_reason":null},{"index":1,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"filePath\":\"C:\\\\repo\\\\README.md\",\"limit\":20,\"offset\":1}"}}]},"finish_reason":null}]}`,
133 `data: {"id":"resp_multi_choice","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":"tool_calls"},{"index":1,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":"tool_calls"}],"usage":{"completion_tokens":10,"total_tokens":20,"prompt_tokens":10}}`,
134 }
135
136 request := []byte(`{"model":"gpt-5.4","tool_choice":"auto","parallel_tool_calls":true}`)
137
138 var param any
139 var out [][]byte
140 for _, line := range in {
141 out = append(out, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), &param)...)
142 }
143
144 type fcEvent struct {
145 outputIndex int64
146 name string
147 arguments string
148 }
149
150 added := map[string]fcEvent{}
151 done := map[string]fcEvent{}
152
153 for _, chunk := range out {
154 ev, data := parseOpenAIResponsesSSEEvent(t, chunk)
155 switch ev {
156 case "response.output_item.added":
157 if data.Get("item.type").String() != "function_call" {
158 continue
159 }
160 callID := data.Get("item.call_id").String()
161 added[callID] = fcEvent{
162 outputIndex: data.Get("output_index").Int(),
163 name: data.Get("item.name").String(),
164 }
165 case "response.output_item.done":
166 if data.Get("item.type").String() != "function_call" {
167 continue
168 }
169 callID := data.Get("item.call_id").String()
170 done[callID] = fcEvent{
171 outputIndex: data.Get("output_index").Int(),
172 name: data.Get("item.name").String(),
173 arguments: data.Get("item.arguments").String(),
174 }
175 }
176 }
177
178 if len(added) != 2 {
179 t.Fatalf("expected 2 function_call added events, got %d", len(added))
180 }
181 if len(done) != 2 {
182 t.Fatalf("expected 2 function_call done events, got %d", len(done))
183 }
184
185 if added["call_choice0"].name != "glob" {
186 t.Fatalf("unexpected added name for call_choice0: %q", added["call_choice0"].name)
187 }
188 if added["call_choice1"].name != "read" {
189 t.Fatalf("unexpected added name for call_choice1: %q", added["call_choice1"].name)
190 }
191 if added["call_choice0"].outputIndex == added["call_choice1"].outputIndex {
192 t.Fatalf("expected distinct output indexes for different choices, both got %d", added["call_choice0"].outputIndex)
193 }
194
195 if !gjson.Valid(done["call_choice0"].arguments) {
196 t.Fatalf("invalid JSON args for call_choice0: %q", done["call_choice0"].arguments)
197 }
198 if !gjson.Valid(done["call_choice1"].arguments) {
199 t.Fatalf("invalid JSON args for call_choice1: %q", done["call_choice1"].arguments)
200 }
201 if done["call_choice0"].outputIndex == done["call_choice1"].outputIndex {
202 t.Fatalf("expected distinct done output indexes for different choices, both got %d", done["call_choice0"].outputIndex)
203 }
204 if done["call_choice0"].name != "glob" {
205 t.Fatalf("unexpected done name for call_choice0: %q", done["call_choice0"].name)
206 }
207 if done["call_choice1"].name != "read" {
208 t.Fatalf("unexpected done name for call_choice1: %q", done["call_choice1"].name)
209 }
210}
211
212func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_MixedMessageAndToolUseDistinctOutputIndexes(t *testing.T) {
213 in := []string{
214 `data: {"id":"resp_mixed","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":"hello","reasoning_content":null,"tool_calls":null},"finish_reason":null},{"index":1,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_choice1","type":"function","function":{"name":"read","arguments":""}}]},"finish_reason":null}]}`,
215 `data: {"id":"resp_mixed","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":"stop"},{"index":1,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"filePath\":\"C:\\\\repo\\\\README.md\",\"limit\":20,\"offset\":1}"}}]},"finish_reason":"tool_calls"}],"usage":{"completion_tokens":10,"total_tokens":20,"prompt_tokens":10}}`,
216 }
217
218 request := []byte(`{"model":"gpt-5.4","tool_choice":"auto","parallel_tool_calls":true}`)
219
220 var param any
221 var out [][]byte
222 for _, line := range in {
223 out = append(out, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), &param)...)
224 }
225
226 var messageOutputIndex int64 = -1
227 var toolOutputIndex int64 = -1
228
229 for _, chunk := range out {
230 ev, data := parseOpenAIResponsesSSEEvent(t, chunk)
231 if ev != "response.output_item.added" {
232 continue
233 }
234 switch data.Get("item.type").String() {
235 case "message":
236 if data.Get("item.id").String() == "msg_resp_mixed_0" {
237 messageOutputIndex = data.Get("output_index").Int()
238 }
239 case "function_call":
240 if data.Get("item.call_id").String() == "call_choice1" {
241 toolOutputIndex = data.Get("output_index").Int()
242 }
243 }
244 }
245
246 if messageOutputIndex < 0 {
247 t.Fatal("did not find message output index")
248 }
249 if toolOutputIndex < 0 {
250 t.Fatal("did not find tool output index")
251 }
252 if messageOutputIndex == toolOutputIndex {
253 t.Fatalf("expected distinct output indexes for message and tool call, both got %d", messageOutputIndex)
254 }
255}
256
257func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_FunctionCallDoneAndCompletedOutputStayAscending(t *testing.T) {
258 in := []string{
259 `data: {"id":"resp_order","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_glob","type":"function","function":{"name":"glob","arguments":""}}]},"finish_reason":null}]}`,
260 `data: {"id":"resp_order","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":\"C:\\\\repo\",\"pattern\":\"*.go\"}"}}]},"finish_reason":null}]}`,
261 `data: {"id":"resp_order","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":1,"id":"call_read","type":"function","function":{"name":"read","arguments":""}}]},"finish_reason":null}]}`,
262 `data: {"id":"resp_order","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":1,"function":{"arguments":"{\"filePath\":\"C:\\\\repo\\\\README.md\",\"limit\":20,\"offset\":1}"}}]},"finish_reason":null}]}`,
263 `data: {"id":"resp_order","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":"tool_calls"}],"usage":{"completion_tokens":10,"total_tokens":20,"prompt_tokens":10}}`,
264 }
265
266 request := []byte(`{"model":"gpt-5.4","tool_choice":"auto","parallel_tool_calls":true}`)
267
268 var param any
269 var out [][]byte
270 for _, line := range in {
271 out = append(out, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), &param)...)
272 }
273
274 var doneIndexes []int64
275 var completedOrder []string
276
277 for _, chunk := range out {
278 ev, data := parseOpenAIResponsesSSEEvent(t, chunk)
279 switch ev {
280 case "response.output_item.done":
281 if data.Get("item.type").String() == "function_call" {
282 doneIndexes = append(doneIndexes, data.Get("output_index").Int())
283 }
284 case "response.completed":
285 for _, item := range data.Get("response.output").Array() {
286 if item.Get("type").String() == "function_call" {
287 completedOrder = append(completedOrder, item.Get("call_id").String())
288 }
289 }
290 }
291 }
292
293 if len(doneIndexes) != 2 {
294 t.Fatalf("expected 2 function_call done indexes, got %d", len(doneIndexes))
295 }
296 if doneIndexes[0] >= doneIndexes[1] {
297 t.Fatalf("expected ascending done output indexes, got %v", doneIndexes)
298 }
299 if len(completedOrder) != 2 {
300 t.Fatalf("expected 2 function_call items in completed output, got %d", len(completedOrder))
301 }
302 if completedOrder[0] != "call_glob" || completedOrder[1] != "call_read" {
303 t.Fatalf("unexpected completed function_call order: %v", completedOrder)
304 }
305}