From fd64d07e61b4ae57830ede458069a002f824de60 Mon Sep 17 00:00:00 2001 From: li Date: Tue, 7 Jul 2026 15:54:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(codex):=20=E5=89=A5=E7=A6=BB=E7=BB=AD?= =?UTF-8?q?=E9=93=BE=20function=5Fcall=20item=20=E7=9A=84=E9=9D=9E?= =?UTF-8?q?=E6=B3=95=20item=5F*=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI OAuth 转发续链请求时,function_call 等 call-input 类 item 的 id 被客户端以 item_* 形式回放,但上游要求以 fc 开头,返回 400 "Expected an ID that begins with 'fc'",导致工具续链反复失败。 filterCodexInputWithOptions 在 PreserveReferences=true 路径下对 call-input 类 item(function_call/tool_call/local_shell_call 等) 增加 id 前缀检查:非 fc 开头即删除。output 类(function_call_output 等)的 id 无此约束,不动。 Fixes #3785 --- .../openai_codex_function_call_id_test.go | 136 ++++++++++++++++++ .../service/openai_codex_transform.go | 26 ++++ 2 files changed, 162 insertions(+) create mode 100644 backend/internal/service/openai_codex_function_call_id_test.go diff --git a/backend/internal/service/openai_codex_function_call_id_test.go b/backend/internal/service/openai_codex_function_call_id_test.go new file mode 100644 index 0000000000..2ac59e0520 --- /dev/null +++ b/backend/internal/service/openai_codex_function_call_id_test.go @@ -0,0 +1,136 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestFilterCodexInput_StripsFunctionCallItemID_WhenPreservingReferences +// verifies that function_call items with non-fc id (e.g. item_*) have their +// id stripped even when PreserveReferences is true. OpenAI upstream requires +// function_call ids to begin with "fc" and rejects item_* with 400: +// "Expected an ID that begins with 'fc'." (#3785) +func TestFilterCodexInput_StripsFunctionCallItemID_WhenPreservingReferences(t *testing.T) { + input := []any{ + map[string]any{ + "type": "function_call", + "id": "item_A9v0SNfS3VaLrfX0j3y4xhyK", + "call_id": "fc_abc123", + "name": "bash", + }, + map[string]any{ + "type": "function_call_output", + "call_id": "fc_abc123", + "output": "done", + }, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{ + PreserveReferences: true, + }) + + require.Len(t, filtered, 2) + + fc, ok := filtered[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "function_call", fc["type"]) + _, hasID := fc["id"] + require.False(t, hasID, "item_* id should be stripped from function_call") + require.Equal(t, "fc_abc123", fc["call_id"], "call_id must be preserved") + require.Equal(t, "bash", fc["name"]) +} + +// TestFilterCodexInput_KeepsFcID_WhenPreservingReferences +// verifies that function_call items with a valid fc* id are kept when +// PreserveReferences is true. +func TestFilterCodexInput_KeepsFcID_WhenPreservingReferences(t *testing.T) { + input := []any{ + map[string]any{ + "type": "function_call", + "id": "fc_validID123", + "call_id": "fc_validID123", + "name": "bash", + }, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{ + PreserveReferences: true, + }) + + require.Len(t, filtered, 1) + fc, ok := filtered[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "fc_validID123", fc["id"], "valid fc* id must be preserved") +} + +// TestFilterCodexInput_StripsItemIDFromAllToolCallInputTypes verifies that +// item_* ids are stripped from all call-input types (not output types). +func TestFilterCodexInput_StripsItemIDFromAllToolCallInputTypes(t *testing.T) { + types := []string{"function_call", "tool_call", "local_shell_call", "custom_tool_call", "mcp_tool_call"} + + for _, typ := range types { + input := []any{ + map[string]any{ + "type": typ, + "id": "item_xyz", + "call_id": "fc_001", + "name": "tool", + }, + } + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{ + PreserveReferences: true, + }) + require.Len(t, filtered, 1) + item, ok := filtered[0].(map[string]any) + require.True(t, ok) + _, hasID := item["id"] + require.False(t, hasID, "item_* id should be stripped from %s", typ) + } +} + +// TestFilterCodexInput_OutputTypeKeepsItemID ensures tool-output items +// (e.g. function_call_output) keep their id — only call-input types have +// the fc* constraint. +func TestFilterCodexInput_OutputTypeKeepsItemID(t *testing.T) { + input := []any{ + map[string]any{ + "type": "function_call_output", + "id": "o1", + "call_id": "fc_abc", + "output": "done", + }, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{ + PreserveReferences: true, + }) + + require.Len(t, filtered, 1) + out, ok := filtered[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "o1", out["id"], "output item id should be preserved") +} + +// TestFilterCodexInput_NonToolCallItemKeepsID ensures non-tool-call items +// (e.g. message) still keep their id when PreserveReferences is true. +func TestFilterCodexInput_NonToolCallItemKeepsID(t *testing.T) { + input := []any{ + map[string]any{ + "type": "message", + "id": "item_msg_001", + "role": "user", + }, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{ + PreserveReferences: true, + }) + + require.Len(t, filtered, 1) + msg, ok := filtered[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "item_msg_001", msg["id"], "non-tool-call items keep their id in preserve mode") +} diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 0666293deb..44c420edbe 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -1303,6 +1303,16 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an if !opts.PreserveReferences { ensureCopy() delete(newItem, "id") + } else if isCodexToolCallInputType(typ) { + // 续链模式下保留 id 以维持上下文引用,但 function_call 等 + // call-input 类 item 的 id 必须以 "fc" 开头(上游校验 + // "Expected an ID that begins with 'fc'")。item_* 形式的 id + // 来自客户端回放,需要删除。 + // 注意:function_call_output 等 output 类的 id 无此约束,不动。 + if id, ok := m["id"].(string); ok && id != "" && !strings.HasPrefix(id, "fc") { + ensureCopy() + delete(newItem, "id") + } } filtered = append(filtered, newItem) @@ -1328,6 +1338,22 @@ func isCodexToolCallItemType(typ string) bool { } } +// isCodexToolCallInputType 仅匹配 call-input 类型(不含 output),这些类型的 +// id 必须以 "fc" 开头,上游会校验 "Expected an ID that begins with 'fc'."。 +func isCodexToolCallInputType(typ string) bool { + switch typ { + case "function_call", + "tool_call", + "local_shell_call", + "tool_search_call", + "custom_tool_call", + "mcp_tool_call": + return true + default: + return false + } +} + func codexInputItemRequiresName(typ string) bool { switch strings.TrimSpace(typ) { case "function_call", "custom_tool_call", "mcp_tool_call":