From 7e1a543b79b21657536609b7eb14734df3197354 Mon Sep 17 00:00:00 2001 From: trph <894304504@qq.com> Date: Thu, 19 Mar 2026 15:16:05 +0800 Subject: [PATCH] fix: preserve separate streamed tool calls in Responses API --- .../openai_openai-responses_response.go | 265 +++++++++--------- .../openai_openai-responses_response_test.go | 127 +++++++++ 2 files changed, 261 insertions(+), 131 deletions(-) create mode 100644 internal/translator/openai/openai/responses/openai_openai-responses_response_test.go diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index c2ac608a..a34a6ff4 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "sort" "strings" "sync/atomic" "time" @@ -16,6 +17,7 @@ import ( type oaiToResponsesStateReasoning struct { ReasoningID string ReasoningData string + OutputIndex int } type oaiToResponsesState struct { Seq int @@ -29,16 +31,19 @@ type oaiToResponsesState struct { MsgTextBuf map[int]*strings.Builder ReasoningBuf strings.Builder Reasonings []oaiToResponsesStateReasoning - FuncArgsBuf map[int]*strings.Builder // index -> args - FuncNames map[int]string // index -> name - FuncCallIDs map[int]string // index -> call_id + FuncArgsBuf map[string]*strings.Builder + FuncNames map[string]string + FuncCallIDs map[string]string + FuncOutputIx map[string]int + MsgOutputIx map[int]int + NextOutputIx int // message item state per output index MsgItemAdded map[int]bool // whether response.output_item.added emitted for message MsgContentAdded map[int]bool // whether response.content_part.added emitted for message MsgItemDone map[int]bool // whether message done events were emitted // function item done state - FuncArgsDone map[int]bool - FuncItemDone map[int]bool + FuncArgsDone map[string]bool + FuncItemDone map[string]bool // usage aggregation PromptTokens int64 CachedTokens int64 @@ -60,15 +65,17 @@ func emitRespEvent(event string, payload []byte) []byte { func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { if *param == nil { *param = &oaiToResponsesState{ - FuncArgsBuf: make(map[int]*strings.Builder), - FuncNames: make(map[int]string), - FuncCallIDs: make(map[int]string), + FuncArgsBuf: make(map[string]*strings.Builder), + FuncNames: make(map[string]string), + FuncCallIDs: make(map[string]string), + FuncOutputIx: make(map[string]int), + MsgOutputIx: make(map[int]int), MsgTextBuf: make(map[int]*strings.Builder), MsgItemAdded: make(map[int]bool), MsgContentAdded: make(map[int]bool), MsgItemDone: make(map[int]bool), - FuncArgsDone: make(map[int]bool), - FuncItemDone: make(map[int]bool), + FuncArgsDone: make(map[string]bool), + FuncItemDone: make(map[string]bool), Reasonings: make([]oaiToResponsesStateReasoning, 0), } } @@ -125,6 +132,12 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } nextSeq := func() int { st.Seq++; return st.Seq } + allocOutputIndex := func() int { + ix := st.NextOutputIx + st.NextOutputIx++ + return ix + } + toolStateKey := func(outputIndex, toolIndex int) string { return fmt.Sprintf("%d:%d", outputIndex, toolIndex) } var out [][]byte if !st.Started { @@ -135,14 +148,17 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.ReasoningBuf.Reset() st.ReasoningID = "" st.ReasoningIndex = 0 - st.FuncArgsBuf = make(map[int]*strings.Builder) - st.FuncNames = make(map[int]string) - st.FuncCallIDs = make(map[int]string) + st.FuncArgsBuf = make(map[string]*strings.Builder) + st.FuncNames = make(map[string]string) + st.FuncCallIDs = make(map[string]string) + st.FuncOutputIx = make(map[string]int) + st.MsgOutputIx = make(map[int]int) + st.NextOutputIx = 0 st.MsgItemAdded = make(map[int]bool) st.MsgContentAdded = make(map[int]bool) st.MsgItemDone = make(map[int]bool) - st.FuncArgsDone = make(map[int]bool) - st.FuncItemDone = make(map[int]bool) + st.FuncArgsDone = make(map[string]bool) + st.FuncItemDone = make(map[string]bool) st.PromptTokens = 0 st.CachedTokens = 0 st.CompletionTokens = 0 @@ -185,7 +201,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.summary.text", text) out = append(out, emitRespEvent("response.output_item.done", outputItemDone)) - st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text}) + st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text, OutputIndex: st.ReasoningIndex}) st.ReasoningID = "" } @@ -201,10 +217,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, stopReasoning(st.ReasoningBuf.String()) st.ReasoningBuf.Reset() } + if _, exists := st.MsgOutputIx[idx]; !exists { + st.MsgOutputIx[idx] = allocOutputIndex() + } + msgOutputIndex := st.MsgOutputIx[idx] if !st.MsgItemAdded[idx] { item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`) item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) - item, _ = sjson.SetBytes(item, "output_index", idx) + item, _ = sjson.SetBytes(item, "output_index", msgOutputIndex) item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) out = append(out, emitRespEvent("response.output_item.added", item)) st.MsgItemAdded[idx] = true @@ -213,7 +233,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, 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":""}}`) part, _ = sjson.SetBytes(part, "sequence_number", nextSeq()) part, _ = sjson.SetBytes(part, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) - part, _ = sjson.SetBytes(part, "output_index", idx) + part, _ = sjson.SetBytes(part, "output_index", msgOutputIndex) part, _ = sjson.SetBytes(part, "content_index", 0) out = append(out, emitRespEvent("response.content_part.added", part)) st.MsgContentAdded[idx] = true @@ -222,7 +242,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`) msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) - msg, _ = sjson.SetBytes(msg, "output_index", idx) + msg, _ = sjson.SetBytes(msg, "output_index", msgOutputIndex) msg, _ = sjson.SetBytes(msg, "content_index", 0) msg, _ = sjson.SetBytes(msg, "delta", c.String()) out = append(out, emitRespEvent("response.output_text.delta", msg)) @@ -238,10 +258,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, // On first appearance, add reasoning item and part if st.ReasoningID == "" { st.ReasoningID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx) - st.ReasoningIndex = idx + st.ReasoningIndex = allocOutputIndex() item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`) item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) - item, _ = sjson.SetBytes(item, "output_index", idx) + item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex) item, _ = sjson.SetBytes(item, "item.id", st.ReasoningID) out = append(out, emitRespEvent("response.output_item.added", item)) 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, // Before emitting any function events, if a message is open for this index, // close its text/content to match Codex expected ordering. if st.MsgItemAdded[idx] && !st.MsgItemDone[idx] { + msgOutputIndex := st.MsgOutputIx[idx] fullText := "" if b := st.MsgTextBuf[idx]; b != nil { fullText = b.String() @@ -276,7 +297,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`) done, _ = sjson.SetBytes(done, "sequence_number", nextSeq()) done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) - done, _ = sjson.SetBytes(done, "output_index", idx) + done, _ = sjson.SetBytes(done, "output_index", msgOutputIndex) done, _ = sjson.SetBytes(done, "content_index", 0) done, _ = sjson.SetBytes(done, "text", fullText) out = append(out, emitRespEvent("response.output_text.done", done)) @@ -284,69 +305,72 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, 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":""}}`) partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq()) partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) - partDone, _ = sjson.SetBytes(partDone, "output_index", idx) + partDone, _ = sjson.SetBytes(partDone, "output_index", msgOutputIndex) partDone, _ = sjson.SetBytes(partDone, "content_index", 0) partDone, _ = sjson.SetBytes(partDone, "part.text", fullText) out = append(out, emitRespEvent("response.content_part.done", partDone)) 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"}}`) itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) - itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", msgOutputIndex) itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText) out = append(out, emitRespEvent("response.output_item.done", itemDone)) st.MsgItemDone[idx] = true } - // Only emit item.added once per tool call and preserve call_id across chunks. - newCallID := tcs.Get("0.id").String() - nameChunk := tcs.Get("0.function.name").String() - if nameChunk != "" { - st.FuncNames[idx] = nameChunk - } - existingCallID := st.FuncCallIDs[idx] - effectiveCallID := existingCallID - shouldEmitItem := false - if existingCallID == "" && newCallID != "" { - // First time seeing a valid call_id for this index - effectiveCallID = newCallID - st.FuncCallIDs[idx] = newCallID - shouldEmitItem = true - } - - if shouldEmitItem && effectiveCallID != "" { - 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":""}}`) - o, _ = sjson.SetBytes(o, "sequence_number", nextSeq()) - o, _ = sjson.SetBytes(o, "output_index", idx) - o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", effectiveCallID)) - o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID) - name := st.FuncNames[idx] - o, _ = sjson.SetBytes(o, "item.name", name) - out = append(out, emitRespEvent("response.output_item.added", o)) - } - - // Ensure args buffer exists for this index - if st.FuncArgsBuf[idx] == nil { - st.FuncArgsBuf[idx] = &strings.Builder{} - } - - // Append arguments delta if available and we have a valid call_id to reference - if args := tcs.Get("0.function.arguments"); args.Exists() && args.String() != "" { - // Prefer an already known call_id; fall back to newCallID if first time - refCallID := st.FuncCallIDs[idx] - if refCallID == "" { - refCallID = newCallID + tcs.ForEach(func(_, tc gjson.Result) bool { + toolIndex := int(tc.Get("index").Int()) + key := toolStateKey(idx, toolIndex) + newCallID := tc.Get("id").String() + nameChunk := tc.Get("function.name").String() + if nameChunk != "" { + st.FuncNames[key] = nameChunk } - if refCallID != "" { - ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`) - ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq()) - ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", refCallID)) - ad, _ = sjson.SetBytes(ad, "output_index", idx) - ad, _ = sjson.SetBytes(ad, "delta", args.String()) - out = append(out, emitRespEvent("response.function_call_arguments.delta", ad)) + + existingCallID := st.FuncCallIDs[key] + effectiveCallID := existingCallID + shouldEmitItem := false + if existingCallID == "" && newCallID != "" { + effectiveCallID = newCallID + st.FuncCallIDs[key] = newCallID + st.FuncOutputIx[key] = allocOutputIndex() + shouldEmitItem = true } - st.FuncArgsBuf[idx].WriteString(args.String()) - } + + if shouldEmitItem && effectiveCallID != "" { + outputIndex := st.FuncOutputIx[key] + 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":""}}`) + o, _ = sjson.SetBytes(o, "sequence_number", nextSeq()) + o, _ = sjson.SetBytes(o, "output_index", outputIndex) + o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", effectiveCallID)) + o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID) + o, _ = sjson.SetBytes(o, "item.name", st.FuncNames[key]) + out = append(out, emitRespEvent("response.output_item.added", o)) + } + + if st.FuncArgsBuf[key] == nil { + st.FuncArgsBuf[key] = &strings.Builder{} + } + + if args := tc.Get("function.arguments"); args.Exists() && args.String() != "" { + refCallID := st.FuncCallIDs[key] + if refCallID == "" { + refCallID = newCallID + } + if refCallID != "" { + outputIndex := st.FuncOutputIx[key] + ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`) + ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq()) + ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", refCallID)) + ad, _ = sjson.SetBytes(ad, "output_index", outputIndex) + ad, _ = sjson.SetBytes(ad, "delta", args.String()) + out = append(out, emitRespEvent("response.function_call_arguments.delta", ad)) + } + st.FuncArgsBuf[key].WriteString(args.String()) + } + return true + }) } } @@ -360,15 +384,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, for i := range st.MsgItemAdded { idxs = append(idxs, i) } - for i := 0; i < len(idxs); i++ { - for j := i + 1; j < len(idxs); j++ { - if idxs[j] < idxs[i] { - idxs[i], idxs[j] = idxs[j], idxs[i] - } - } - } + sort.Slice(idxs, func(i, j int) bool { return st.MsgOutputIx[idxs[i]] < st.MsgOutputIx[idxs[j]] }) for _, i := range idxs { if st.MsgItemAdded[i] && !st.MsgItemDone[i] { + msgOutputIndex := st.MsgOutputIx[i] fullText := "" if b := st.MsgTextBuf[i]; b != nil { fullText = b.String() @@ -376,7 +395,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`) done, _ = sjson.SetBytes(done, "sequence_number", nextSeq()) done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i)) - done, _ = sjson.SetBytes(done, "output_index", i) + done, _ = sjson.SetBytes(done, "output_index", msgOutputIndex) done, _ = sjson.SetBytes(done, "content_index", 0) done, _ = sjson.SetBytes(done, "text", fullText) out = append(out, emitRespEvent("response.output_text.done", done)) @@ -384,14 +403,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, 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":""}}`) partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq()) partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i)) - partDone, _ = sjson.SetBytes(partDone, "output_index", i) + partDone, _ = sjson.SetBytes(partDone, "output_index", msgOutputIndex) partDone, _ = sjson.SetBytes(partDone, "content_index", 0) partDone, _ = sjson.SetBytes(partDone, "part.text", fullText) out = append(out, emitRespEvent("response.content_part.done", partDone)) 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"}}`) itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) - itemDone, _ = sjson.SetBytes(itemDone, "output_index", i) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", msgOutputIndex) itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i)) itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText) out = append(out, emitRespEvent("response.output_item.done", itemDone)) @@ -407,43 +426,42 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, // Emit function call done events for any active function calls if len(st.FuncCallIDs) > 0 { - idxs := make([]int, 0, len(st.FuncCallIDs)) - for i := range st.FuncCallIDs { - idxs = append(idxs, i) + keys := make([]string, 0, len(st.FuncCallIDs)) + for key := range st.FuncCallIDs { + keys = append(keys, key) } - for i := 0; i < len(idxs); i++ { - for j := i + 1; j < len(idxs); j++ { - if idxs[j] < idxs[i] { - idxs[i], idxs[j] = idxs[j], idxs[i] - } - } - } - for _, i := range idxs { - callID := st.FuncCallIDs[i] - if callID == "" || st.FuncItemDone[i] { + sort.Slice(keys, func(i, j int) bool { + left := st.FuncOutputIx[keys[i]] + right := st.FuncOutputIx[keys[j]] + return left < right || (left == right && keys[i] < keys[j]) + }) + for _, key := range keys { + callID := st.FuncCallIDs[key] + if callID == "" || st.FuncItemDone[key] { continue } + outputIndex := st.FuncOutputIx[key] args := "{}" - if b := st.FuncArgsBuf[i]; b != nil && b.Len() > 0 { + if b := st.FuncArgsBuf[key]; b != nil && b.Len() > 0 { args = b.String() } fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`) fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq()) fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", callID)) - fcDone, _ = sjson.SetBytes(fcDone, "output_index", i) + fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex) fcDone, _ = sjson.SetBytes(fcDone, "arguments", args) out = append(out, emitRespEvent("response.function_call_arguments.done", fcDone)) itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`) itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) - itemDone, _ = sjson.SetBytes(itemDone, "output_index", i) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex) itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID)) itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args) itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID) - itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[i]) + itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[key]) out = append(out, emitRespEvent("response.output_item.done", itemDone)) - st.FuncItemDone[i] = true - st.FuncArgsDone[i] = true + st.FuncItemDone[key] = true + st.FuncArgsDone[key] = true } } 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, } // Build response.output using aggregated buffers outputsWrapper := []byte(`{"arr":[]}`) + type completedOutputItem struct { + index int + raw []byte + } + outputItems := make([]completedOutputItem, 0, len(st.Reasonings)+len(st.MsgItemAdded)+len(st.FuncArgsBuf)) if len(st.Reasonings) > 0 { for _, r := range st.Reasonings { item := []byte(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`) item, _ = sjson.SetBytes(item, "id", r.ReasoningID) item, _ = sjson.SetBytes(item, "summary.0.text", r.ReasoningData) - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) + outputItems = append(outputItems, completedOutputItem{index: r.OutputIndex, raw: item}) } } - // Append message items in ascending index order if len(st.MsgItemAdded) > 0 { - midxs := make([]int, 0, len(st.MsgItemAdded)) for i := range st.MsgItemAdded { - midxs = append(midxs, i) - } - for i := 0; i < len(midxs); i++ { - for j := i + 1; j < len(midxs); j++ { - if midxs[j] < midxs[i] { - midxs[i], midxs[j] = midxs[j], midxs[i] - } - } - } - for _, i := range midxs { txt := "" if b := st.MsgTextBuf[i]; b != nil { txt = b.String() @@ -545,37 +556,29 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i)) item, _ = sjson.SetBytes(item, "content.0.text", txt) - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) + outputItems = append(outputItems, completedOutputItem{index: st.MsgOutputIx[i], raw: item}) } } if len(st.FuncArgsBuf) > 0 { - idxs := make([]int, 0, len(st.FuncArgsBuf)) - for i := range st.FuncArgsBuf { - idxs = append(idxs, i) - } - // small-N sort without extra imports - for i := 0; i < len(idxs); i++ { - for j := i + 1; j < len(idxs); j++ { - if idxs[j] < idxs[i] { - idxs[i], idxs[j] = idxs[j], idxs[i] - } - } - } - for _, i := range idxs { + for key := range st.FuncArgsBuf { args := "" - if b := st.FuncArgsBuf[i]; b != nil { + if b := st.FuncArgsBuf[key]; b != nil { args = b.String() } - callID := st.FuncCallIDs[i] - name := st.FuncNames[i] + callID := st.FuncCallIDs[key] + name := st.FuncNames[key] item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID)) item, _ = sjson.SetBytes(item, "arguments", args) item, _ = sjson.SetBytes(item, "call_id", callID) item, _ = sjson.SetBytes(item, "name", name) - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) + outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item}) } } + sort.Slice(outputItems, func(i, j int) bool { return outputItems[i].index < outputItems[j].index }) + for _, item := range outputItems { + outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item.raw) + } if gjson.GetBytes(outputsWrapper, "arr.#").Int() > 0 { completed, _ = sjson.SetRawBytes(completed, "response.output", []byte(gjson.GetBytes(outputsWrapper, "arr").Raw)) } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go new file mode 100644 index 00000000..164acbca --- /dev/null +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go @@ -0,0 +1,127 @@ +package responses + +import ( + "context" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func parseOpenAIResponsesSSEEvent(t *testing.T, chunk []byte) (string, gjson.Result) { + t.Helper() + + lines := strings.Split(string(chunk), "\n") + if len(lines) < 2 { + t.Fatalf("unexpected SSE chunk: %q", chunk) + } + + event := strings.TrimSpace(strings.TrimPrefix(lines[0], "event:")) + dataLine := strings.TrimSpace(strings.TrimPrefix(lines[1], "data:")) + if !gjson.Valid(dataLine) { + t.Fatalf("invalid SSE data JSON: %q", dataLine) + } + return event, gjson.Parse(dataLine) +} + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_MultipleToolCallsRemainSeparate(t *testing.T) { + in := []string{ + `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}]}`, + `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}]}`, + `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}]}`, + `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}]}`, + `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}}`, + } + + request := []byte(`{"model":"gpt-5.4","tool_choice":"auto","parallel_tool_calls":true}`) + + var param any + var out [][]byte + for _, line := range in { + out = append(out, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), ¶m)...) + } + + addedNames := map[string]string{} + doneArgs := map[string]string{} + doneNames := map[string]string{} + outputItems := map[string]gjson.Result{} + + for _, chunk := range out { + ev, data := parseOpenAIResponsesSSEEvent(t, chunk) + switch ev { + case "response.output_item.added": + if data.Get("item.type").String() != "function_call" { + continue + } + addedNames[data.Get("item.call_id").String()] = data.Get("item.name").String() + case "response.output_item.done": + if data.Get("item.type").String() != "function_call" { + continue + } + callID := data.Get("item.call_id").String() + doneArgs[callID] = data.Get("item.arguments").String() + doneNames[callID] = data.Get("item.name").String() + case "response.completed": + output := data.Get("response.output") + for _, item := range output.Array() { + if item.Get("type").String() == "function_call" { + outputItems[item.Get("call_id").String()] = item + } + } + } + } + + if len(addedNames) != 2 { + t.Fatalf("expected 2 function_call added events, got %d", len(addedNames)) + } + if len(doneArgs) != 2 { + t.Fatalf("expected 2 function_call done events, got %d", len(doneArgs)) + } + + if addedNames["call_read"] != "read" { + t.Fatalf("unexpected added name for call_read: %q", addedNames["call_read"]) + } + if addedNames["call_glob"] != "glob" { + t.Fatalf("unexpected added name for call_glob: %q", addedNames["call_glob"]) + } + + if !gjson.Valid(doneArgs["call_read"]) { + t.Fatalf("invalid JSON args for call_read: %q", doneArgs["call_read"]) + } + if !gjson.Valid(doneArgs["call_glob"]) { + t.Fatalf("invalid JSON args for call_glob: %q", doneArgs["call_glob"]) + } + if strings.Contains(doneArgs["call_read"], "}{") { + t.Fatalf("call_read args were concatenated: %q", doneArgs["call_read"]) + } + if strings.Contains(doneArgs["call_glob"], "}{") { + t.Fatalf("call_glob args were concatenated: %q", doneArgs["call_glob"]) + } + + if doneNames["call_read"] != "read" { + t.Fatalf("unexpected done name for call_read: %q", doneNames["call_read"]) + } + if doneNames["call_glob"] != "glob" { + t.Fatalf("unexpected done name for call_glob: %q", doneNames["call_glob"]) + } + + if got := gjson.Get(doneArgs["call_read"], "filePath").String(); got != `C:\repo` { + t.Fatalf("unexpected filePath for call_read: %q", got) + } + if got := gjson.Get(doneArgs["call_glob"], "path").String(); got != `C:\repo` { + t.Fatalf("unexpected path for call_glob: %q", got) + } + if got := gjson.Get(doneArgs["call_glob"], "pattern").String(); got != "*.{yml,yaml}" { + t.Fatalf("unexpected pattern for call_glob: %q", got) + } + + if len(outputItems) != 2 { + t.Fatalf("expected 2 function_call items in response.output, got %d", len(outputItems)) + } + if outputItems["call_read"].Get("name").String() != "read" { + t.Fatalf("unexpected response.output name for call_read: %q", outputItems["call_read"].Get("name").String()) + } + if outputItems["call_glob"].Get("name").String() != "glob" { + t.Fatalf("unexpected response.output name for call_glob: %q", outputItems["call_glob"].Get("name").String()) + } +}