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.` 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(); });