Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
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
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
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user