From 3abd64658297f5ec47ad09120a0a26d6081d42de Mon Sep 17 00:00:00 2001 From: feeeei Date: Sat, 10 Oct 2026 22:09:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(ops):=20Gemini=20=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E7=9A=84=E7=BD=91=E5=85=B3=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8C=89=E9=94=99=E8=AF=AF=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=BD=92=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google 格式错误体没有 type 字段,ops 一律记为 api_error,客户端参数错误、 引用不存在的缓存等都被归为平台内部错误。 - googleError 写出时按状态码向 ops 登记错误类型:400/413 invalid_request_error、 401 authentication_error、403 permission_error、404 not_found_error, 429 与 5xx 不登记 - 计费、并发与风控拦截沿用 Claude / OpenAI 格式错误的类型与错误码,归类口径一致 - 存储价未配置、显式缓存未启用改用 googlePlatformError,仍按平台错误统计; 错误透传规则改写的上游错误不登记本地类型 - 上游返回的缓存不存在标记为请求级失败:按客户端请求错误分类, 上游原始错误保留为明细但不参与上游归因 --- .../handler/gemini_cached_contents_handler.go | 19 +-- .../internal/handler/gemini_v1beta_handler.go | 45 +++++- backend/internal/handler/ops_error_logger.go | 13 ++ .../handler/ops_error_logger_google_test.go | 135 ++++++++++++++++++ .../internal/handler/security_audit_errors.go | 7 +- .../gemini_cached_content_not_found_test.go | 4 + .../service/gemini_messages_compat_service.go | 3 +- .../internal/service/ops_upstream_context.go | 44 ++++++ 8 files changed, 252 insertions(+), 18 deletions(-) create mode 100644 backend/internal/handler/ops_error_logger_google_test.go diff --git a/backend/internal/handler/gemini_cached_contents_handler.go b/backend/internal/handler/gemini_cached_contents_handler.go index 82b3741fad..00404329d5 100644 --- a/backend/internal/handler/gemini_cached_contents_handler.go +++ b/backend/internal/handler/gemini_cached_contents_handler.go @@ -42,7 +42,7 @@ func (h *GatewayHandler) geminiCachedContentsPreamble(c *gin.Context, component return nil, middleware.AuthSubject{}, nil, false } if h.geminiCachedContentService == nil { - googleError(c, http.StatusNotFound, "Explicit context caching is not enabled") + googlePlatformError(c, http.StatusNotFound, "Explicit context caching is not enabled") return nil, middleware.AuthSubject{}, nil, false } if apiKey.Group == nil || apiKey.GroupID == nil || apiKey.Group.Platform == service.PlatformComposite { @@ -83,11 +83,11 @@ func readGeminiCachedContentBody(c *gin.Context) ([]byte, bool) { func (h *GatewayHandler) checkGeminiCachedContentBilling(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subscription *service.UserSubscription) bool { if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { reqLog.Info("gemini.cached_content.billing_eligibility_check_failed", zap.Error(err)) - status, _, message, retryAfter := billingErrorDetails(err) + status, code, message, retryAfter := billingErrorDetails(err) if retryAfter > 0 { c.Header("Retry-After", strconv.Itoa(retryAfter)) } - googleError(c, status, message) + googleErrorWithType(c, status, code, "", message) return false } return true @@ -150,11 +150,11 @@ func (h *GatewayHandler) GeminiCachedContentsCreate(c *gin.Context) { inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(modelName, body)) if err != nil { reqLog.Info("gemini.cached_content.inflight_reservation_rejected", zap.Error(err)) - status, _, message, retryAfter := billingErrorDetails(err) + status, code, message, retryAfter := billingErrorDetails(err) if retryAfter > 0 { c.Header("Retry-After", strconv.Itoa(retryAfter)) } - googleError(c, status, message) + googleErrorWithType(c, status, code, "", message) return } defer inflightRelease() @@ -218,7 +218,7 @@ func (h *GatewayHandler) GeminiCachedContentsCreate(c *gin.Context) { ChannelUsageFields: usageFields, }) { accountReleaseFunc() - googleError(c, http.StatusBadRequest, fmt.Sprintf("Explicit context caching is not available for model %s: storage pricing is not configured", modelName)) + googlePlatformError(c, http.StatusBadRequest, fmt.Sprintf("Explicit context caching is not available for model %s: storage pricing is not configured", modelName)) return } upstreamModel, err := service.GeminiCachedContentUpstreamModelResource(account, mappedModel) @@ -438,7 +438,7 @@ func (h *GatewayHandler) GeminiCachedContentsPatch(c *gin.Context) { APIKey: apiKey, User: apiKey.User, Subscription: subscription, PricingAt: pricingAt, } if !h.gatewayService.CacheStoragePriced(c.Request.Context(), usage) { - googleError(c, http.StatusBadRequest, fmt.Sprintf("Explicit context caching is not available for model %s: storage pricing is not configured", record.Model)) + googlePlatformError(c, http.StatusBadRequest, fmt.Sprintf("Explicit context caching is not available for model %s: storage pricing is not configured", record.Model)) return } if !h.checkGeminiCachedContentBilling(c, reqLog, apiKey, subscription) { @@ -722,11 +722,11 @@ func (h *GatewayHandler) reserveGeminiCachedContentCharge(c *gin.Context, apiKey } func writeGeminiCachedContentBillingError(c *gin.Context, err error) { - status, _, message, retryAfter := billingErrorDetails(err) + status, code, message, retryAfter := billingErrorDetails(err) if retryAfter > 0 { c.Header("Retry-After", strconv.Itoa(retryAfter)) } - googleError(c, status, message) + googleErrorWithType(c, status, code, "", message) } // writeGeminiCachedContentUpstreamError 透传上游错误:缓存不存在统一返回标准 403(各上游形态不同且可能含上游资源 ID); @@ -736,6 +736,7 @@ func writeGeminiCachedContentUpstreamError(c *gin.Context, upErr *service.Gemini message := strings.TrimSpace(extractGoogleErrorMessage(upErr.Body)) service.SetOpsUpstreamError(c, status, message, "") if upErr.NotFound() { + service.MarkOpsRequestScopedError(c, "permission_error") c.Data(http.StatusForbidden, "application/json", []byte(service.GeminiCachedContentNotFoundResponse)) return } diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 18ac84084f..63e944cb5e 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -388,11 +388,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { // 2) billing eligibility check (after wait) if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { reqLog.Info("gemini.billing_eligibility_check_failed", zap.Error(err)) - status, _, message, retryAfter := billingErrorDetails(err) + status, code, message, retryAfter := billingErrorDetails(err) if retryAfter > 0 { c.Header("Retry-After", strconv.Itoa(retryAfter)) } - googleError(c, status, message) + googleErrorWithType(c, status, code, "", message) return } @@ -400,11 +400,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(modelName, body)) if err != nil { reqLog.Info("gemini.inflight_reservation_rejected", zap.Error(err)) - status, _, message, retryAfter := billingErrorDetails(err) + status, code, message, retryAfter := billingErrorDetails(err) if retryAfter > 0 { c.Header("Retry-After", strconv.Itoa(retryAfter)) } - googleError(c, status, message) + googleErrorWithType(c, status, code, "", message) return } defer inflightRelease() @@ -861,7 +861,8 @@ func (h *GatewayHandler) handleGeminiFailoverExhausted(c *gin.Context, failoverE c.Set(service.OpsSkipPassthroughKey, true) } - googleError(c, respCode, msg) + // 按透传规则改写的是上游错误,不登记本地错误类型。 + googleErrorWithType(c, respCode, "", "", msg) return } } @@ -896,7 +897,21 @@ type pathParseError struct{ msg string } func (e *pathParseError) Error() string { return e.msg } +// googleError 以 Google 错误格式回写网关产生的错误,并按状态码向 ops 登记错误类型: +// 4xx 为客户端请求问题,其余状态码不登记(按平台错误统计)。 func googleError(c *gin.Context, status int, message string) { + googleErrorWithType(c, status, googleClientErrorType(status), "", message) +} + +// googlePlatformError 以 Google 错误格式回写由平台侧原因(如管理员未完成配置)导致的错误,ops 按平台错误统计。 +func googlePlatformError(c *gin.Context, status int, message string) { + googleErrorWithType(c, status, "", "", message) +} + +// googleErrorWithType 以 Google 错误格式回写错误,并向 ops 登记错误类型与错误码(Google 错误体没有 type 字段), +// 与 Claude / OpenAI 格式错误同口径分类;errType 为空时不登记。 +func googleErrorWithType(c *gin.Context, status int, errType, code, message string) { + service.SetOpsLocalErrorType(c, errType, code) c.JSON(status, gin.H{ "error": gin.H{ "code": status, @@ -906,11 +921,27 @@ func googleError(c *gin.Context, status int, message string) { }) } +// googleClientErrorType 返回客户端请求类 4xx 状态码对应的 ops 错误类型;其余状态码返回空串。 +func googleClientErrorType(status int) string { + switch status { + case http.StatusBadRequest, http.StatusRequestEntityTooLarge: + return "invalid_request_error" + case http.StatusUnauthorized: + return "authentication_error" + case http.StatusForbidden: + return "permission_error" + case http.StatusNotFound: + return "not_found_error" + default: + return "" + } +} + // googleConcurrencyError 以 Google 错误格式回写并发槽获取失败,状态码与文案 // 沿用 concurrencyErrorResponse 的统一映射(客户端断开为 499)。 func googleConcurrencyError(c *gin.Context, err error, slotType string) { - status, _, _, message := concurrencyErrorResponse(err, slotType) - googleError(c, status, message) + status, errType, code, message := concurrencyErrorResponse(err, slotType) + googleErrorWithType(c, status, errType, code, message) } func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) { diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index a4e30814ca..679cf4e8a2 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -1183,6 +1183,14 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } } + // Google 格式错误体没有 type 字段:按网关回写时登记的错误类型补齐。 + if errType, code := service.OpsLocalErrorType(c); errType != "" && !parsed.StreamFailure && + (parsed.ErrorType == "" || parsed.ErrorType == "api_error") { + parsed.ErrorType = errType + if code != "" { + parsed.Code = code + } + } normalizedType := normalizeOpsErrorType(parsed.ErrorType, parsed.Code) phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, normalizedType, parsed.Message, parsed.Code, status) @@ -2214,6 +2222,11 @@ func classifyOpsSeverity(errType string, status int) string { func classifyOpsErrorLog(c *gin.Context, errType, message, code string, status int) (phase string, isBusinessLimited bool, errorOwner string, errorSource string) { phase = classifyOpsPhase(errType, message, code) + if service.IsOpsRequestScopedError(c) { + // 失败由请求内容决定:只按错误类型分类,上游错误上下文不参与归因。 + isBusinessLimited = classifyOpsIsBusinessLimited(errType, phase, code, status, message) + return phase, isBusinessLimited, classifyOpsErrorOwner(phase, message), classifyOpsErrorSource(phase, message) + } routingCapacityLimited := isOpsRoutingCapacityLimited(c) clientBusinessLimited := service.HasOpsClientBusinessLimited(c) localModelConfiguration := clientBusinessLimited && service.OpsClientBusinessLimitedReason(c) == service.OpsClientBusinessLimitedReasonLocalModelConfiguration diff --git a/backend/internal/handler/ops_error_logger_google_test.go b/backend/internal/handler/ops_error_logger_google_test.go new file mode 100644 index 0000000000..392421dffd --- /dev/null +++ b/backend/internal/handler/ops_error_logger_google_test.go @@ -0,0 +1,135 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// recordGoogleFormatOpsError 让 handle 以 Google 错误格式回写错误,返回 ops 中间件记录的错误日志。 +func recordGoogleFormatOpsError(t *testing.T, handle gin.HandlerFunc) *service.OpsInsertErrorLogInput { + t.Helper() + setupOpsErrorLogTestQueue(t, 1) + gin.SetMode(gin.TestMode) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1beta/cachedContents", handle) + + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1beta/cachedContents", nil)) + + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + return (<-opsErrorLogQueue).entry +} + +func TestOpsErrorLoggerMiddleware_GoogleClientErrorsClassifiedAsClient(t *testing.T) { + for _, tc := range []struct { + status int + errType string + phase string + }{ + {status: http.StatusBadRequest, errType: "invalid_request_error", phase: "request"}, + {status: http.StatusRequestEntityTooLarge, errType: "invalid_request_error", phase: "request"}, + {status: http.StatusUnauthorized, errType: "authentication_error", phase: "auth"}, + {status: http.StatusForbidden, errType: "permission_error", phase: "request"}, + {status: http.StatusNotFound, errType: "not_found_error", phase: "request"}, + } { + t.Run(http.StatusText(tc.status), func(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + googleError(c, tc.status, "client request problem") + }) + require.Equal(t, tc.status, entry.StatusCode) + require.Equal(t, tc.errType, entry.ErrorType) + require.Equal(t, tc.phase, entry.ErrorPhase) + require.Equal(t, "client", entry.ErrorOwner) + require.Equal(t, "client_request", entry.ErrorSource) + require.Equal(t, "P3", entry.Severity) + }) + } +} + +func TestOpsErrorLoggerMiddleware_GooglePlatformErrorStaysPlatform(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + googlePlatformError(c, http.StatusBadRequest, "Explicit context caching is not available for model m: storage pricing is not configured") + }) + require.Equal(t, "api_error", entry.ErrorType) + require.Equal(t, "internal", entry.ErrorPhase) + require.Equal(t, "platform", entry.ErrorOwner) + require.Equal(t, "gateway", entry.ErrorSource) +} + +func TestOpsErrorLoggerMiddleware_GoogleServerErrorsNotTypedByStatus(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + googleError(c, http.StatusInternalServerError, "Failed to persist cached content") + }) + require.Equal(t, "api_error", entry.ErrorType) + require.Equal(t, "platform", entry.ErrorOwner) +} + +func TestOpsErrorLoggerMiddleware_GoogleBillingErrorMatchesClaudeFormat(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + status, code, message, _ := billingErrorDetails(service.ErrInsufficientBalance) + googleErrorWithType(c, status, code, "", message) + }) + require.Equal(t, "billing_error", entry.ErrorType) + require.Equal(t, "request", entry.ErrorPhase) + require.Equal(t, "client", entry.ErrorOwner) + require.True(t, entry.IsBusinessLimited) +} + +func TestOpsErrorLoggerMiddleware_GoogleConcurrencyErrorClassifiedAsRequest(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + googleConcurrencyError(c, &ConcurrencyError{SlotType: "user"}, "user") + }) + require.Equal(t, http.StatusTooManyRequests, entry.StatusCode) + require.Equal(t, "rate_limit_error", entry.ErrorType) + require.Equal(t, "request", entry.ErrorPhase) + require.Equal(t, "client", entry.ErrorOwner) +} + +func TestOpsErrorLoggerMiddleware_GoogleErrorAfterUpstreamFailureKeepsUpstreamAttribution(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + service.SetOpsUpstreamError(c, http.StatusBadRequest, "upstream rejected", "") + googleError(c, http.StatusBadRequest, "upstream rejected") + }) + require.Equal(t, "upstream", entry.ErrorPhase) + require.Equal(t, "provider", entry.ErrorOwner) + require.Equal(t, "upstream_http", entry.ErrorSource) +} + +func TestOpsErrorLoggerMiddleware_RequestScopedUpstreamErrorClassifiedAsClient(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + service.SetOpsUpstreamError(c, http.StatusBadRequest, "Invalid resource state for cache content 8079767076522164224.", "") + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{ + Platform: service.PlatformGemini, AccountID: 173, UpstreamStatusCode: http.StatusBadRequest, Kind: "http_error", + Message: "Invalid resource state for cache content 8079767076522164224.", + }}) + service.MarkOpsRequestScopedError(c, "permission_error") + c.Data(http.StatusForbidden, "application/json", []byte(service.GeminiCachedContentNotFoundResponse)) + }) + require.Equal(t, http.StatusForbidden, entry.StatusCode) + require.Equal(t, "permission_error", entry.ErrorType) + require.Equal(t, "request", entry.ErrorPhase) + require.Equal(t, "client", entry.ErrorOwner) + require.Equal(t, "client_request", entry.ErrorSource) + require.False(t, entry.IsBusinessLimited) + require.NotNil(t, entry.UpstreamStatusCode, "上游原始错误保留为明细") + require.Equal(t, http.StatusBadRequest, *entry.UpstreamStatusCode) +} + +func TestSetOpsLocalErrorTypeClearsRequestScopedMark(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + service.MarkOpsRequestScopedError(c, "permission_error") + require.True(t, service.IsOpsRequestScopedError(c)) + + service.SetOpsLocalErrorType(c, "", "") + require.False(t, service.IsOpsRequestScopedError(c)) + errType, code := service.OpsLocalErrorType(c) + require.Empty(t, errType) + require.Empty(t, code) +} diff --git a/backend/internal/handler/security_audit_errors.go b/backend/internal/handler/security_audit_errors.go index b6f42c7111..7c55e9bfde 100644 --- a/backend/internal/handler/security_audit_errors.go +++ b/backend/internal/handler/security_audit_errors.go @@ -100,9 +100,14 @@ func googleSecurityAuditError(c *gin.Context, decision *securityaudit.Decision) return } if decision.Legacy != nil && decision.Legacy.Blocked { - googleError(c, securityAuditStatus(decision), securityAuditMessage(decision)) + googleErrorWithType(c, securityAuditStatus(decision), securityAuditErrorCode(decision), "", securityAuditMessage(decision)) return } + errType := "api_error" + if decision.Kind == securityaudit.DecisionBlock { + errType = "permission_error" + } + service.SetOpsLocalErrorType(c, errType, securityAuditErrorCode(decision)) status := securityAuditStatus(decision) googleStatus := googleapi.HTTPStatusToGoogleStatus(status) if status == http.StatusServiceUnavailable { diff --git a/backend/internal/service/gemini_cached_content_not_found_test.go b/backend/internal/service/gemini_cached_content_not_found_test.go index 783ba71c72..fc4daa1181 100644 --- a/backend/internal/service/gemini_cached_content_not_found_test.go +++ b/backend/internal/service/gemini_cached_content_not_found_test.go @@ -75,6 +75,7 @@ func TestGeminiForwardNative_CachedContentNotFoundPassthroughWithoutAccountPenal require.Equal(t, 1, httpStub.calls, "不应重试") require.Equal(t, http.StatusForbidden, rec.Code) require.Equal(t, GeminiCachedContentNotFoundResponse, rec.Body.String(), "应返回标准缓存不存在响应") + require.True(t, IsOpsRequestScopedError(c), "缓存不存在按客户端请求错误统计") }) } } @@ -177,6 +178,9 @@ func TestGeminiForwardNative_VertexCachedContentNotFoundReturnsStandardForbidden require.Equal(t, http.StatusForbidden, rec.Code) require.Equal(t, GeminiCachedContentNotFoundResponse, rec.Body.String()) require.NotContains(t, rec.Body.String(), "8079767076522164224", "不应暴露上游缓存 ID") + require.True(t, IsOpsRequestScopedError(c), "缓存不存在按客户端请求错误统计") + errType, _ := OpsLocalErrorType(c) + require.Equal(t, "permission_error", errType) }) } } diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 60421d1f7f..c3edbd2fd4 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -1834,10 +1834,11 @@ func (s *GeminiMessagesCompatService) writeGeminiNativeUpstreamError(c *gin.Cont } // writeGeminiCachedContentNotFound 记录上游原始错误,并向客户端统一返回缓存不存在的 403: -// 各上游的响应形态不同,且 Vertex 的文案含上游缓存资源 ID。 +// 各上游的响应形态不同,且 Vertex 的文案含上游缓存资源 ID。失败由请求引用的缓存决定,ops 按客户端请求错误统计。 func (s *GeminiMessagesCompatService) writeGeminiCachedContentNotFound(c *gin.Context, account *Account, resp *http.Response, respBody []byte, requestID string, isOAuth bool) error { respBody = unwrapIfNeeded(isOAuth, respBody) upstreamMsg := s.recordGeminiNativeUpstreamError(c, account, resp.StatusCode, respBody, requestID) + MarkOpsRequestScopedError(c, "permission_error") MarkResponseCommitted(c) c.Data(http.StatusForbidden, "application/json", []byte(GeminiCachedContentNotFoundResponse)) return fmt.Errorf("gemini upstream error: %d message=%s", resp.StatusCode, upstreamMsg) diff --git a/backend/internal/service/ops_upstream_context.go b/backend/internal/service/ops_upstream_context.go index 4d09a43ae6..a8d0625fd8 100644 --- a/backend/internal/service/ops_upstream_context.go +++ b/backend/internal/service/ops_upstream_context.go @@ -52,6 +52,14 @@ const ( // ensureForwardErrorResponse 检查此 key,为 true 时跳过兜底写入,避免在已完成的 JSON 后追加 SSE。 ResponseCommittedKey = "response_committed" + // OpsLocalErrorTypeKey / OpsLocalErrorCodeKey 记录网关以 Google 错误格式回写的错误类型与错误码: + // Google 错误体没有 type 字段,ops_error_logger 据此按与 Claude / OpenAI 格式错误相同的口径分类。 + OpsLocalErrorTypeKey = "ops_local_error_type" + OpsLocalErrorCodeKey = "ops_local_error_code" + // OpsRequestScopedErrorKey 标记失败由请求内容决定(如引用的显式缓存不存在):只按错误类型分类, + // 上游错误上下文保留为明细但不参与归因。 + OpsRequestScopedErrorKey = "ops_request_scoped_error" + OpsClientBusinessLimitedKey = "ops_client_business_limited" OpsClientBusinessLimitedReasonKey = "ops_client_business_limited_reason" OpsClientBusinessLimitedReasonIPRestriction = "api_key_ip_restriction" @@ -134,6 +142,42 @@ func OpsClientBusinessLimitedReason(c *gin.Context) string { return strings.TrimSpace(reason) } +// SetOpsLocalErrorType 登记响应错误体缺少的错误类型与错误码,并清除请求级失败标记;errType 为空表示不登记。 +func SetOpsLocalErrorType(c *gin.Context, errType, code string) { + if c == nil { + return + } + c.Set(OpsLocalErrorTypeKey, strings.TrimSpace(errType)) + c.Set(OpsLocalErrorCodeKey, strings.TrimSpace(code)) + c.Set(OpsRequestScopedErrorKey, false) +} + +// OpsLocalErrorType 返回 SetOpsLocalErrorType 登记的错误类型与错误码;未登记时返回空串。 +func OpsLocalErrorType(c *gin.Context) (errType, code string) { + if c == nil { + return "", "" + } + errType = c.GetString(OpsLocalErrorTypeKey) + if errType == "" { + return "", "" + } + return errType, c.GetString(OpsLocalErrorCodeKey) +} + +// MarkOpsRequestScopedError 登记错误类型,并标记失败由请求内容决定:上游错误上下文不参与 ops 归因。 +func MarkOpsRequestScopedError(c *gin.Context, errType string) { + if c == nil { + return + } + SetOpsLocalErrorType(c, errType, "") + c.Set(OpsRequestScopedErrorKey, true) +} + +// IsOpsRequestScopedError 判断 MarkOpsRequestScopedError 是否标记了本次请求的失败。 +func IsOpsRequestScopedError(c *gin.Context) bool { + return c != nil && c.GetBool(OpsRequestScopedErrorKey) +} + // OpsStreamError 描述承载在 2xx 响应上的带内错误:网关在响应状态已固化为 200 之后 // 就地以 SSE error 帧返回的错误(并发限流回退、Wait 后二次计费校验失败、流开始后才无 // 可用账号等),以及上游 2xx 正文或事件里携带的错误结果(NonStream 标记非流式正文)。