Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions src/app/v1/_lib/proxy/gemini-function-id-rectifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,20 @@ export type GeminiFunctionIdRectifierResult = {
// 逐条提取 `unknown name "id" at <path>` 违规中的 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
Expand All @@ -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";
}
}
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/proxy/gemini-function-id-rectifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();
});
Expand Down
Loading