diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index e765d7e9..59fb7a33 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -853,6 +853,14 @@ func filterCodexInput(input []any, preserveReferences bool) []any { } typ, _ := m["type"].(string) + // chatgpt.com codex backend (OAuth path) does not persist reasoning + // items because applyCodexOAuthTransform forces store=false. Any rs_* + // reference replayed in input is guaranteed to 404 upstream + // ("Item with id 'rs_...' not found"). Drop reasoning items entirely. + if typ == "reasoning" { + continue + } + // 仅修正真正的 tool/function call 标识,避免误改普通 message/reasoning id; // 若 item_reference 指向 legacy call_* 标识,则仅修正该引用本身。 fixCallIDPrefix := func(id string) string { diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 75f5c55c..b392cf96 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -1,6 +1,8 @@ package service import ( + "fmt" + "strings" "testing" "github.com/stretchr/testify/require" @@ -1094,3 +1096,54 @@ func TestIsInstructionsEmpty(t *testing.T) { }) } } + +func TestFilterCodexInput_DropsReasoningItemsRegardlessOfPreserveReferences(t *testing.T) { + // Reasoning items in input[] reference rs_* IDs that were emitted by + // chatgpt.com under store=false (forced by applyCodexOAuthTransform). + // They are never persisted upstream, so forwarding them produces a + // guaranteed 404 ("Item with id 'rs_...' not found"). Drop them + // regardless of preserveReferences. See: Wei-Shaw/sub2api issue #1957. + + build := func() []any { + return []any{ + map[string]any{"type": "message", "id": "msg_0", "role": "user", "content": "hi"}, + map[string]any{ + "type": "reasoning", + "id": "rs_0672f12450da0b9c0169f07220a6c08198b68c2455ced99344", + "summary": []any{}, + }, + map[string]any{"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "tool"}, + map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "{}"}, + } + } + + for _, preserve := range []bool{true, false} { + preserve := preserve + t.Run(fmt.Sprintf("preserveReferences=%v", preserve), func(t *testing.T) { + filtered := filterCodexInput(build(), preserve) + + for _, raw := range filtered { + item, ok := raw.(map[string]any) + require.True(t, ok) + require.NotEqual(t, "reasoning", item["type"], + "reasoning items must be dropped from input on the OAuth path") + if id, ok := item["id"].(string); ok { + require.False(t, strings.HasPrefix(id, "rs_"), + "no item carrying an rs_* id should survive the filter") + } + } + + // Sanity check: the non-reasoning items should still be present. + gotTypes := make(map[string]int) + for _, raw := range filtered { + item, ok := raw.(map[string]any) + require.True(t, ok) + gotTypes[item["type"].(string)]++ + } + require.Equal(t, 1, gotTypes["message"]) + require.Equal(t, 1, gotTypes["function_call"]) + require.Equal(t, 1, gotTypes["function_call_output"]) + require.Equal(t, 0, gotTypes["reasoning"]) + }) + } +}