From 6701410be5a90b5024f8eb22b0037d7e6aaa1ceb Mon Sep 17 00:00:00 2001 From: pris Date: Tue, 7 Jul 2026 10:02:20 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(proxy):=20Gemini=20function=20id=20?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E8=AF=8D=E5=85=BC=E5=AE=B9=20JSON=20?= =?UTF-8?q?=E8=BD=AC=E4=B9=89=E5=BC=95=E5=8F=B7=E5=B9=B6=E6=95=B4=E6=AE=B5?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E8=B7=AF=E5=BE=84=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E6=BC=8F=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产验证发现(v0.8.10 + Vertex 直连实测):forwarder 的详细错误消息把 上游 JSON body 原样拼入(`| Upstream: {"error":{"message":"... Unknown name \"id\" ..."}}`),message 字段内层引号处于 JSON 转义形态,裸引号 正则匹配不到 → 整流器从不触发,Vertex 400 依旧直达客户端。 两处修复: - `id` 两侧允许任意个反斜杠(`\*"id\*"`),兼容转义/解码两种形态; - 路径中的函数字段改为按路径段精确匹配(剥数组下标后整段比对), 避免 `tool_config.function_calling_config` 等真实 Gemini 路径因含 `function_call` 子串被误判,进而压制正常故障转移。 新增 3 例回归测试,其一取自生产 forwarder 详细错误消息原样形态。 修复后的正则已在生产实测:400 → rectifier applied → retry → 200。 Co-Authored-By: Claude Fable 5 --- .../proxy/gemini-function-id-rectifier.ts | 26 ++++++++++---- .../gemini-function-id-rectifier.test.ts | 34 +++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/app/v1/_lib/proxy/gemini-function-id-rectifier.ts b/src/app/v1/_lib/proxy/gemini-function-id-rectifier.ts index 9d6314e34..5d3ab2b2e 100644 --- a/src/app/v1/_lib/proxy/gemini-function-id-rectifier.ts +++ b/src/app/v1/_lib/proxy/gemini-function-id-rectifier.ts @@ -20,7 +20,20 @@ export type GeminiFunctionIdRectifierResult = { // 逐条提取 `unknown name "id" at ` 违规中的 path,引号路径与无引号路径分开捕获 // (兼容网关改写文案):引号路径在闭合引号截断,无引号路径在空白/冒号/换行截断, // 防止多条违规被合并到一行时跨违规误捕获。 -const ID_VIOLATION_PATTERN = /unknown name "id" at\s+(?:'([^':\n]+)'|([^\s':\n]+))/g; +// `id` 两侧允许任意个反斜杠:转发链路常把上游 JSON body 原样拼进错误消息 +// (如 `... | Upstream: {"error":{"message":"... Unknown name \"id\" ..."}}`), +// 此时消息字段内层引号处于 JSON 转义形态,裸引号正则会漏检。 +const ID_VIOLATION_PATTERN = /unknown name \\*"id\\*" at\s+(?:'([^':\n]+)'|([^\s':\n]+))/g; + +// 函数字段的路径段全名(小写化后)。必须整段精确匹配而非子串包含: +// `tool_config.function_calling_config` 等真实 Gemini 路径含 `function_call` 子串, +// 子串匹配会把无关违规误判为函数字段 id 违规。 +const FUNCTION_FIELD_SEGMENTS = new Set([ + "function_call", + "function_response", + "functioncall", + "functionresponse", +]); export function detectGeminiFunctionIdRectifierTrigger( errorMessage: string | null | undefined @@ -34,12 +47,11 @@ export function detectGeminiFunctionIdRectifierTrigger( // 兼容 snake_case(Vertex 错误文案)与 camelCase(部分兼容网关)两种路径写法。 for (const match of lower.matchAll(ID_VIOLATION_PATTERN)) { const path = match[1] ?? match[2] ?? ""; - if ( - path.includes("function_call") || - path.includes("function_response") || - path.includes("functioncall") || - path.includes("functionresponse") - ) { + const hasFunctionFieldSegment = path + .split(".") + .some((segment) => FUNCTION_FIELD_SEGMENTS.has(segment.replace(/\[\d+\]$/, ""))); + + if (hasFunctionFieldSegment) { return "unknown_function_id_field"; } } diff --git a/tests/unit/proxy/gemini-function-id-rectifier.test.ts b/tests/unit/proxy/gemini-function-id-rectifier.test.ts index a1d2bbda7..f3c3821fe 100644 --- a/tests/unit/proxy/gemini-function-id-rectifier.test.ts +++ b/tests/unit/proxy/gemini-function-id-rectifier.test.ts @@ -42,6 +42,27 @@ Invalid JSON payload received. Unknown name "id" at 'contents[2].parts[0].functi expect(trigger).toBe("unknown_function_id_field"); }); + it("should detect JSON-escaped quotes in forwarder detailed error messages", () => { + // 转发链路把上游 JSON body 原样拼进错误消息,message 字段内层引号为 \" 转义形态 + const trigger = detectGeminiFunctionIdRectifierTrigger( + `Provider Vertex AI returned 400: Provider returned 400: Bad Request | Upstream: { + "error": { + "code": 400, + "message": "Invalid JSON payload received. Unknown name \\"id\\" at 'contents[2].parts[0].function_call': Cannot find field.\\nInvalid JSON payload received. Unknown name \\"id\\" at 'contents[3].parts[0].function_response': Cannot find field.", + "status": "INVALID_ARGUMENT" + } +}` + ); + expect(trigger).toBe("unknown_function_id_field"); + }); + + it("should not detect JSON-escaped id violation on unrelated path", () => { + const trigger = detectGeminiFunctionIdRectifierTrigger( + `Provider returned 400 | Upstream: {"error":{"message":"Invalid JSON payload received. Unknown name \\"id\\" at 'generation_config': Cannot find field."}}` + ); + expect(trigger).toBeNull(); + }); + it("should not cross-match id violation on one path with function field on another", () => { const trigger = detectGeminiFunctionIdRectifierTrigger( `Invalid JSON payload received. Unknown name "id" at 'generation_config': Cannot find field. @@ -64,6 +85,19 @@ Invalid JSON payload received. Unknown name "foo" at 'contents[0].parts[0].funct expect(trigger).toBe("unknown_function_id_field"); }); + it("should not match function_calling_config paths via substring", () => { + expect( + detectGeminiFunctionIdRectifierTrigger( + `Invalid JSON payload received. Unknown name "id" at 'tool_config.function_calling_config': Cannot find field.` + ) + ).toBeNull(); + expect( + detectGeminiFunctionIdRectifierTrigger( + `Unknown name "id" at 'toolConfig.functionCallingConfig'` + ) + ).toBeNull(); + }); + it("should return null when unknown field is not id", () => { const trigger = detectGeminiFunctionIdRectifierTrigger( `Invalid JSON payload received. Unknown name "foo" at 'contents[0].parts[0].function_call': Cannot find field.` From c7db8a5162c53ca3d64237c8244c24309dcb8655 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 23 Jul 2026 04:12:17 +0800 Subject: [PATCH 2/2] test(proxy): consume Responses failure stream --- ...response-handler-endpoint-circuit-isolation.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index 1b640ac5d..c88b61ff6 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -465,7 +465,8 @@ describe("Endpoint circuit breaker isolation", () => { setDeferredMeta(session, 42); const response = createOpenAIResponsesFailedStreamResponse(); - await ProxyResponseHandler.dispatch(session, response); + const clientResponse = await ProxyResponseHandler.dispatch(session, response); + await clientResponse.text(); await drainAsyncTasks(); expect(mockRecordFailure).toHaveBeenCalledWith( @@ -474,15 +475,16 @@ describe("Endpoint circuit breaker isolation", () => { ); expect(mockRecordEndpointSuccess).not.toHaveBeenCalled(); expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); - expect(SessionManager.clearSessionProvider).toHaveBeenCalledWith("fake-session"); - expect(updateMessageRequestDetails).toHaveBeenCalledWith( + expect(SessionManager.clearSessionProvider).toHaveBeenCalledWith("fake-session", 1); + expect(updateMessageRequestDetailsDurably).toHaveBeenCalledWith( 1, expect.objectContaining({ statusCode: 502, errorMessage: "FAKE_200_OPENAI_RESPONSE_FAILED: Concurrency limit exceeded for user, please retry later", providerId: 1, - }) + }), + expect.objectContaining({ onCommitted: expect.any(Function) }) ); expect(RateLimitService.trackCost).not.toHaveBeenCalled(); });