Skip to content

Commit 3444820

Browse files
authored
Merge pull request router-for-me#2939 from stringer07/fix/codex-stream-output-backfill
fix(codex): backfill streaming response output
2 parents 1716a84 + b6781d6 commit 3444820

2 files changed

Lines changed: 124 additions & 2 deletions

File tree

internal/runtime/executor/codex_executor.go

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,69 @@ const (
3636

3737
var dataTag = []byte("data:")
3838

39+
// Streamed Codex responses may emit response.output_item.done events while leaving
40+
// response.completed.response.output empty. Keep the stream path aligned with the
41+
// already-patched non-stream path by reconstructing response.output from those items.
42+
func collectCodexOutputItemDone(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) {
43+
itemResult := gjson.GetBytes(eventData, "item")
44+
if !itemResult.Exists() || itemResult.Type != gjson.JSON {
45+
return
46+
}
47+
outputIndexResult := gjson.GetBytes(eventData, "output_index")
48+
if outputIndexResult.Exists() {
49+
outputItemsByIndex[outputIndexResult.Int()] = []byte(itemResult.Raw)
50+
return
51+
}
52+
*outputItemsFallback = append(*outputItemsFallback, []byte(itemResult.Raw))
53+
}
54+
55+
func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
56+
outputResult := gjson.GetBytes(eventData, "response.output")
57+
shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0)
58+
if !shouldPatchOutput {
59+
return eventData
60+
}
61+
62+
indexes := make([]int64, 0, len(outputItemsByIndex))
63+
for idx := range outputItemsByIndex {
64+
indexes = append(indexes, idx)
65+
}
66+
sort.Slice(indexes, func(i, j int) bool {
67+
return indexes[i] < indexes[j]
68+
})
69+
70+
items := make([][]byte, 0, len(outputItemsByIndex)+len(outputItemsFallback))
71+
for _, idx := range indexes {
72+
items = append(items, outputItemsByIndex[idx])
73+
}
74+
items = append(items, outputItemsFallback...)
75+
76+
outputArray := []byte("[]")
77+
if len(items) > 0 {
78+
var buf bytes.Buffer
79+
totalLen := 2
80+
for _, item := range items {
81+
totalLen += len(item)
82+
}
83+
if len(items) > 1 {
84+
totalLen += len(items) - 1
85+
}
86+
buf.Grow(totalLen)
87+
buf.WriteByte('[')
88+
for i, item := range items {
89+
if i > 0 {
90+
buf.WriteByte(',')
91+
}
92+
buf.Write(item)
93+
}
94+
buf.WriteByte(']')
95+
outputArray = buf.Bytes()
96+
}
97+
98+
completedDataPatched, _ := sjson.SetRawBytes(eventData, "response.output", outputArray)
99+
return completedDataPatched
100+
}
101+
39102
// CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint).
40103
// If api_key is unavailable on auth, it falls back to legacy via ClientAdapter.
41104
type CodexExecutor struct {
@@ -414,20 +477,28 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
414477
scanner := bufio.NewScanner(httpResp.Body)
415478
scanner.Buffer(nil, 52_428_800) // 50MB
416479
var param any
480+
outputItemsByIndex := make(map[int64][]byte)
481+
var outputItemsFallback [][]byte
417482
for scanner.Scan() {
418483
line := scanner.Bytes()
419484
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
485+
translatedLine := bytes.Clone(line)
420486

421487
if bytes.HasPrefix(line, dataTag) {
422488
data := bytes.TrimSpace(line[5:])
423-
if gjson.GetBytes(data, "type").String() == "response.completed" {
489+
switch gjson.GetBytes(data, "type").String() {
490+
case "response.output_item.done":
491+
collectCodexOutputItemDone(data, outputItemsByIndex, &outputItemsFallback)
492+
case "response.completed":
424493
if detail, ok := helps.ParseCodexUsage(data); ok {
425494
reporter.Publish(ctx, detail)
426495
}
496+
data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback)
497+
translatedLine = append([]byte("data: "), data...)
427498
}
428499
}
429500

430-
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, bytes.Clone(line), &param)
501+
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, translatedLine, &param)
431502
for i := range chunks {
432503
out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
433504
}

internal/runtime/executor/codex_executor_stream_output_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package executor
22

33
import (
4+
"bytes"
45
"context"
56
"net/http"
67
"net/http/httptest"
@@ -44,3 +45,53 @@ func TestCodexExecutorExecute_EmptyStreamCompletionOutputUsesOutputItemDone(t *t
4445
t.Fatalf("choices.0.message.content = %q, want %q; payload=%s", gotContent, "ok", string(resp.Payload))
4546
}
4647
}
48+
49+
func TestCodexExecutorExecuteStream_EmptyStreamCompletionOutputUsesOutputItemDone(t *testing.T) {
50+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
51+
w.Header().Set("Content-Type", "text/event-stream")
52+
_, _ = w.Write([]byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}\n"))
53+
_, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":1775555723,\"status\":\"completed\",\"model\":\"gpt-5.4-mini-2026-03-17\",\"output\":[],\"usage\":{\"input_tokens\":8,\"output_tokens\":28,\"total_tokens\":36}}}\n\n"))
54+
}))
55+
defer server.Close()
56+
57+
executor := NewCodexExecutor(&config.Config{})
58+
auth := &cliproxyauth.Auth{Attributes: map[string]string{
59+
"base_url": server.URL,
60+
"api_key": "test",
61+
}}
62+
63+
result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
64+
Model: "gpt-5.4-mini",
65+
Payload: []byte(`{"model":"gpt-5.4-mini","input":"Say ok"}`),
66+
}, cliproxyexecutor.Options{
67+
SourceFormat: sdktranslator.FromString("openai-response"),
68+
Stream: true,
69+
})
70+
if err != nil {
71+
t.Fatalf("ExecuteStream error: %v", err)
72+
}
73+
74+
var completed []byte
75+
for chunk := range result.Chunks {
76+
if chunk.Err != nil {
77+
t.Fatalf("stream chunk error: %v", chunk.Err)
78+
}
79+
payload := bytes.TrimSpace(chunk.Payload)
80+
if !bytes.HasPrefix(payload, []byte("data:")) {
81+
continue
82+
}
83+
data := bytes.TrimSpace(payload[5:])
84+
if gjson.GetBytes(data, "type").String() == "response.completed" {
85+
completed = append([]byte(nil), data...)
86+
}
87+
}
88+
89+
if len(completed) == 0 {
90+
t.Fatal("missing response.completed chunk")
91+
}
92+
93+
gotContent := gjson.GetBytes(completed, "response.output.0.content.0.text").String()
94+
if gotContent != "ok" {
95+
t.Fatalf("response.output[0].content[0].text = %q, want %q; completed=%s", gotContent, "ok", string(completed))
96+
}
97+
}

0 commit comments

Comments
 (0)