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:
parent
6126ede963
commit
b72d1b835e
2 changed files with 97 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue