From c401595e0164d9ed899f548136a1a2dd480389c5 Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Mon, 20 Jul 2026 18:39:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(openai):=20preserve=20=E8=B7=AF=E5=BE=84=20?= =?UTF-8?q?call=5Fid=20=E8=B6=85=E9=95=BF=E6=97=B6=E5=8E=8B=E7=BC=A9?= =?UTF-8?q?=E8=87=B3=2064=20=E5=AD=97=E7=AC=A6=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产环境真实报错:客户端走 /v1/messages(Anthropic Messages 格式)入站, OAuth 账号转发上游 OpenAI /v1/responses 时(PreserveToolCallIDs=true), 客户端历史中出现超长工具 ID(如嵌套代理拼接产生的 78 字符 srvtoolu_ ID) 会被原样透传,上游 400 拒绝: "Invalid 'input[614].call_id': string too long. Expected a string with maximum length 64, but got a string with length 78 instead." #4612 为非 preserve 路径增加了 call_id 归一化与压缩,但有意将 PreserveToolCallIDs=true 的路径排除在外。本次在 preserve 路径补上长度护栏: - len(id) <= 64 时仍原样透传,保持 #4612 承诺的"保持原样"语义; - 超过 64 时复用已有的 compactCodexCallID 做确定性 SHA-256 压缩。 由于压缩是确定性的,同一逻辑 ID 在 function_call 与 function_call_output 两侧结果一致,tool_use/tool_result 配对不破坏;OAuth 路径 store=false 每轮全量重放历史,跨轮结果亦一致。改写点 fixCallIDPrefix 是 filterCodexInputWithOptions 内所有 call_id 修正的单一入口(含 item_reference 的 call_* 引用),同时覆盖 Messages 桥接(openai_gateway_messages.go)与 /v1/responses 透传(openai_gateway_forward.go)两条 preserve 路径。 Co-authored-by: Cursor --- .../service/openai_codex_transform.go | 10 +++- .../service/openai_codex_transform_test.go | 53 +++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 29af1d806..2d6766af4 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -1409,7 +1409,15 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an // 若 item_reference 指向 legacy call_* 标识,则仅修正该引用本身。 fixCallIDPrefix := func(id string) string { if opts.PreserveCallIDs { - return id + // preserve 模式尽量原样透传客户端 id 以维持 tool_use/tool_result + // 配对,但上游对 call_id 有 64 字符硬上限,超长原样透传必然被 + // 400 拒绝("Invalid 'input[N].call_id': string too long")。 + // 超长时退回确定性压缩:同一逻辑 id 在 function_call 与 + // function_call_output 两侧结果一致,配对不受影响。 + if len(id) <= codexCallIDMaxLength { + return id + } + return compactCodexCallID(id) } return normalizeCodexCallID(id) } diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index e5002326a..9489ab60d 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -167,8 +167,48 @@ func TestApplyCodexOAuthTransform_BoundsLongCallIDsAndPreservesPairing(t *testin } } -func TestApplyCodexOAuthTransform_PreservesLongCallIDsWhenRequested(t *testing.T) { - callID := "call-" + strings.Repeat("x", 70) +func TestApplyCodexOAuthTransform_PreservesCallIDsWithinLimitWhenRequested(t *testing.T) { + // preserve 模式下 ≤64 字符的 id 必须原样透传(含 64 字符等长边界), + // 不做任何前缀改写或压缩。 + for _, tc := range []struct { + name string + callID string + }{ + {name: "anthropic toolu id", callID: "toolu_01ABCdefGHIjklMNOpqrsTUV"}, + {name: "boundary 64 chars", callID: "toolu_" + strings.Repeat("x", codexCallIDMaxLength-len("toolu_"))}, + } { + t.Run(tc.name, func(t *testing.T) { + require.LessOrEqual(t, len(tc.callID), codexCallIDMaxLength) + reqBody := map[string]any{ + "model": "gpt-5.2", + "input": []any{ + map[string]any{"type": "function_call", "call_id": tc.callID, "name": "shell"}, + map[string]any{"type": "function_call_output", "call_id": tc.callID, "output": "done"}, + }, + } + + applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{ + PreserveToolCallIDs: true, + }) + + input, ok := reqBody["input"].([]any) + require.True(t, ok) + call, ok := input[0].(map[string]any) + require.True(t, ok) + output, ok := input[1].(map[string]any) + require.True(t, ok) + require.Equal(t, tc.callID, call["call_id"]) + require.Equal(t, tc.callID, output["call_id"]) + }) + } +} + +func TestApplyCodexOAuthTransform_CompactsOverlongCallIDsWhenPreserveRequested(t *testing.T) { + // preserve 模式下超过 64 字符的 id 若原样透传,上游必然 400 + // ("Invalid 'input[N].call_id': string too long"),需退回确定性压缩; + // function_call 与 function_call_output 两侧压缩结果一致,配对保持。 + callID := "srvtoolu_" + strings.Repeat("x", 69) // 78 字符,对应生产环境真实报错长度 + require.Len(t, callID, 78) reqBody := map[string]any{ "model": "gpt-5.2", "input": []any{ @@ -187,8 +227,13 @@ func TestApplyCodexOAuthTransform_PreservesLongCallIDsWhenRequested(t *testing.T require.True(t, ok) output, ok := input[1].(map[string]any) require.True(t, ok) - require.Equal(t, callID, call["call_id"]) - require.Equal(t, callID, output["call_id"]) + + compacted, ok := call["call_id"].(string) + require.True(t, ok) + require.Len(t, compacted, codexCallIDMaxLength) + require.True(t, strings.HasPrefix(compacted, codexCallIDPrefix)) + require.Equal(t, compacted, output["call_id"], "两侧压缩结果必须一致以保持配对") + require.Equal(t, compactCodexCallID(callID), compacted, "压缩必须是确定性的") } func TestApplyCodexOAuthTransform_ToolSearchOutputPreservesCallID(t *testing.T) {