mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
fix(gemini): 模型侧 finishReason 不再按上游失败登记
MALFORMED_FUNCTION_CALL / UNEXPECTED_TOOL_CALL 是模型生成结果,此前按生成异常 登记为上游 502:写带账号归因的上游错误事件、计入 SLA、严重度 P1,并落进渠道 监控的 upstream_5xx 桶。 - detectGeminiResponseSignal 的 finishReason 只登记内容过滤类枚举,其余一律不登记 - geminiSignalAbnormalStop 仅承载 EMPTY_STREAM / EMPTY_RESPONSE - 新增原生流式 MALFORMED_FUNCTION_CALL 不留 ops 标记的回归用例
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user