From 8ea4dc56f0292491af026ce486bd6ed53afc81d3 Mon Sep 17 00:00:00 2001 From: feeeei Date: Fri, 11 Sep 2026 17:06:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(gemini):=20=E6=A8=A1=E5=9E=8B=E4=BE=A7=20fi?= =?UTF-8?q?nishReason=20=E4=B8=8D=E5=86=8D=E6=8C=89=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=99=BB=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MALFORMED_FUNCTION_CALL / UNEXPECTED_TOOL_CALL 是模型生成结果,此前按生成异常 登记为上游 502:写带账号归因的上游错误事件、计入 SLA、严重度 P1,并落进渠道 监控的 upstream_5xx 桶。 - detectGeminiResponseSignal 的 finishReason 只登记内容过滤类枚举,其余一律不登记 - geminiSignalAbnormalStop 仅承载 EMPTY_STREAM / EMPTY_RESPONSE - 新增原生流式 MALFORMED_FUNCTION_CALL 不留 ops 标记的回归用例 --- .../gemini_native_response_signal_test.go | 15 ++++++ .../service/gemini_response_signal.go | 50 +++++-------------- .../service/gemini_response_signal_test.go | 7 +-- 3 files changed, 29 insertions(+), 43 deletions(-) diff --git a/backend/internal/service/gemini_native_response_signal_test.go b/backend/internal/service/gemini_native_response_signal_test.go index 382c493194..78c061ff08 100644 --- a/backend/internal/service/gemini_native_response_signal_test.go +++ b/backend/internal/service/gemini_native_response_signal_test.go @@ -421,6 +421,21 @@ func TestGeminiForwardNative_StreamOtherFinishReasonLeavesNoMark(t *testing.T) { require.Empty(t, upstreamErrorEventsFromContext(t, c)) } +func TestGeminiForwardNative_StreamMalformedFunctionCallLeavesNoMark(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `data: {"candidates":[{"content":{"parts":[{"text":"partial"}],"role":"model"},"finishReason":"MALFORMED_FUNCTION_CALL"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2}} + +` + svc := newGeminiSignalService("text/event-stream", body) + c, _ := newGeminiNativeTestContext(t) + result, err := svc.ForwardNative(context.Background(), c, geminiSignalTestAccount(), + "gemini-3.7-flash", "streamGenerateContent", true, geminiSignalTestRequest()) + require.NoError(t, err) + require.Equal(t, 2, result.Usage.OutputTokens) + require.Empty(t, GetOpsStreamErrors(c)) + require.Empty(t, upstreamErrorEventsFromContext(t, c)) +} + func TestGeminiForwardNative_NonStreamEmptyBodyMarksEmptyResponse(t *testing.T) { gin.SetMode(gin.TestMode) for name, body := range map[string]string{ diff --git a/backend/internal/service/gemini_response_signal.go b/backend/internal/service/gemini_response_signal.go index 312d797c8f..b4d97dfe17 100644 --- a/backend/internal/service/gemini_response_signal.go +++ b/backend/internal/service/gemini_response_signal.go @@ -21,7 +21,7 @@ const ( geminiSignalPromptBlocked // geminiSignalContentFilter:首候选 finishReason 属于内容过滤类枚举。 geminiSignalContentFilter - // geminiSignalAbnormalStop:首候选 finishReason 是模型生成异常,或响应体为空。 + // geminiSignalAbnormalStop:上游 2xx 但响应体为空(EMPTY_STREAM / EMPTY_RESPONSE)。 geminiSignalAbnormalStop // geminiSignalError:事件本身是 Google 错误信封 {"error":{"code","message","status"}}, // 与 Gemini SDK 客户端对流式 chunk 的错误判定相同。 @@ -34,7 +34,7 @@ type geminiResponseSignal struct { // Reason 是 finishReason / blockReason 枚举值,或 Google 错误信封的 status;作为 ops 错误 code。 Reason string Message string - // Status 是该信号的语义 HTTP 状态:内容策略类固定 400,错误信封取 error.code,生成异常与空响应 502。 + // Status 是该信号的语义 HTTP 状态:内容策略类固定 400,错误信封取 error.code,空响应 502。 Status int // Detail 是错误信封原文(已截断),仅 geminiSignalError 填写。 Detail string @@ -61,8 +61,8 @@ func geminiPayloadMayCarrySignal(payload []byte) bool { // detectGeminiResponseSignal 检查一个 Gemini 响应事件是否携带带内信号;Code Assist 的 // {"response":{...}} 包装体先解包。判定顺序:错误信封 > promptFeedback.blockReason > 首候选 finishReason。 -// finishReason 为空、STOP、MAX_TOKENS、FINISH_REASON_UNSPECIFIED 视为正常;OTHER、 -// TOO_MANY_TOOL_CALLS、NO_IMAGE、IMAGE_OTHER 与未知枚举同样按正常停止处理(与 gemini-cli 遥测一致)。 +// finishReason 只有内容过滤类枚举登记为信号;STOP、MAX_TOKENS、OTHER、MALFORMED_FUNCTION_CALL、 +// UNEXPECTED_TOOL_CALL、TOO_MANY_TOOL_CALLS 等其余枚举都是模型侧的生成结果,不做上游归因,一律不登记。 func detectGeminiResponseSignal(payload []byte) (geminiResponseSignal, bool) { payload = bytes.TrimSpace(payload) if len(payload) == 0 || !geminiPayloadMayCarrySignal(payload) || !gjson.ValidBytes(payload) { @@ -109,8 +109,7 @@ func detectGeminiResponseSignal(payload []byte) (geminiResponseSignal, bool) { candidate := geminiPrimaryCandidate(payload) finishReason := strings.ToUpper(strings.TrimSpace(candidate.Get("finishReason").String())) - switch finishReason { - case "", "STOP", "MAX_TOKENS", "FINISH_REASON_UNSPECIFIED": + if !isGeminiContentFilterFinishReason(finishReason) { return geminiResponseSignal{}, false } detail := strings.TrimSpace(candidate.Get("finishMessage").String()) @@ -118,23 +117,12 @@ func detectGeminiResponseSignal(payload []byte) (geminiResponseSignal, bool) { detail = geminiFinishReasonText(finishReason) } detail = truncateString(detail, 512) - if isGeminiContentFilterFinishReason(finishReason) { - return geminiResponseSignal{ - Kind: geminiSignalContentFilter, - Reason: finishReason, - Message: fmt.Sprintf("Gemini content policy stop (finishReason=%s): %s", finishReason, detail), - Status: http.StatusBadRequest, - }, true - } - if isGeminiAbnormalStopFinishReason(finishReason) { - return geminiResponseSignal{ - Kind: geminiSignalAbnormalStop, - Reason: finishReason, - Message: fmt.Sprintf("Gemini generation stopped abnormally (finishReason=%s): %s", finishReason, detail), - Status: http.StatusBadGateway, - }, true - } - return geminiResponseSignal{}, false + return geminiResponseSignal{ + Kind: geminiSignalContentFilter, + Reason: finishReason, + Message: fmt.Sprintf("Gemini content policy stop (finishReason=%s): %s", finishReason, detail), + Status: http.StatusBadRequest, + }, true } // geminiPrimaryCandidate 返回首候选:index 缺省或为 0 的候选。多候选流里其它 index 的候选不参与判定。 @@ -226,16 +214,6 @@ func isGeminiContentFilterFinishReason(reason string) bool { } } -// isGeminiAbnormalStopFinishReason 的集合与 gemini-cli 遥测的 ERROR 归类一致。 -func isGeminiAbnormalStopFinishReason(reason string) bool { - switch reason { - case "MALFORMED_FUNCTION_CALL", "UNEXPECTED_TOOL_CALL": - return true - default: - return false - } -} - // geminiFinishReasonText 是 finishMessage 缺失时的兜底描述;列出的枚举沿用 gemini-cli 的 // 用户提示文案,其余枚举用通用兜底。 func geminiFinishReasonText(reason string) string { @@ -252,12 +230,8 @@ func geminiFinishReasonText(reason string) string { return "Response stopped due to prohibited content." case "SPII": return "Response stopped due to sensitive personally identifiable information." - case "MALFORMED_FUNCTION_CALL": - return "Response stopped due to malformed function call." case "IMAGE_SAFETY": return "Response stopped due to image safety violations." - case "UNEXPECTED_TOOL_CALL": - return "Response stopped due to unexpected tool call." case "IMAGE_PROHIBITED_CONTENT": return "Response stopped due to prohibited image content." default: @@ -345,7 +319,7 @@ func (b *geminiSSEFallbackBody) Truncated() bool { // 用量与计费照常走 ForwardResult。stream 是客户端请求的流式标记。 // - 内容策略类(promptFeedback.blockReason / 内容过滤 finishReason)是请求级结果: // 只记请求级带内错误,不归因上游账号,不计入 SLA。 -// - 错误信封、生成异常与空响应按上游失败登记:写上游错误上下文与尝试事件,并按语义状态计入 SLA。 +// - 错误信封与空响应按上游失败登记:写上游错误上下文与尝试事件,并按语义状态计入 SLA。 func (s *GeminiMessagesCompatService) markGeminiResponseSignal(c *gin.Context, account *Account, sig geminiResponseSignal, stream bool, upstreamRequestID string) { if c == nil || sig.Kind == geminiSignalNone { return diff --git a/backend/internal/service/gemini_response_signal_test.go b/backend/internal/service/gemini_response_signal_test.go index 7783980cd1..d2543998c7 100644 --- a/backend/internal/service/gemini_response_signal_test.go +++ b/backend/internal/service/gemini_response_signal_test.go @@ -62,13 +62,10 @@ func TestDetectGeminiResponseSignal(t *testing.T) { wantKind: geminiSignalContentFilter, wantReason: "SPII", wantStatus: http.StatusBadRequest, }, { - name: "MALFORMED_FUNCTION_CALL is an abnormal stop", payload: `{"candidates":[{"content":{},"finishReason":"MALFORMED_FUNCTION_CALL"}]}`, wantHit: true, - wantKind: geminiSignalAbnormalStop, wantReason: "MALFORMED_FUNCTION_CALL", wantStatus: http.StatusBadGateway, - wantMessage: "Gemini generation stopped abnormally (finishReason=MALFORMED_FUNCTION_CALL): Response stopped due to malformed function call.", + name: "MALFORMED_FUNCTION_CALL is not a signal", payload: `{"candidates":[{"content":{},"finishReason":"MALFORMED_FUNCTION_CALL"}]}`, wantHit: false, }, { - name: "UNEXPECTED_TOOL_CALL is an abnormal stop", payload: `{"candidates":[{"finishReason":"UNEXPECTED_TOOL_CALL"}]}`, wantHit: true, - wantKind: geminiSignalAbnormalStop, wantReason: "UNEXPECTED_TOOL_CALL", wantStatus: http.StatusBadGateway, + name: "UNEXPECTED_TOOL_CALL is not a signal", payload: `{"candidates":[{"finishReason":"UNEXPECTED_TOOL_CALL"}]}`, wantHit: false, }, { name: "promptFeedback blockReason", payload: `{"promptFeedback":{"blockReason":"PROHIBITED_CONTENT","safetyRatings":[]},"usageMetadata":{"promptTokenCount":10,"totalTokenCount":10}}`, wantHit: true,