Recover Codex output from streamed message events

The ChatGPT Codex OAuth backend can now send assistant text in response.output_item.done while leaving the completed response.output array empty. The Codex provider already receives the streamed items, so this patch reconstructs response.output from those items when the completed payload is empty and adds regression tests for both the fallback path and the existing completed-output path.\n\nTested: go test ./pkg/providers/oauth/...
This commit is contained in:
astrada-c 2026-04-18 20:40:22 +08:00
parent 6126ede963
commit b72d1b835e
2 changed files with 97 additions and 0 deletions

View file

@ -104,8 +104,12 @@ func (p *CodexProvider) Chat(
defer stream.Close()
var resp *responses.Response
var outputItems []responses.ResponseOutputItemUnion
for stream.Next() {
evt := stream.Current()
if evt.Type == "response.output_item.done" {
outputItems = append(outputItems, evt.Item)
}
if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" {
evtResp := evt.Response
if evtResp.ID != "" {
@ -153,9 +157,32 @@ func (p *CodexProvider) Chat(
return nil, fmt.Errorf("codex API call: stream ended without completed response")
}
resp = hydrateCodexResponseOutput(resp, outputItems, model, resolvedModel, accountID)
return orc.ParseResponseFromStruct(resp), nil
}
func hydrateCodexResponseOutput(
resp *responses.Response, outputItems []responses.ResponseOutputItemUnion, model, resolvedModel, accountID string,
) *responses.Response {
if resp == nil || len(resp.Output) > 0 || len(outputItems) == 0 {
return resp
}
resp.Output = outputItems
logger.WarnCF(
"provider.codex",
"Codex completed response had empty output; reconstructed output from streamed output_item.done events",
map[string]any{
"requested_model": model,
"resolved_model": resolvedModel,
"streamed_output_items": len(outputItems),
"account_id_present": accountID != "",
},
)
return resp
}
func (p *CodexProvider) GetDefaultModel() string {
return codexDefaultModel
}

View file

@ -14,6 +14,17 @@ import (
orc "github.com/sipeed/picoclaw/pkg/providers/openai_responses_common"
)
func mustUnmarshalOutputItem(t *testing.T, raw string) responses.ResponseOutputItemUnion {
t.Helper()
var item responses.ResponseOutputItemUnion
if err := json.Unmarshal([]byte(raw), &item); err != nil {
t.Fatalf("unmarshal output item: %v", err)
}
return item
}
func TestBuildCodexParams_BasicMessage(t *testing.T) {
messages := []Message{
{Role: "user", Content: "Hello"},
@ -239,6 +250,65 @@ func TestParseCodexResponse_TextOutput(t *testing.T) {
}
}
func TestHydrateCodexResponseOutput_UsesStreamItemsWhenCompletedOutputIsEmpty(t *testing.T) {
resp := &responses.Response{}
streamItem := mustUnmarshalOutputItem(t, `{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{"type": "output_text", "text": "PONG"}
]
}`)
resp = hydrateCodexResponseOutput(resp, []responses.ResponseOutputItemUnion{streamItem}, "gpt-5.4", "gpt-5.4", "acct_123")
result := orc.ParseResponseFromStruct(resp)
if result.Content != "PONG" {
t.Fatalf("Content = %q, want %q", result.Content, "PONG")
}
}
func TestHydrateCodexResponseOutput_PreservesExistingCompletedOutput(t *testing.T) {
resp := &responses.Response{}
if err := json.Unmarshal([]byte(`{
"id": "resp_test",
"object": "response",
"status": "completed",
"output": [
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{"type": "output_text", "text": "from-completed"}
]
}
]
}`), resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
streamItem := mustUnmarshalOutputItem(t, `{
"id": "msg_2",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{"type": "output_text", "text": "from-stream"}
]
}`)
resp = hydrateCodexResponseOutput(resp, []responses.ResponseOutputItemUnion{streamItem}, "gpt-5.4", "gpt-5.4", "acct_123")
result := orc.ParseResponseFromStruct(resp)
if result.Content != "from-completed" {
t.Fatalf("Content = %q, want %q", result.Content, "from-completed")
}
}
func TestParseCodexResponse_FunctionCall(t *testing.T) {
respJSON := `{
"id": "resp_test",