diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 161df133..168fc271 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -53,6 +53,8 @@ const ( opsCodeUserNotFound = "USER_NOT_FOUND" opsCodeAPIKeyQuotaExhausted = "API_KEY_QUOTA_EXHAUSTED" opsCodeAPIKeyQueryDeprecated = "api_key_in_query_deprecated" + opsCodeGroupDeleted = "GROUP_DELETED" + opsCodeGroupDisabled = "GROUP_DISABLED" ) const ( @@ -1012,6 +1014,8 @@ func parseOpsErrorResponse(body []byte) parsedOpsError { var code string if v, ok := errObj["code"]; ok { switch n := v.(type) { + case string: + code = strings.TrimSpace(n) case float64: code = strconvItoa(int(n)) case int: @@ -1188,14 +1192,19 @@ func isOpsClientAuthError(code string, msg string) bool { opsCodeAPIKeyExpired, opsCodeAPIKeyDisabled, opsCodeUserNotFound, - opsCodeUserInactive: + opsCodeUserInactive, + opsCodeGroupDeleted, + opsCodeGroupDisabled: return true } return strings.Contains(msg, "invalid api key") || strings.Contains(msg, "api key is required") || strings.Contains(msg, "api key is disabled") || strings.Contains(msg, "user associated with api key not found") || - strings.Contains(msg, "user account is not active") + strings.Contains(msg, "user account is not active") || + strings.Contains(msg, "api key 所属分组已删除") || + strings.Contains(msg, "api key 所属分组已停用") || + strings.Contains(msg, "api key is not assigned to any group") } func isOpsLocalBusinessLimitError(code string, msg string) bool { @@ -1211,6 +1220,7 @@ func isOpsLocalBusinessLimitError(code string, msg string) bool { return strings.Contains(msg, "api key in query parameter is deprecated") || strings.Contains(msg, "query parameter api_key is deprecated") || strings.Contains(msg, "no active subscription found for this group") || + strings.Contains(msg, "subscription is invalid or expired") || strings.Contains(msg, opsErrInsufficientBalance) || strings.Contains(msg, "insufficient account balance") || strings.Contains(msg, "api key group platform is not gemini") || @@ -1221,7 +1231,22 @@ func isOpsLocalBusinessLimitError(code string, msg string) bool { strings.Contains(msg, "daily usage limit exceeded") || strings.Contains(msg, "weekly usage limit exceeded") || strings.Contains(msg, "monthly usage limit exceeded") || - strings.Contains(msg, "requests-per-minute limit exceeded") + strings.Contains(msg, "usage quota exhausted for this platform") || + strings.Contains(msg, "requests-per-minute limit exceeded") || + strings.Contains(msg, "too many pending requests") || + strings.Contains(msg, "concurrency limit exceeded") || + strings.Contains(msg, "image generation concurrency limit exceeded") || + strings.Contains(msg, "this group is restricted to claude code clients") || + strings.Contains(msg, "this group does not allow /v1/messages dispatch") || + strings.Contains(msg, "image generation is not enabled for this group") || + strings.Contains(msg, "token counting is not supported for this platform") || + strings.Contains(msg, "images api is not supported for this platform") || + (strings.Contains(msg, "model ") && strings.Contains(msg, " not in whitelist")) || + (strings.Contains(msg, "beta feature ") && strings.Contains(msg, " is not allowed")) || + (strings.Contains(msg, "openai service_tier=") && strings.Contains(msg, " is not allowed for model")) || + strings.Contains(msg, "this account only allows codex official clients") || + strings.Contains(msg, "openai wsv1 is temporarily unsupported") || + strings.Contains(msg, "openai codex passthrough requires a non-empty instructions field") } func hasOpsUpstreamErrorContext(c *gin.Context) bool { diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index 81f12b0c..d4e1177e 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -288,6 +288,34 @@ func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) { code: "USER_INACTIVE", status: http.StatusUnauthorized, }, + { + name: "deleted local API key group", + errType: "api_error", + message: "API Key 所属分组已删除", + code: "GROUP_DELETED", + status: http.StatusForbidden, + }, + { + name: "disabled local API key group", + errType: "api_error", + message: "API Key 所属分组已停用", + code: "GROUP_DISABLED", + status: http.StatusForbidden, + }, + { + name: "google deleted API key group message without semantic code", + errType: "api_error", + message: "API Key 所属分组已删除", + code: "403", + status: http.StatusForbidden, + }, + { + name: "anthropic unassigned API key group", + errType: "permission_error", + message: "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.", + code: "", + status: http.StatusForbidden, + }, { name: "google invalid API key", errType: "api_error", @@ -389,6 +417,15 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) { wantErrType: "api_error", wantPhase: "request", }, + { + name: "gateway subscription invalid cache recheck", + errType: "billing_error", + message: "subscription is invalid or expired", + code: "billing_error", + status: http.StatusForbidden, + wantErrType: "billing_error", + wantPhase: "request", + }, { name: "google insufficient account balance", errType: "api_error", @@ -443,6 +480,132 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) { wantErrType: "api_error", wantPhase: "request", }, + { + name: "user platform daily quota exhausted", + errType: "api_error", + message: "Daily usage quota exhausted for this platform.", + code: "rate_limit_exceeded", + status: http.StatusTooManyRequests, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "local pending queue limit", + errType: "rate_limit_error", + message: "Too many pending requests, please retry later", + code: "", + status: http.StatusTooManyRequests, + wantErrType: "rate_limit_error", + wantPhase: "request", + }, + { + name: "local concurrency limit", + errType: "rate_limit_error", + message: "Concurrency limit exceeded for user, please retry later", + code: "", + status: http.StatusTooManyRequests, + wantErrType: "rate_limit_error", + wantPhase: "request", + }, + { + name: "group claude code only feature gate", + errType: "permission_error", + message: "This group is restricted to Claude Code clients (/v1/messages only)", + code: "", + status: http.StatusForbidden, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "group image generation feature gate", + errType: "permission_error", + message: "Image generation is not enabled for this group", + code: "", + status: http.StatusForbidden, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "route token counting platform unsupported", + errType: "not_found_error", + message: "Token counting is not supported for this platform", + code: "", + status: http.StatusNotFound, + wantErrType: "not_found_error", + wantPhase: "request", + }, + { + name: "route images API platform unsupported", + errType: "not_found_error", + message: "Images API is not supported for this platform", + code: "", + status: http.StatusNotFound, + wantErrType: "not_found_error", + wantPhase: "request", + }, + { + name: "antigravity model whitelist feature gate", + errType: "permission_error", + message: "model claude-3-5-sonnet not in whitelist", + code: "", + status: http.StatusForbidden, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "google antigravity model whitelist feature gate", + errType: "api_error", + message: "model gemini-2.5-pro not in whitelist", + code: "403", + status: http.StatusForbidden, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "claude beta policy block", + errType: "invalid_request_error", + message: "beta feature interleaved-thinking-2025-05-14 is not allowed", + code: "", + status: http.StatusBadRequest, + wantErrType: "invalid_request_error", + wantPhase: "request", + }, + { + name: "openai fast policy block", + errType: "permission_error", + message: "openai service_tier=priority is not allowed for model gpt-5.5", + code: "", + status: http.StatusForbidden, + wantErrType: "api_error", + wantPhase: "request", + }, + { + name: "codex official client policy block", + errType: "forbidden_error", + message: "This account only allows Codex official clients", + code: "", + status: http.StatusForbidden, + wantErrType: "forbidden_error", + wantPhase: "request", + }, + { + name: "openai wsv1 unsupported feature gate", + errType: "invalid_request_error", + message: "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.", + code: "", + status: http.StatusBadRequest, + wantErrType: "invalid_request_error", + wantPhase: "request", + }, + { + name: "openai passthrough instructions policy block", + errType: "forbidden_error", + message: "OpenAI codex passthrough requires a non-empty instructions field", + code: "", + status: http.StatusForbidden, + wantErrType: "forbidden_error", + wantPhase: "request", + }, } for _, tt := range tests { @@ -479,6 +642,22 @@ func TestClassifyOpsIPRestrictionAccessDeniedExcludedFromSLA(t *testing.T) { require.Equal(t, "client_request", errorSource) } +func TestClassifyOpsClientBusinessLimitedMarkerExcludesCustomPolicyDenialFromSLA(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied) + + errType := normalizeOpsErrorType("invalid_request_error", "") + phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, "custom admin policy message", "", http.StatusBadRequest) + + require.Equal(t, "invalid_request_error", errType) + require.Equal(t, "auth", phase) + require.True(t, isBusinessLimited) + require.Equal(t, "client", errorOwner) + require.Equal(t, "client_request", errorSource) +} + func TestClassifyOpsOtherErrorsStillCountForSLA(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() @@ -583,6 +762,78 @@ func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) { code: "API_KEY_QUOTA_EXHAUSTED", status: http.StatusTooManyRequests, }, + { + name: "provider deleted group shaped error", + message: "API Key 所属分组已删除", + code: "GROUP_DELETED", + status: http.StatusForbidden, + }, + { + name: "provider unassigned group shaped error", + message: "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.", + code: "403", + status: http.StatusForbidden, + }, + { + name: "provider local quota shaped error", + message: "Daily usage quota exhausted for this platform.", + code: "rate_limit_exceeded", + status: http.StatusTooManyRequests, + }, + { + name: "provider feature gate shaped error", + message: "Image generation is not enabled for this group", + code: "403", + status: http.StatusForbidden, + }, + { + name: "provider token counting unsupported shaped error", + message: "Token counting is not supported for this platform", + code: "404", + status: http.StatusNotFound, + }, + { + name: "provider image API unsupported shaped error", + message: "Images API is not supported for this platform", + code: "404", + status: http.StatusNotFound, + }, + { + name: "provider antigravity whitelist shaped error", + message: "model claude-3-5-sonnet not in whitelist", + code: "403", + status: http.StatusForbidden, + }, + { + name: "provider beta policy shaped error", + message: "beta feature interleaved-thinking-2025-05-14 is not allowed", + code: "400", + status: http.StatusBadRequest, + }, + { + name: "provider openai fast policy shaped error", + message: "openai service_tier=priority is not allowed for model gpt-5.5", + code: "403", + status: http.StatusForbidden, + }, + { + name: "provider codex client policy shaped error", + message: "This account only allows Codex official clients", + code: "403", + status: http.StatusForbidden, + }, + { + name: "provider wsv1 unsupported shaped error", + message: "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.", + code: "400", + status: http.StatusBadRequest, + }, + { + name: "provider passthrough instructions shaped error", + message: "OpenAI codex passthrough requires a non-empty instructions field", + code: "403", + status: http.StatusForbidden, + }, } for _, tt := range tests { @@ -628,6 +879,14 @@ func TestClassifyOpsUpstreamNoAvailableTextStillCountsForSLA(t *testing.T) { require.Equal(t, "upstream_http", errorSource) } +func TestParseOpsErrorResponsePreservesNestedStringCode(t *testing.T) { + parsed := parseOpsErrorResponse([]byte(`{"error":{"type":"permission_error","code":"GROUP_DELETED","message":"API Key 所属分组已删除"}}`)) + + require.Equal(t, "permission_error", parsed.ErrorType) + require.Equal(t, "GROUP_DELETED", parsed.Code) + require.Equal(t, "API Key 所属分组已删除", parsed.Message) +} + func TestSetOpsEndpointContext_SetsContextKeys(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder()