mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
fix(codex): hydrate missing response.completed output item IDs
When a `response.completed` payload already includes output entries, fill in only missing/empty item `id`s from the streamed `output_item.done` data while keeping existing IDs untouched. Closes: #4622
This commit is contained in:
@@ -18,6 +18,43 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCodexExecutorExecute_NonEmptyCompletionOutputHydratesMissingItemID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"id":"fc_123","type":"function_call","call_id":"call_123","name":"weather","arguments":"{}"},"output_index":0}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"id":"fc_done_existing","type":"function_call","call_id":"call_existing","name":"other","arguments":"{}"},"output_index":1}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[{"id":null,"type":"function_call","call_id":"call_123","name":"weather-terminal","arguments":"{}"},{"id":"fc_existing","type":"function_call","call_id":"call_existing","name":"preserved","arguments":"{}"}]}}` + "\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
executor := NewCodexExecutor(&config.Config{})
|
||||
auth := &cliproxyauth.Auth{Attributes: map[string]string{
|
||||
"base_url": server.URL,
|
||||
"api_key": "test",
|
||||
}}
|
||||
|
||||
resp, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
||||
Model: "gpt-5.4",
|
||||
Payload: []byte(`{"model":"gpt-5.4","input":"What is the weather?"}`),
|
||||
}, cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FromString("openai-response"),
|
||||
Stream: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute error: %v", err)
|
||||
}
|
||||
|
||||
if got := gjson.GetBytes(resp.Payload, "output.0.id").String(); got != "fc_123" {
|
||||
t.Fatalf("output[0].id = %q, want %q; payload=%s", got, "fc_123", resp.Payload)
|
||||
}
|
||||
if got := gjson.GetBytes(resp.Payload, "output.0.name").String(); got != "weather-terminal" {
|
||||
t.Fatalf("output[0].name = %q, want terminal value; payload=%s", got, resp.Payload)
|
||||
}
|
||||
if got := gjson.GetBytes(resp.Payload, "output.1.id").String(); got != "fc_existing" {
|
||||
t.Fatalf("output[1].id = %q, want existing value; payload=%s", got, resp.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexExecutorExecute_EmptyStreamCompletionOutputUsesOutputItemDone(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -44,8 +45,39 @@ func collectCodexOutputItemDone(eventData []byte, outputItemsByIndex map[int64][
|
||||
*outputItemsFallback = append(*outputItemsFallback, []byte(itemResult.Raw))
|
||||
}
|
||||
|
||||
func hydrateCodexCompletedOutputItemIDs(eventData []byte, outputItems []gjson.Result, outputItemsByIndex map[int64][]byte) []byte {
|
||||
patchedData := eventData
|
||||
for outputIndex, outputItem := range outputItems {
|
||||
itemData := []byte(outputItem.Raw)
|
||||
itemID := gjson.GetBytes(itemData, "id")
|
||||
if itemID.Exists() && itemID.Type != gjson.Null && (itemID.Type != gjson.String || strings.TrimSpace(itemID.String()) != "") {
|
||||
continue
|
||||
}
|
||||
|
||||
completedItem, ok := outputItemsByIndex[int64(outputIndex)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
completedID := gjson.GetBytes(completedItem, "id")
|
||||
if completedID.Type != gjson.String || strings.TrimSpace(completedID.String()) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
updatedData, errSet := sjson.SetRawBytes(patchedData, "response.output."+strconv.Itoa(outputIndex)+".id", []byte(completedID.Raw))
|
||||
if errSet != nil {
|
||||
continue
|
||||
}
|
||||
patchedData = updatedData
|
||||
}
|
||||
return patchedData
|
||||
}
|
||||
|
||||
func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
|
||||
outputResult := gjson.GetBytes(eventData, "response.output")
|
||||
if outputResult.Exists() && outputResult.IsArray() && len(outputResult.Array()) > 0 {
|
||||
return hydrateCodexCompletedOutputItemIDs(eventData, outputResult.Array(), outputItemsByIndex)
|
||||
}
|
||||
|
||||
shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0)
|
||||
if !shouldPatchOutput {
|
||||
return eventData
|
||||
|
||||
Reference in New Issue
Block a user