Files
sub2api/backend/internal/service/grok_upstream_errors.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

246 lines
6.8 KiB
Go

package service
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
)
// isGrokContentPolicyRejection identifies request-scoped safety refusals from
// xAI. These failures are caused by the prompt or media, so retrying another
// OAuth account cannot change the outcome and would incorrectly drain a pool.
// Keep this matcher deliberately narrow: account entitlement and suspension
// messages may mention policy but must retain the normal account failover path.
func isGrokContentPolicyRejection(statusCode int, responseBody []byte) bool {
if statusCode != http.StatusForbidden || len(responseBody) == 0 {
return false
}
if grokAccountAccessMessage(string(responseBody)) {
return false
}
var payload any
if json.Unmarshal(responseBody, &payload) == nil {
if grokStructuredAccountAccessMarker(payload) {
return false
}
if grokStructuredContentPolicyMarker(payload) {
return true
}
}
return grokContentPolicyMessage(string(responseBody))
}
func grokStructuredAccountAccessMarker(value any) bool {
switch node := value.(type) {
case map[string]any:
for key, child := range node {
normalizedKey := normalizeGrokErrorMarker(key)
switch normalizedKey {
case "code", "error_code", "type", "category", "reason":
if marker, ok := child.(string); ok && isGrokAccountAccessCode(marker) {
return true
}
}
if grokStructuredAccountAccessMarker(child) {
return true
}
}
case []any:
for _, child := range node {
if grokStructuredAccountAccessMarker(child) {
return true
}
}
}
return false
}
func grokStructuredContentPolicyMarker(value any) bool {
switch node := value.(type) {
case map[string]any:
for key, child := range node {
normalizedKey := normalizeGrokErrorMarker(key)
switch normalizedKey {
case "code", "error_code", "type", "category", "reason":
if marker, ok := child.(string); ok && isGrokContentPolicyCode(marker) {
return true
}
}
if grokStructuredContentPolicyMarker(child) {
return true
}
}
case []any:
for _, child := range node {
if grokStructuredContentPolicyMarker(child) {
return true
}
}
}
return false
}
func normalizeGrokErrorMarker(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, "-", "_")
value = strings.ReplaceAll(value, " ", "_")
return value
}
func isGrokContentPolicyCode(value string) bool {
switch normalizeGrokErrorMarker(value) {
case "content_filter",
"content_policy",
"content_policy_violation",
"content_moderation",
"cyber_policy",
"new_sensitive":
return true
default:
return false
}
}
func isGrokAccountAccessCode(value string) bool {
switch normalizeGrokErrorMarker(value) {
case "account_suspended",
"account_disabled",
"user_suspended",
"user_disabled",
"subscription_required",
"entitlement_required",
"not_entitled",
"plan_required",
"permission_denied":
return true
default:
return false
}
}
func grokAccountAccessMessage(value string) bool {
lower := strings.ToLower(strings.TrimSpace(value))
for _, phrase := range []string{
"account suspended",
"account has been suspended",
"account disabled",
"account has been disabled",
"user suspended",
"user has been suspended",
"subscription required",
"entitlement required",
"not entitled",
} {
if strings.Contains(lower, phrase) {
return true
}
}
return false
}
func grokContentPolicyMessage(value string) bool {
lower := strings.ToLower(strings.TrimSpace(value))
if lower == "" {
return false
}
// xAI's media safety responses use these exact phrases. They are specific
// enough not to classify a generic account-policy or entitlement message.
for _, phrase := range []string{
"the moderation feature is not available",
"image is sensitive",
"text is sensitive",
"prohibited content",
"forbidden content",
"content policy violation",
"content policy rejection",
"content policy rejected",
"content moderation rejection",
"content moderation rejected",
"content moderation blocked",
"request blocked by content moderation",
"request rejected by content moderation",
"request blocked by policy",
"request rejected by policy",
"request violates policy",
"prompt violates content policy",
"prompt violates policy",
"input violates content policy",
"input violates policy",
} {
if strings.Contains(lower, phrase) {
return true
}
}
return false
}
func grokContentPolicyClientMessage(responseBody []byte) string {
message := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(responseBody)))
if message == "" {
return "Request blocked by upstream content policy"
}
return message
}
// shouldFailoverGrokUpstreamError is the body-aware counterpart of the
// status-only failover helper. Grok content refusals must stay on the current
// account and be returned to the caller instead of consuming the account pool.
// Free-usage / empty-output / billing bodies also failover even when the HTTP
// status alone would not (e.g. 400 with free-usage-exhausted).
func (s *OpenAIGatewayService) shouldFailoverGrokUpstreamError(statusCode int, responseBody []byte) bool {
if isGrokContentPolicyRejection(statusCode, responseBody) {
return false
}
decision := classifyGrokUpstreamFailure(statusCode, responseBody, "")
switch decision.Class {
case GrokFailureFreeUsage, GrokFailureEmptyUpstream, GrokFailureBilling, GrokFailureModelCapacity:
return decision.ShouldFailover
}
return s.shouldFailoverUpstreamError(statusCode)
}
// applyGrokForbiddenPolicy applies an administrator's existing temporary
// unschedulable rules to a non-content 403. It reports true only when a rule
// matched; unmatched responses retain the legacy entitlement cooldown.
func (s *OpenAIGatewayService) applyGrokForbiddenPolicy(ctx context.Context, account *Account, responseBody []byte) bool {
if account == nil || !account.IsTempUnschedulableEnabled() {
return false
}
matches := matchTempUnschedulableRules(account, http.StatusForbidden, responseBody)
if len(matches) == 0 {
return false
}
match := matches[0]
// Reuse the central policy implementation when it has a repository. This
// preserves the existing reason/cache format and avoids duplicating writes.
if s != nil && s.rateLimitService != nil && s.rateLimitService.accountRepo != nil {
stateCtx, cancel := openAIAccountStateContext(ctx)
handled := s.rateLimitService.tryTempUnschedulable(
stateCtx,
account,
http.StatusForbidden,
responseBody,
)
cancel()
if handled {
return true
}
}
// A partially constructed service (for example a unit-test gateway) still
// honors the configured duration instead of silently falling back to 30m.
cooldown := time.Duration(match.rule.DurationMinutes) * time.Minute
if cooldown > 0 {
s.tempUnscheduleGrok(ctx, account, cooldown, "grok configured forbidden rule")
}
return true
}