From 953028718d590dc64a79899f29f46c7c44f012cd Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:02:40 -0700 Subject: [PATCH 01/28] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Grok=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=88=86=E7=B1=BB=E4=B8=8E=E5=AE=B9=E9=87=8F=E9=87=8D?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/failover_loop.go | 17 ++- backend/internal/service/gateway_service.go | 14 +-- .../internal/service/grok_upstream_errors.go | 2 +- .../internal/service/grok_upstream_failure.go | 102 +++++++++++++++++- .../service/grok_upstream_failure_test.go | 72 +++++++++++++ 5 files changed, 192 insertions(+), 15 deletions(-) diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go index edbe4e232a..669cfcf365 100644 --- a/backend/internal/handler/failover_loop.go +++ b/backend/internal/handler/failover_loop.go @@ -56,7 +56,13 @@ const ( const profitVetoExhaustedMessage = "No available accounts: all candidates rejected by group profit control" func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryCount int) time.Duration { - if failoverErr == nil || !failoverErr.RequestScopedTransient || retryCount <= 1 { + if failoverErr == nil { + return sameAccountRetryDelay + } + if failoverErr.SameAccountRetryDelay > 0 { + return failoverErr.SameAccountRetryDelay + } + if !failoverErr.RequestScopedTransient || retryCount <= 1 { return sameAccountRetryDelay } @@ -158,14 +164,19 @@ func (s *FailoverState) HandleFailoverError( } // 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。 - sameAccountRetry := failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit + retryCount := s.SameAccountRetryCount[accountID] + sameAccountRetryAllowed := failoverErr.RetryableOnSameAccount && retryLimit > 0 && retryCount < retryLimit + if sameAccountRetryAllowed && !failoverErr.SameAccountRetryDeadline.IsZero() { + sameAccountRetryAllowed = time.Now().Before(failoverErr.SameAccountRetryDeadline) + } + sameAccountRetry := sameAccountRetryAllowed if needForceCacheBilling(s.hasBoundSession, failoverErr, sameAccountRetry) { s.ForceCacheBilling = true } // 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试。 // 重试次数上限 retryLimit 由调用方传入(账号级 pool_mode_retry_count 配置)。 - if failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit { + if sameAccountRetryAllowed { s.SameAccountRetryCount[accountID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, s.SameAccountRetryCount[accountID]) logger.FromContext(ctx).Warn("gateway.failover_same_account_retry", diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 20e7e5b9e6..6e1ebecbbb 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -671,12 +671,14 @@ type GatewayFailureReason string // source-compatible and preserves their legacy retry-next-account behavior. type UpstreamFailoverError struct { StatusCode int - ResponseBody []byte // 上游响应体,用于错误透传规则匹配 - ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息 - ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true - RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换 - RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁 - SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号 + ResponseBody []byte // 上游响应体,用于错误透传规则匹配 + ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息 + ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true + RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换 + SameAccountRetryDelay time.Duration // 同账号重试的最小间隔;零值使用 handler 默认值 + SameAccountRetryDeadline time.Time // 同账号重试截止时间;零值表示仅受 retryLimit 限制 + RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁 + SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号 Stage GatewayFailureStage Scope GatewayFailureScope Reason GatewayFailureReason diff --git a/backend/internal/service/grok_upstream_errors.go b/backend/internal/service/grok_upstream_errors.go index 0039ea4cdc..1d1bddc6d6 100644 --- a/backend/internal/service/grok_upstream_errors.go +++ b/backend/internal/service/grok_upstream_errors.go @@ -199,7 +199,7 @@ func (s *OpenAIGatewayService) shouldFailoverGrokUpstreamError(statusCode int, r } decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") switch decision.Class { - case GrokFailureFreeUsage, GrokFailureEmptyUpstream, GrokFailureBilling, GrokFailureModelCapacity: + case GrokFailureFreeUsage, GrokFailureEmptyUpstream, GrokFailureBilling, GrokFailureModelCapacity, GrokFailureCompatibility: return decision.ShouldFailover } return s.shouldFailoverUpstreamError(statusCode) diff --git a/backend/internal/service/grok_upstream_failure.go b/backend/internal/service/grok_upstream_failure.go index e7bed08bf0..475c6cff16 100644 --- a/backend/internal/service/grok_upstream_failure.go +++ b/backend/internal/service/grok_upstream_failure.go @@ -26,6 +26,10 @@ const ( GrokFailureRateLimit GrokUpstreamFailureClass = "rate_limit" GrokFailureAuth GrokUpstreamFailureClass = "auth_error" GrokFailureServer GrokUpstreamFailureClass = "server_error" + // GrokFailureCompatibility represents a request-history/body-shape that is + // incompatible with the selected account or upstream replay contract. It + // is account-independent: fail over, but never quarantine the pool. + GrokFailureCompatibility GrokUpstreamFailureClass = "compatibility_error" ) // GrokUpstreamFailureDecision is a pure classification result. Callers map it @@ -111,6 +115,20 @@ func classifyGrokUpstreamFailure(statusCode int, responseBody []byte, requestedM } } + // Responses replay/compaction payloads can be rejected by one Grok + // deployment while the same request is valid on another account. Treat + // these precise decoder/content-shape failures as account compatibility + // errors, rather than durable account health failures. + if isGrokCompatibilityError(low, code) { + return GrokUpstreamFailureDecision{ + Class: GrokFailureCompatibility, + Model: model, + ShouldFailover: true, + ShouldCooldown: false, + Reason: firstNonEmpty(text, "grok response compatibility error"), + } + } + // Empty HTTP 200 / empty model output (often rewritten to synthetic 502). if isGrokEmptyModelOutputText(low) || isGrokEmptyModelOutputCode(code) { return GrokUpstreamFailureDecision{ @@ -355,7 +373,8 @@ func isGrokBillingQuotaText(low string) bool { if strings.Contains(low, "payment") && (strings.Contains(low, "required") || strings.Contains(low, "fail")) { return true } - if strings.Contains(low, "spending limit") || strings.Contains(low, "run out of credits") || strings.Contains(low, "out of credits") { + if strings.Contains(low, "spending limit") || strings.Contains(low, "run out of credits") || strings.Contains(low, "out of credits") || + (strings.Contains(low, "need a grok subscription") || strings.Contains(low, "need grok subscription")) { return true } if strings.Contains(low, "余额不足") || strings.Contains(low, "欠费") || strings.Contains(low, "需要付费") { @@ -364,6 +383,73 @@ func isGrokBillingQuotaText(low string) bool { return false } +// grokRetryableOnSameAccount marks transient 429 classes for the shared +// failover loop. Capacity and ordinary throttles are request/model pressure, +// not evidence that the credential is invalid, so a bounded retry on the same +// account is preferable before switching accounts. Free-usage and billing +// exhaustion deliberately skip same-account retry and fail over immediately. +func grokRetryableOnSameAccount(account *Account, statusCode int, responseBody []byte) bool { + if account == nil || !account.IsGrok() { + return false + } + decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") + switch decision.Class { + case GrokFailureFreeUsage, GrokFailureBilling, GrokFailureCompatibility: + // Quota/entitlement exhaustion is account state, not transient + // pressure. Retrying the same account only repeats the failure. + return false + case GrokFailureModelCapacity: + if statusCode == http.StatusTooManyRequests { + return true + } + } + return account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode) +} + +func grokSameAccountRetryMetadata(account *Account, statusCode int, responseBody []byte) (bool, time.Duration, time.Time) { + if !grokRetryableOnSameAccount(account, statusCode, responseBody) { + return false, 0, time.Time{} + } + decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") + if decision.Class != GrokFailureModelCapacity { + return true, 0, time.Time{} + } + return true, 500 * time.Millisecond, time.Now().Add(30 * time.Second) +} + +func isGrokCompatibilityError(low, code string) bool { + combined := strings.ToLower(strings.TrimSpace(low + " " + code)) + // Compaction blobs are account/session-bound and frequently fail with 400 + // or 422 after a reconnect. Also cover xAI's JSON decoder shape errors. + for _, phrase := range []string{ + "could not decode the compaction blob", + "cannot decode the compaction blob", + "decode the compaction blob", + "ensure it is unmodified from the compact response", + "compaction blob", + "failed to deserialize the json body", + "data did not match any variant", + "untagged enum content", + "invalid content shape", + "invalid response history", + } { + if strings.Contains(combined, phrase) { + return true + } + } + for _, marker := range []string{ + "invalid_compaction", + "compaction_decode_error", + "response_history_incompatible", + "invalid_content_shape", + } { + if strings.Contains(combined, marker) { + return true + } + } + return false +} + func isGrokModelCapacityText(low string) bool { return strings.Contains(low, "capacity") || strings.Contains(low, "overloaded") || @@ -489,10 +575,11 @@ func (s *OpenAIGatewayService) applyGrokUpstreamFailureDecision( case GrokFailureEmptyUpstream: reason = "grok empty model output" case GrokFailureModelCapacity: - if persistGrokTransientModelCooldown(account, decision) { - return true - } - reason = "grok model capacity" + // Capacity is scoped to the requested model. Never persist an account-wide + // unschedulable state for this transient class; the failover loop performs + // a bounded same-account retry before selecting another account. + _ = persistGrokTransientModelCooldown(account, decision) + return true case GrokFailureRateLimit: // Pure 429 without free-usage language keeps the existing rate-limit // snapshot path (Retry-After / quota headers). Body-only rate-limit @@ -501,6 +588,11 @@ func (s *OpenAIGatewayService) applyGrokUpstreamFailureDecision( return false case GrokFailureServer: reason = "grok upstream temporary error" + case GrokFailureCompatibility: + // Deliberately no account mutation. The caller uses ShouldFailover to + // retry another account; cooling a pool for a request-shape mismatch + // would remove healthy accounts. + return true default: return false } diff --git a/backend/internal/service/grok_upstream_failure_test.go b/backend/internal/service/grok_upstream_failure_test.go index 0bd1063577..358c1595d3 100644 --- a/backend/internal/service/grok_upstream_failure_test.go +++ b/backend/internal/service/grok_upstream_failure_test.go @@ -61,6 +61,44 @@ func TestClassifyGrokUpstreamFailure_Billing(t *testing.T) { require.True(t, d.ShouldFailover) } +func TestClassifyGrokUpstreamFailure_GrokSubscriptionRequiredIsBilling(t *testing.T) { + d := classifyGrokUpstreamFailure(http.StatusPaymentRequired, + []byte(`{"error":{"message":"You have run out of credits or need a Grok subscription"}}`), "grok-4.6") + require.Equal(t, GrokFailureBilling, d.Class) + require.True(t, d.ShouldFailover) + require.True(t, d.ShouldCooldown) +} + +func TestGrokRetryableOnSameAccount_CapacityAndRateLimit(t *testing.T) { + account := &Account{ID: 9105, Platform: PlatformGrok, Type: AccountTypeOAuth} + require.True(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests, + []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`))) + require.False(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests, + []byte(`{"error":{"message":"rate limit exceeded"}}`))) + require.False(t, grokRetryableOnSameAccount(account, http.StatusPaymentRequired, + []byte(`{"error":{"message":"You have run out of credits or need a Grok subscription"}}`))) + require.False(t, grokRetryableOnSameAccount(account, http.StatusBadRequest, + []byte(`{"error":{"message":"capacity field is invalid"}}`))) + nonGrok := &Account{ID: 9106, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + require.False(t, grokRetryableOnSameAccount(nonGrok, http.StatusTooManyRequests, + []byte(`{"error":{"message":"model at capacity"}}`))) +} + +func TestGrokSameAccountRetryMetadata_CapacityDeadline(t *testing.T) { + account := &Account{ID: 9107, Platform: PlatformGrok, Type: AccountTypeOAuth} + retryable, delay, deadline := grokSameAccountRetryMetadata(account, http.StatusTooManyRequests, + []byte(`{"error":{"message":"model capacity exceeded"}}`)) + require.True(t, retryable) + require.Equal(t, 500*time.Millisecond, delay) + require.WithinDuration(t, time.Now().Add(30*time.Second), deadline, 2*time.Second) + + retryable, delay, deadline = grokSameAccountRetryMetadata(account, http.StatusTooManyRequests, + []byte(`{"error":{"message":"rate limit exceeded"}}`)) + require.False(t, retryable) + require.Zero(t, delay) + require.True(t, deadline.IsZero()) +} + func TestClassifyGrokUpstreamFailure_ValidationNoCool(t *testing.T) { d := classifyGrokUpstreamFailure(http.StatusBadRequest, []byte(`{"error":{"message":"invalid tool schema"}}`), "") require.Equal(t, GrokFailureNone, d.Class) @@ -75,12 +113,33 @@ func TestClassifyGrokUpstreamFailure_FreeUsageWinsOver5xx(t *testing.T) { require.NotEqual(t, GrokFailureServer, d.Class) } +func TestClassifyGrokUpstreamFailure_CompatibilityDoesNotCooldown(t *testing.T) { + cases := []string{ + `{"error":{"message":"Could not decode the compaction blob. Ensure it is unmodified from the compact response"}}`, + `{"error":{"message":"data did not match any variant of the untagged enum content"}}`, + `{"code":"compaction_decode_error","message":"invalid response history"}`, + } + for _, body := range cases { + d := classifyGrokUpstreamFailure(http.StatusUnprocessableEntity, []byte(body), "grok-4.6") + require.Equal(t, GrokFailureCompatibility, d.Class, body) + require.True(t, d.ShouldFailover, body) + require.False(t, d.ShouldCooldown, body) + require.Zero(t, d.Cooldown, body) + } +} + func TestShouldFailoverGrokUpstreamError_FreeUsageBody(t *testing.T) { svc := &OpenAIGatewayService{} body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"free usage exhausted"}}`) require.True(t, svc.shouldFailoverGrokUpstreamError(http.StatusBadRequest, body)) } +func TestShouldFailoverGrokUpstreamError_CompatibilityBody(t *testing.T) { + svc := &OpenAIGatewayService{} + body := []byte(`{"error":{"message":"Could not decode the compaction blob"}}`) + require.True(t, svc.shouldFailoverGrokUpstreamError(http.StatusUnprocessableEntity, body)) +} + func TestShouldFailoverGrokUpstreamError_ContentPolicyStillNoFailover(t *testing.T) { svc := &OpenAIGatewayService{} body := []byte(`{"error":{"code":"new_sensitive","message":"text is sensitive"}}`) @@ -149,6 +208,19 @@ func TestHandleGrokAccountUpstreamError_MultiAgentCapacityBlocksOnlyThatModel(t require.False(t, isGrokModelQuotaBlocked(account.ID, "grok-4.5", time.Now())) } +func TestHandleGrokAccountUpstreamError_CapacityNeverCoolsAccount(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 9121, Platform: PlatformGrok, Type: AccountTypeOAuth} + ctx := withGrokTeamRateLimitModel(context.Background(), "grok-4.6") + + svc.handleGrokAccountUpstreamError(ctx, account, http.StatusTooManyRequests, nil, + []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`)) + + require.Zero(t, repo.tempUnschedCalls) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + func TestHandleGrokAccountUpstreamError_FreeUsageDoesNotCoolPoolMode(t *testing.T) { repo := &grokQuotaAccountRepo{} svc := &OpenAIGatewayService{accountRepo: repo} From 5ade094318b73450fb8d2db51ff7899131133884 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:02:49 -0700 Subject: [PATCH 02/28] =?UTF-8?q?=E4=BC=98=E5=8C=96=20Grok=20=E4=BC=A0?= =?UTF-8?q?=E8=BE=93=E8=B6=85=E6=97=B6=E4=B8=8E=20Realtime=20=E6=8F=A1?= =?UTF-8?q?=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/config/config.go | 7 ++++ backend/internal/handler/grok_audio.go | 18 +++++++-- backend/internal/repository/http_upstream.go | 20 +++++++--- backend/internal/service/grok_audio.go | 40 +++++++++++++++++++ backend/internal/service/grok_media.go | 12 ++++-- .../internal/service/http_upstream_profile.go | 3 +- 6 files changed, 85 insertions(+), 15 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0fa0058a5d..048f2d76e9 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -934,6 +934,9 @@ type GatewayConfig struct { // OpenAIResponseHeaderTimeout: OpenAI/Codex 上游等待响应头的超时时间(秒),0表示无超时 // OpenAI/Codex 请求可能在上游排队较久;默认不使用通用响应头超时截断。 OpenAIResponseHeaderTimeout int `mapstructure:"openai_response_header_timeout"` + // GrokResponseHeaderTimeout bounds the pre-first-byte wait for xAI/Grok. + // A zero value uses the provider-safe default instead of the generic gateway timeout. + GrokResponseHeaderTimeout int `mapstructure:"grok_response_header_timeout"` // OpenAIFirstOutputTimeoutSeconds: native HTTP Responses 首个语义输出超时(秒),0表示禁用。 OpenAIFirstOutputTimeoutSeconds int `mapstructure:"openai_first_output_timeout_seconds"` // OpenAIHighEffortFirstOutputTimeoutSeconds: high/xhigh/max 推理的首个语义输出超时(秒)。 @@ -2327,6 +2330,7 @@ func setDefaults() { // Gateway viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久 viper.SetDefault("gateway.openai_response_header_timeout", 0) + viper.SetDefault("gateway.grok_response_header_timeout", 120) viper.SetDefault("gateway.openai_first_output_timeout_seconds", 0) viper.SetDefault("gateway.openai_high_effort_first_output_timeout_seconds", 0) viper.SetDefault("gateway.log_upstream_error_body", true) @@ -3241,6 +3245,9 @@ func (c *Config) Validate() error { if c.Gateway.OpenAIResponseHeaderTimeout < 0 { return fmt.Errorf("gateway.openai_response_header_timeout must be non-negative") } + if c.Gateway.GrokResponseHeaderTimeout < 0 || c.Gateway.GrokResponseHeaderTimeout > 1800 { + return fmt.Errorf("gateway.grok_response_header_timeout must be between 0-1800 seconds") + } if c.Gateway.OpenAIFirstOutputTimeoutSeconds < 0 || c.Gateway.OpenAIFirstOutputTimeoutSeconds > 600 || (c.Gateway.OpenAIFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIFirstOutputTimeoutSeconds < 30) { return fmt.Errorf("gateway.openai_first_output_timeout_seconds must be 0 or between 30-600 seconds") diff --git a/backend/internal/handler/grok_audio.go b/backend/internal/handler/grok_audio.go index 4ac1ae53bf..a31ec802f6 100644 --- a/backend/internal/handler/grok_audio.go +++ b/backend/internal/handler/grok_audio.go @@ -77,6 +77,20 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok credential unavailable") return } + model := c.Query("model") + if strings.TrimSpace(model) == "" { + model = "grok-voice-latest" + } + // Complete the upstream handshake before sending HTTP 101. This keeps + // pre-accept failures representable as normal JSON errors to the client. + probeCtx, cancelProbe := context.WithTimeout(c.Request.Context(), 15*time.Second) + probeErr := h.gatewayService.ProbeGrokRealtime(probeCtx, selection.Account, token, model) + cancelProbe() + if probeErr != nil { + reqLog.Warn("grok_realtime.pre_accept_failed", zap.Error(probeErr)) + h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable") + return + } conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) if err != nil { @@ -84,10 +98,6 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { } defer func() { _ = conn.CloseNow() }() - model := c.Query("model") - if strings.TrimSpace(model) == "" { - model = "grok-voice-latest" - } started := time.Now() audioObserved, proxyErr := h.gatewayService.ProxyGrokRealtime(c.Request.Context(), c, conn, selection.Account, token, model) elapsed := time.Since(started) diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 904ff09eb9..43be1bc995 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -899,12 +899,20 @@ func (s *httpUpstreamService) resolvePoolSettings(isolation string, accountConcu } func (s *httpUpstreamService) applyProfilePoolSettings(settings poolSettings, profile service.HTTPUpstreamProfile) poolSettings { - if profile != service.HTTPUpstreamProfileOpenAI { - return settings - } - settings.responseHeaderTimeout = 0 - if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIResponseHeaderTimeout > 0 { - settings.responseHeaderTimeout = time.Duration(s.cfg.Gateway.OpenAIResponseHeaderTimeout) * time.Second + switch profile { + case service.HTTPUpstreamProfileOpenAI: + settings.responseHeaderTimeout = 0 + if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIResponseHeaderTimeout > 0 { + settings.responseHeaderTimeout = time.Duration(s.cfg.Gateway.OpenAIResponseHeaderTimeout) * time.Second + } + case service.HTTPUpstreamProfileGrok: + // Grok can stall before its first byte under capacity pressure. Keep the + // generic 600s gateway timeout from turning one request into a 10-minute + // resource hold; streaming after headers is unaffected. + settings.responseHeaderTimeout = 120 * time.Second + if s != nil && s.cfg != nil && s.cfg.Gateway.GrokResponseHeaderTimeout > 0 { + settings.responseHeaderTimeout = time.Duration(s.cfg.Gateway.GrokResponseHeaderTimeout) * time.Second + } } return settings } diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index 06c8410b8d..63a2e28b00 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -63,6 +63,7 @@ func (s *OpenAIGatewayService) ForwardGrokVoice(ctx context.Context, c *gin.Cont } upstreamCtx, release := detachUpstreamContext(ctx) defer release() + upstreamCtx = WithHTTPUpstreamProfile(upstreamCtx, HTTPUpstreamProfileGrok) method := http.MethodPost if c != nil && c.Request != nil && strings.TrimSpace(c.Request.Method) != "" { method = c.Request.Method @@ -207,6 +208,45 @@ func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Con return awaitGrokRealtimeAudioObserved(errCh, &audioObserved) } +// ProbeGrokRealtime performs the upstream WebSocket handshake without sending +// any client-visible events. Handlers use it before accepting the downstream +// upgrade so authentication and endpoint failures remain ordinary HTTP errors. +func (s *OpenAIGatewayService) ProbeGrokRealtime(ctx context.Context, account *Account, token, model string) error { + if s == nil || account == nil { + return fmt.Errorf("realtime service and account are required") + } + if account.Platform != PlatformGrok { + return fmt.Errorf("account platform %s is not supported for grok realtime", account.Platform) + } + base, err := buildGrokVoiceURL(account, s.cfg, "realtime") + if err != nil { + return err + } + u, err := url.Parse(base) + if err != nil { + return err + } + u.Scheme = "wss" + q := u.Query() + q.Set("model", firstNonEmpty(model, "grok-voice-latest")) + u.RawQuery = q.Encode() + headers := http.Header{"Authorization": []string{"Bearer " + token}} + if account.IsGrokOAuth() && isGrokCLIProxyTarget(u.String()) { + applyGrokCLIHeaders(headers) + } + account.ApplyHeaderOverrides(headers) + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + dialer := s.getOpenAIWSPassthroughDialer() + conn, _, _, err := dialer.Dial(ctx, u.String(), headers, proxyURL) + if err != nil { + return err + } + return conn.Close() +} + func awaitGrokRealtimeAudioObserved(errCh <-chan error, audioObserved *atomic.Bool) (bool, error) { err := <-errCh if audioObserved == nil { diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 2efb2332f6..2155f7eb99 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -658,6 +658,7 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( } upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) defer releaseUpstreamCtx() + upstreamCtx = WithHTTPUpstreamProfile(upstreamCtx, HTTPUpstreamProfileGrok) upstreamReq, err := http.NewRequestWithContext(upstreamCtx, endpoint.httpMethod(), targetURL, bodyReader) if err != nil { return nil, err @@ -1288,11 +1289,14 @@ func (s *OpenAIGatewayService) handleGrokMediaErrorResponse( Detail: upstreamDetail, }) if kind == "failover" { + retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, body) return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: body, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + StatusCode: resp.StatusCode, + ResponseBody: body, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, } } diff --git a/backend/internal/service/http_upstream_profile.go b/backend/internal/service/http_upstream_profile.go index 83bd6e70fc..b2de6002cf 100644 --- a/backend/internal/service/http_upstream_profile.go +++ b/backend/internal/service/http_upstream_profile.go @@ -9,6 +9,7 @@ type HTTPUpstreamProfile string const ( HTTPUpstreamProfileDefault HTTPUpstreamProfile = "" HTTPUpstreamProfileOpenAI HTTPUpstreamProfile = "openai" + HTTPUpstreamProfileGrok HTTPUpstreamProfile = "grok" ) type httpUpstreamProfileContextKey struct{} @@ -35,7 +36,7 @@ func HTTPUpstreamProfileFromContext(ctx context.Context) HTTPUpstreamProfile { return HTTPUpstreamProfileDefault } switch profile { - case HTTPUpstreamProfileOpenAI: + case HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileGrok: return profile default: return HTTPUpstreamProfileDefault From ed4207a16f42573adf8b3ccd22ebd82591d3db24 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:03:29 -0700 Subject: [PATCH 03/28] =?UTF-8?q?=E6=A0=A1=E6=AD=A3=20Grok=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=9B=AE=E5=BD=95=E8=AE=A1=E8=B4=B9=E4=B8=8E=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=87=BA=E7=AB=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/pkg/xai/models.go | 21 ++++++------ backend/internal/pkg/xai/models_test.go | 8 ++++- backend/internal/service/billing_service.go | 19 +++++++++++ .../internal/service/openai_gateway_grok.go | 32 +++++++++++++------ .../service/openai_gateway_grok_test.go | 6 ++-- 5 files changed, 62 insertions(+), 24 deletions(-) diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index 990a6d28e0..d392249617 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -57,9 +57,10 @@ const DefaultTextModel = "grok-4.5" const ( DefaultImagineImageQualityModel = "grok-imagine-image-quality" DefaultImagineImageFastModel = "grok-imagine-image" + DefaultImagineImage20Model = "grok-imagine-image-2.0" DefaultImagineVideoModel = "grok-imagine-video" - DefaultImagineVideo15LegacyModel = "grok-imagine-video-1.5" - DefaultImagineVideo15Model = "grok-imagine-video-1.5-preview" + DefaultImagineVideo15Model = "grok-imagine-video-1.5" + DefaultImagineVideo15LegacyModel = "grok-imagine-video-1.5-preview" ) // ModelMappingOptions controls optional expansions of the default mapping. @@ -86,8 +87,6 @@ var defaultModels = []Model{ {ID: "grok-4.6", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.6"}, {ID: "grok-4.5", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.5"}, {ID: "grok-4.3", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"}, - {ID: "grok-3-mini", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 3 Mini"}, - {ID: "grok-3-mini-fast", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 3 Mini Fast"}, {ID: "grok-build-0.1", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Build 0.1"}, {ID: "grok-composer-2.5-fast", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Composer 2.5 Fast"}, {ID: "grok-4.20-0309-reasoning", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Reasoning"}, @@ -96,9 +95,9 @@ var defaultModels = []Model{ // Imagine {ID: DefaultImagineImageQualityModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image Quality"}, {ID: DefaultImagineImageFastModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image"}, + {ID: DefaultImagineImage20Model, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image 2.0"}, {ID: DefaultImagineVideoModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video"}, - {ID: DefaultImagineVideo15Model, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5 Preview"}, - {ID: DefaultImagineVideo15LegacyModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5 Legacy"}, + {ID: DefaultImagineVideo15Model, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5"}, } // grokTextResponsesModelAliases is the source of truth for Grok text models @@ -109,8 +108,8 @@ var grokTextResponsesModelAliases = map[string]string{ "grok-latest": DefaultTextModel, "grok-4.6": "grok-4.6", "grok-4.6-latest": "grok-4.6", - "grok-4.5": DefaultTextModel, - "grok-4.5-latest": DefaultTextModel, + "grok-4.5": "grok-4.5", + "grok-4.5-latest": "grok-4.5", "grok-4.3": "grok-4.3", "grok-4.3-latest": "grok-4.3", "grok-3-mini": "grok-3-mini", @@ -162,7 +161,7 @@ func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string { } for alias, canonical := range grokTextResponsesModelAliases { // Remap aliases that pointed at DefaultTextModel constant to runtime default. - if canonical == DefaultTextModel { + if (alias == "grok" || alias == "grok-latest" || alias == "grok-build-latest") && canonical == DefaultTextModel { mapping[alias] = defaultText } else { mapping[alias] = canonical @@ -179,7 +178,7 @@ func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string { // Keep official IDs as identity so client-requested model strings are not // rewritten on the wire (pricing still canonicalizes 1.5* via CanonicalImagineVideoModel). mapping["grok-imagine-video"] = DefaultImagineVideoModel - mapping["grok-imagine-video-1.5"] = DefaultImagineVideo15LegacyModel + mapping["grok-imagine-video-1.5"] = DefaultImagineVideo15Model mapping["grok-imagine-video-1.5-preview"] = DefaultImagineVideo15Model // Informal alias only: mapping["grok-video-1.5"] = DefaultImagineVideo15Model @@ -273,7 +272,7 @@ func ResolveGrokTextResponsesModelID(model string, defaultText ...string) string } normalized := strings.ToLower(StripGrokProviderPrefix(trimmed)) if canonical, ok := grokTextResponsesModelAliases[normalized]; ok { - if canonical == DefaultTextModel { + if (normalized == "grok" || normalized == "grok-latest" || normalized == "grok-build-latest") && canonical == DefaultTextModel { return fallback } return canonical diff --git a/backend/internal/pkg/xai/models_test.go b/backend/internal/pkg/xai/models_test.go index a01d993ccb..0fc463814b 100644 --- a/backend/internal/pkg/xai/models_test.go +++ b/backend/internal/pkg/xai/models_test.go @@ -17,7 +17,7 @@ func TestDefaultModelMappingExcludesCrossClientWildcards(t *testing.T) { require.Equal(t, "grok-build-0.1", mapping["grok-build"]) require.Equal(t, DefaultTextModel, mapping["grok-build-latest"]) require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"]) - require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"]) + require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5"]) require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"]) require.Equal(t, "grok-4.5", mapping["xai/grok"]) @@ -72,3 +72,9 @@ func TestResolveGrokTextResponsesModelID(t *testing.T) { require.Equal(t, "grok-4.3", ResolveGrokTextResponsesModelID("grok", "grok-4.3")) require.Equal(t, "grok-4.20-multi-agent-0309", ResolveGrokTextResponsesModelID("grok-4.20-multi-agent")) } + +func TestExplicitGrok45DoesNotFollowRuntimeDefault(t *testing.T) { + require.Equal(t, "grok-4.5", ResolveGrokTextResponsesModelID("grok-4.5", "grok-4.6")) + require.Equal(t, "grok-4.5", ResolveGrokTextResponsesModelID("grok-4.5-latest", "grok-4.6")) + require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("grok", "grok-4.6")) +} diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index af8c694495..2447c31719 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -704,6 +704,21 @@ func (s *BillingService) initFallbackPricing() { LongContextInputMultiplier: 2, LongContextOutputMultiplier: 2, } + + // Keep legacy Grok 3 Mini requests on their own historical xAI price card; + // otherwise the generic Grok fallback bills them as Grok 4.5. + s.fallbackPrices["grok-3-mini"] = &ModelPricing{ + InputPricePerToken: 0.30e-6, + OutputPricePerToken: 0.50e-6, + CacheReadPricePerToken: 0.075e-6, + SupportsCacheBreakdown: false, + } + s.fallbackPrices["grok-3-mini-fast"] = &ModelPricing{ + InputPricePerToken: 0.60e-6, + OutputPricePerToken: 4e-6, + CacheReadPricePerToken: 0.15e-6, + SupportsCacheBreakdown: false, + } // xAI Grok Build 0.1 (official docs: $1 input / $0.20 cached input / // $2 output per MTok). Composer is available only through Grok Build and // has no standalone public API rate card, so its aliases use this coding @@ -913,6 +928,10 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { return s.fallbackPrices["grok-4.5"] case "grok-4.6", "grok-4.6-latest": return s.fallbackPrices["grok-4.6"] + case "grok-3-mini": + return s.fallbackPrices["grok-3-mini"] + case "grok-3-mini-fast": + return s.fallbackPrices["grok-3-mini-fast"] case "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index d9c7c5b0e4..9cb3b01246 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -173,11 +173,14 @@ func (s *OpenAIGatewayService) forwardGrokResponses( markGrokTeamModelRateLimit(account, upstreamModel, resolveGrokTeamRateLimitUntil(time.Now().Add(grokTeamRateLimitDefaultTTL), time.Now())) } if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { + retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + StatusCode: resp.StatusCode, + ResponseBody: respBody, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, } } return s.handleErrorResponse(ctx, resp, c, account, patchedBody, upstreamModel) @@ -899,7 +902,14 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { return body, nil } if !tools.IsArray() { - return body, nil + // xAI rejects tool_choice when tools is null/object. Treat malformed + // tool collections as absent at egress rather than forwarding a pair + // that cannot be interpreted by the Grok Responses endpoint. + body, err := sjson.DeleteBytes(body, "tools") + if err != nil { + return nil, err + } + return sjson.DeleteBytes(body, "tool_choice") } rawTools := tools.Array() @@ -1173,11 +1183,14 @@ func (s *OpenAIGatewayService) describeGrokComposerImage( }) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, grokComposerImageBridgeVisionModel), account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { + retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return "", OpenAIUsage{}, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + StatusCode: resp.StatusCode, + ResponseBody: respBody, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, } } return "", OpenAIUsage{}, fmt.Errorf("grok composer image bridge upstream error: %s", upstreamMsg) @@ -1314,6 +1327,7 @@ func buildGrokResponsesRequest(ctx context.Context, c *gin.Context, account *Acc if err != nil { return nil, err } + req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileGrok)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 7b30e683aa..cbeacdff6e 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -406,10 +406,10 @@ func TestSanitizeGrokResponsesToolsKeepsToolChoiceOnlyWithSupportedTools(t *test wantToolChoice: true, }, { - name: "malformed non-array tools remain untouched", + name: "malformed non-array tools are removed", body: `{"input":"hello","tools":{"type":"function","name":"lookup"},"tool_choice":"auto"}`, - wantTools: true, - wantToolChoice: true, + wantTools: false, + wantToolChoice: false, }, } From 39485f2e28d39056544b3f3bb9d117cc6970b74d Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:32:22 -0700 Subject: [PATCH 04/28] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20Grok=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=A8=A1=E5=9E=8B=E4=B8=8E=E5=AE=98=E6=96=B9=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- backend/internal/pkg/xai/models.go | 12 +++--- backend/internal/pkg/xai/models_test.go | 16 +++++--- backend/internal/pkg/xai/oauth_test.go | 8 ++-- backend/internal/service/billing_service.go | 38 +++++++++++++------ .../internal/service/billing_service_test.go | 36 ++++++++++++++++-- backend/internal/service/setting_parse.go | 4 +- backend/internal/service/setting_update.go | 2 +- .../__tests__/useModelWhitelist.spec.ts | 3 +- frontend/src/composables/useModelWhitelist.ts | 2 +- 10 files changed, 87 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 2c97044ddc..127653ce82 100644 --- a/README.md +++ b/README.md @@ -718,7 +718,7 @@ Sub2API supports both Grok subscription accounts through xAI OAuth and standard - Codex CLI style Responses WebSocket ingress is accepted on the Responses targets and bridged to xAI HTTP/SSE Responses upstream - Text models: `grok-4.5`, `grok-4.3`, `grok-build-0.1`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309` - Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/edits`, `/videos/edits`, `/v1/videos/extensions`, `/videos/extensions`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation, editing, and extension requests require the group image-generation permission. -- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5` +- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-image-2.0`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5` - JSON image-edit and video-generation requests accept image references in `image`, `images`, `reference_images`, and `mask` objects. Use `url` for xAI-compatible payloads; the legacy `image_url` field remains accepted and is normalized to `url` before forwarding. - Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index d392249617..6fab34dc39 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -51,7 +51,7 @@ type Model struct { // DefaultTextModel is the built-in fallback for empty model fields and Grok // text aliases (e.g. "grok", "grok-latest"). Operators may override the runtime // default via settings key grok_default_text_model. -const DefaultTextModel = "grok-4.5" +const DefaultTextModel = "grok-4.6" // Official Imagine model IDs (https://docs.x.ai/docs/models). const ( @@ -66,10 +66,10 @@ const ( // ModelMappingOptions controls optional expansions of the default mapping. // Cross-client wildcards (gpt-*/claude-*) default ON via settings // grok_cross_client_model_map_enabled so Codex/Claude clients keep working -// against Grok groups (map to DefaultText / grok-4.5). Operators may disable. +// against Grok groups (map to DefaultText / grok-4.6). Operators may disable. type ModelMappingOptions struct { // DefaultText is the target for empty models and optional cross-client maps. - // Empty → DefaultTextModel (grok-4.5). + // Empty → DefaultTextModel (grok-4.6). DefaultText string // EnableCrossClientMap merges gpt-*/codex-*/o*/claude-* → DefaultText. EnableCrossClientMap bool @@ -115,7 +115,7 @@ var grokTextResponsesModelAliases = map[string]string{ "grok-3-mini": "grok-3-mini", "grok-3-mini-fast": "grok-3-mini-fast", "grok-build": "grok-build-0.1", - "grok-build-latest": DefaultTextModel, + "grok-build-latest": "grok-build-0.1", "grok-build-0.1": "grok-build-0.1", "grok-composer-2.5-fast": "grok-composer-2.5-fast", "grok-composer": "grok-composer-2.5-fast", @@ -161,7 +161,7 @@ func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string { } for alias, canonical := range grokTextResponsesModelAliases { // Remap aliases that pointed at DefaultTextModel constant to runtime default. - if (alias == "grok" || alias == "grok-latest" || alias == "grok-build-latest") && canonical == DefaultTextModel { + if (alias == "grok" || alias == "grok-latest") && canonical == DefaultTextModel { mapping[alias] = defaultText } else { mapping[alias] = canonical @@ -272,7 +272,7 @@ func ResolveGrokTextResponsesModelID(model string, defaultText ...string) string } normalized := strings.ToLower(StripGrokProviderPrefix(trimmed)) if canonical, ok := grokTextResponsesModelAliases[normalized]; ok { - if (normalized == "grok" || normalized == "grok-latest" || normalized == "grok-build-latest") && canonical == DefaultTextModel { + if (normalized == "grok" || normalized == "grok-latest") && canonical == DefaultTextModel { return fallback } return canonical diff --git a/backend/internal/pkg/xai/models_test.go b/backend/internal/pkg/xai/models_test.go index 0fc463814b..fb4860ee4d 100644 --- a/backend/internal/pkg/xai/models_test.go +++ b/backend/internal/pkg/xai/models_test.go @@ -12,14 +12,14 @@ func TestDefaultModelMappingExcludesCrossClientWildcards(t *testing.T) { SetRuntimeModelMappingOptions(ModelMappingOptions{}) mapping := DefaultModelMapping() - require.Equal(t, "grok-4.5", mapping["grok"]) - require.Equal(t, "grok-4.5", mapping["grok-latest"]) + require.Equal(t, "grok-4.6", mapping["grok"]) + require.Equal(t, "grok-4.6", mapping["grok-latest"]) require.Equal(t, "grok-build-0.1", mapping["grok-build"]) - require.Equal(t, DefaultTextModel, mapping["grok-build-latest"]) + require.Equal(t, "grok-build-0.1", mapping["grok-build-latest"]) require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"]) require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5"]) require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"]) - require.Equal(t, "grok-4.5", mapping["xai/grok"]) + require.Equal(t, "grok-4.6", mapping["xai/grok"]) // Cross-vendor wildcards must stay opt-in. _, hasGPT := mapping["gpt-*"] @@ -68,7 +68,7 @@ func TestDefaultModelsIncludesGrok46(t *testing.T) { func TestResolveGrokTextResponsesModelID(t *testing.T) { t.Parallel() - require.Equal(t, "grok-4.5", ResolveGrokTextResponsesModelID("")) + require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("")) require.Equal(t, "grok-4.3", ResolveGrokTextResponsesModelID("grok", "grok-4.3")) require.Equal(t, "grok-4.20-multi-agent-0309", ResolveGrokTextResponsesModelID("grok-4.20-multi-agent")) } @@ -78,3 +78,9 @@ func TestExplicitGrok45DoesNotFollowRuntimeDefault(t *testing.T) { require.Equal(t, "grok-4.5", ResolveGrokTextResponsesModelID("grok-4.5-latest", "grok-4.6")) require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("grok", "grok-4.6")) } + +func TestBareGrokAliasesFollowGrok46Default(t *testing.T) { + require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("grok")) + require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("grok-latest")) + require.Equal(t, "grok-build-0.1", ResolveGrokTextResponsesModelID("grok-build-latest")) +} diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go index 90036ff8f2..c7b1577be4 100644 --- a/backend/internal/pkg/xai/oauth_test.go +++ b/backend/internal/pkg/xai/oauth_test.go @@ -350,14 +350,14 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) { t.Cleanup(func() { SetRuntimeModelMappingOptions(original) }) SetRuntimeModelMappingOptions(ModelMappingOptions{}) mapping := DefaultModelMapping() - require.Equal(t, "grok-4.5", mapping["grok"]) - require.Equal(t, "grok-4.5", mapping["grok-latest"]) + require.Equal(t, "grok-4.6", mapping["grok"]) + require.Equal(t, "grok-4.6", mapping["grok-latest"]) require.Equal(t, "grok-4.6", mapping["grok-4.6"]) require.Equal(t, "grok-4.6", mapping["grok-4.6-latest"]) require.Equal(t, "grok-4.5", mapping["grok-4.5"]) require.Equal(t, "grok-4.5", mapping["grok-4.5-latest"]) require.Equal(t, "grok-build-0.1", mapping["grok-build"]) - require.Equal(t, "grok-4.5", mapping["grok-build-latest"]) + require.Equal(t, "grok-build-0.1", mapping["grok-build-latest"]) require.Equal(t, "grok-composer-2.5-fast", mapping["grok-composer"]) require.Equal(t, "grok-composer-2.5-fast", mapping["composer-2.5"]) require.Equal(t, "grok-4.20-0309-reasoning", mapping["grok-4.20-reasoning"]) @@ -368,7 +368,7 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) { require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-image-quality"]) require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"]) require.Equal(t, DefaultImagineVideoModel, mapping["grok-imagine-video"]) - require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"]) + require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5"]) require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"]) _, hasGPT := mapping["gpt-*"] require.False(t, hasGPT, "cross-client wildcards must be opt-in") diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 2447c31719..6345f2c40e 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -667,7 +667,8 @@ func (s *BillingService) initFallbackPricing() { SupportsCacheBreakdown: false, } - // xAI Grok 4.5: $2 input / $0.30 cached input / $6 output below 200k. + // xAI Grok 4.5: $2 input / $0.30 cached input / $6 output below 200k; + // long-context rates are $4 / $0.60 / $12 (>=200k prompt tokens). s.fallbackPrices["grok-4.5"] = &ModelPricing{ InputPricePerToken: 2e-6, OutputPricePerToken: 6e-6, @@ -679,9 +680,8 @@ func (s *BillingService) initFallbackPricing() { LongContextOutputMultiplier: 2, } - // xAI Grok 4.6 (docs.x.ai/developers/models: $2 input / $0.50 cached input / - // $6 output per MTok under 200k prompt tokens; ≥200k is 2× on input, - // cached input, and output). + // xAI Grok 4.6: $2 input / $0.50 cached input / $6 output below 200k; + // long-context rates are $4 / $1 / $12 (>=200k prompt tokens). s.fallbackPrices["grok-4.6"] = &ModelPricing{ InputPricePerToken: 2e-6, OutputPricePerToken: 6e-6, @@ -693,7 +693,8 @@ func (s *BillingService) initFallbackPricing() { LongContextOutputMultiplier: 2, } - // xAI Grok 4.3: $1.25 input / $0.20 cached / $2.50 output below 200k. + // xAI Grok 4.3: $1.25 input / $0.20 cached / $2.50 output below 200k; + // long-context rates are $2.50 / $0.40 / $5. s.fallbackPrices["grok-4.3"] = &ModelPricing{ InputPricePerToken: 1.25e-6, OutputPricePerToken: 2.5e-6, @@ -704,6 +705,18 @@ func (s *BillingService) initFallbackPricing() { LongContextInputMultiplier: 2, LongContextOutputMultiplier: 2, } + // Grok 4.20 variants share the official $1.25 / $0.20 / $2.50 card + // (and $2.50 / $0.40 / $5 long-context rates) with Grok 4.3. + s.fallbackPrices["grok-4.20"] = &ModelPricing{ + InputPricePerToken: 1.25e-6, + OutputPricePerToken: 2.5e-6, + CacheReadPricePerToken: 0.2e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: 200000, + LongContextThresholdInclusive: true, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 2, + } // Keep legacy Grok 3 Mini requests on their own historical xAI price card; // otherwise the generic Grok fallback bills them as Grok 4.5. @@ -924,21 +937,22 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { } switch modelLower { - case "grok", "grok-latest", "grok-4.5", "grok-4.5-latest": - return s.fallbackPrices["grok-4.5"] - case "grok-4.6", "grok-4.6-latest": + case "grok", "grok-latest", "grok-4.6", "grok-4.6-latest": return s.fallbackPrices["grok-4.6"] + case "grok-4.5", "grok-4.5-latest": + return s.fallbackPrices["grok-4.5"] case "grok-3-mini": return s.fallbackPrices["grok-3-mini"] case "grok-3-mini-fast": return s.fallbackPrices["grok-3-mini-fast"] - case "grok-4.3", - "grok-4.20-0309-reasoning", + case "grok-4.3": + return s.fallbackPrices["grok-4.3"] + case "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-4.20-multi-agent-0309", "grok-4.20-reasoning", "grok-4.20-non-reasoning": - return s.fallbackPrices["grok-4.3"] + return s.fallbackPrices["grok-4.20"] case "grok-build", "grok-build-latest", "grok-build-0.1", "grok-composer", "grok-composer-2.5-fast", "composer-2.5": return s.fallbackPrices["grok-build-0.1"] } @@ -956,7 +970,7 @@ func (s *BillingService) grokUnknownTextFamilyFallback(model string) *ModelPrici if s == nil || !isGrokUnknownTextFamilyModel(model) { return nil } - return s.fallbackPrices["grok-4.5"] + return s.fallbackPrices["grok-4.6"] } func isGrokUnknownTextFamilyModel(model string) bool { diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 1e801d8e95..7248878a2e 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1179,7 +1179,7 @@ func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) { func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) { svc := newTestBillingService() - for _, model := range []string{"grok", "grok-latest", "grok-4.5", "grok-4.5-latest"} { + for _, model := range []string{"grok-4.5", "grok-4.5-latest"} { model := model t.Run(model, func(t *testing.T) { pricing, err := svc.GetModelPricing(model) @@ -1192,6 +1192,17 @@ func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) { } } +func TestGetModelPricing_GrokBareAliasesUseGrok46(t *testing.T) { + svc := newTestBillingService() + for _, model := range []string{"grok", "grok-latest"} { + pricing, err := svc.GetModelPricing(model) + require.NoError(t, err) + require.InDelta(t, 2e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 6e-6, pricing.OutputPricePerToken, 1e-12) + } +} + func TestGetModelPricing_Grok46OfficialFallback(t *testing.T) { svc := newTestBillingService() @@ -1211,6 +1222,25 @@ func TestGetModelPricing_Grok46OfficialFallback(t *testing.T) { } } +func TestGetModelPricing_GrokOfficialFamilyCards(t *testing.T) { + svc := newTestBillingService() + for _, tc := range []struct { + model string + input, cached, output float64 + }{ + {"grok-4.3", 1.25e-6, 0.2e-6, 2.5e-6}, + {"grok-4.20-0309-reasoning", 1.25e-6, 0.2e-6, 2.5e-6}, + {"grok-build-0.1", 1e-6, 0.2e-6, 2e-6}, + } { + p, err := svc.GetModelPricing(tc.model) + require.NoError(t, err, tc.model) + require.InDelta(t, tc.input, p.InputPricePerToken, 1e-12) + require.InDelta(t, tc.cached, p.CacheReadPricePerToken, 1e-12) + require.InDelta(t, tc.output, p.OutputPricePerToken, 1e-12) + require.Equal(t, 200000, p.LongContextInputThreshold) + } +} + func TestCalculateCostUnified_GroupLongContextToggleUsesPresetLadder(t *testing.T) { svc := newTestBillingService() resolver := NewModelPricingResolver(nil, svc) @@ -1234,9 +1264,9 @@ func TestCalculateCostUnified_GroupLongContextToggleUsesPresetLadder(t *testing. require.InDelta(t, disabled.OutputCost*2, enabled.OutputCost, 1e-12) } -func TestGetModelPricing_UnknownGrokTextFallsBackToGrok45(t *testing.T) { +func TestGetModelPricing_UnknownGrokTextFallsBackToGrok46(t *testing.T) { svc := newTestBillingService() - baseline, err := svc.GetModelPricing("grok-4.5") + baseline, err := svc.GetModelPricing("grok-4.6") require.NoError(t, err) for _, model := range []string{"grok-5", "grok-5-latest", "x-ai/grok-7", "grok-4.7-beta"} { diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 9f3dfbddf2..7787e8810f 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -193,7 +193,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyChannelMonitorShowQuota: "false", // Grok: safe defaults — no cross-vendor model rewrite unless operators enable it. - SettingKeyGrokDefaultTextModel: "grok-4.5", + SettingKeyGrokDefaultTextModel: "grok-4.6", SettingKeyGrokCrossClientModelMapEnabled: "true", SettingKeyGrokDefaultBaseURLMode: GrokDefaultBaseURLModeCLI, @@ -806,7 +806,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // Grok default mapping policy result.GrokDefaultTextModel = strings.TrimSpace(settings[SettingKeyGrokDefaultTextModel]) if result.GrokDefaultTextModel == "" { - result.GrokDefaultTextModel = "grok-4.5" + result.GrokDefaultTextModel = "grok-4.6" } // Default true (missing/empty → enabled) so Claude/Codex→Grok mapping keeps working. // Operators can set false to disable silent cross-client rewrite. diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index a4fadded0c..65e28ff6ee 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -423,7 +423,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting if v := strings.TrimSpace(settings.GrokDefaultTextModel); v != "" { updates[SettingKeyGrokDefaultTextModel] = v } else { - updates[SettingKeyGrokDefaultTextModel] = "grok-4.5" + updates[SettingKeyGrokDefaultTextModel] = "grok-4.6" } updates[SettingKeyGrokCrossClientModelMapEnabled] = strconv.FormatBool(settings.GrokCrossClientModelMapEnabled) updates[SettingKeyGrokDefaultBaseURLMode] = normalizeGrokDefaultBaseURLMode(settings.GrokDefaultBaseURLMode) diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index 9fd93e6573..af6cc61820 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -51,7 +51,8 @@ describe('useModelWhitelist', () => { expect(models).toContain('grok-4.5') expect(models).toContain('grok-4.5-latest') expect(models).toContain('grok-build-latest') - expect(models).toContain('grok-imagine-video-1.5-preview') + expect(models).toContain('grok-imagine-image-2.0') + expect(models).toContain('grok-imagine-video-1.5') }) it('combined 模式支持 Grok 4.5 官方别名映射', () => { diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 2ca860c673..4b16d44b02 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -157,8 +157,8 @@ const xaiModels = [ 'grok-imagine', 'grok-imagine-image-quality', 'grok-imagine-image', + 'grok-imagine-image-2.0', 'grok-imagine-video', - 'grok-imagine-video-1.5-preview', 'grok-imagine-video-1.5' ] From ad26172b83e80b48ed1209f658a8293940476be1 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:32:43 -0700 Subject: [PATCH 05/28] =?UTF-8?q?=E5=AE=8C=E5=96=84=20Grok=20=E9=99=90?= =?UTF-8?q?=E6=B5=81=E5=86=B7=E5=8D=B4=E4=B8=8E=E7=94=A8=E9=87=8F=E5=85=BC?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/failover_loop.go | 6 +++++ .../internal/handler/failover_loop_test.go | 10 ++++++++ backend/internal/handler/grok_media.go | 6 +++-- .../handler/openai_chat_completions.go | 2 +- .../handler/openai_gateway_handler.go | 4 ++-- backend/internal/handler/openai_images.go | 6 +++-- backend/internal/repository/http_upstream.go | 6 ++++- backend/internal/service/grok_stream_idle.go | 8 +++++-- .../internal/service/grok_stream_idle_test.go | 9 +++++++ .../internal/service/grok_upstream_failure.go | 20 ++++++++++------ .../service/grok_upstream_failure_test.go | 24 ++++++++++++++++++- .../internal/service/openai_gateway_grok.go | 12 ++++++---- .../openai_gateway_response_handling.go | 18 ++++++++++++++ .../service/openai_gateway_service_test.go | 14 +++++++++++ deploy/config.example.yaml | 3 +++ 15 files changed, 125 insertions(+), 23 deletions(-) diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go index 669cfcf365..d349980fc6 100644 --- a/backend/internal/handler/failover_loop.go +++ b/backend/internal/handler/failover_loop.go @@ -76,6 +76,12 @@ func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryC return delay } +// sameAccountRetryDeadlineAllows prevents a retry from starting after the +// service-provided same-account retry window has elapsed. +func sameAccountRetryDeadlineAllows(failoverErr *service.UpstreamFailoverError) bool { + return failoverErr == nil || failoverErr.SameAccountRetryDeadline.IsZero() || time.Now().Before(failoverErr.SameAccountRetryDeadline) +} + // FailoverState 跨循环迭代共享的 failover 状态 type FailoverState struct { SwitchCount int diff --git a/backend/internal/handler/failover_loop_test.go b/backend/internal/handler/failover_loop_test.go index e65b43c172..40602f9df9 100644 --- a/backend/internal/handler/failover_loop_test.go +++ b/backend/internal/handler/failover_loop_test.go @@ -60,6 +60,16 @@ func TestSameAccountRetryDelayFor(t *testing.T) { }) } +func TestSameAccountRetryDeadlineAllows(t *testing.T) { + require.True(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{})) + require.True(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{ + SameAccountRetryDeadline: time.Now().Add(time.Second), + })) + require.False(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{ + SameAccountRetryDeadline: time.Now().Add(-time.Second), + })) +} + // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index 3cef97ed3f..6e5619b6e5 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -362,18 +362,20 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. } if failoverErr.RetryableOnSameAccount { retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { + if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) { sameAccountRetryCount[account.ID]++ + retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("grok_media.pool_mode_same_account_retry", zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("retry_limit", retryLimit), zap.Int("retry_count", sameAccountRetryCount[account.ID]), + zap.Duration("retry_delay", retryDelay), ) select { case <-requestCtx.Done(): return - case <-time.After(sameAccountRetryDelay): + case <-time.After(retryDelay): } continue } diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 79709cabb7..1dd8f63ceb 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -328,7 +328,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { // Pool mode: retry on the same account if failoverErr.RetryableOnSameAccount { retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { + if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai_chat_completions.pool_mode_same_account_retry", diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index aebb68f543..1f7c849a6a 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -718,7 +718,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // 池模式:同账号重试 if failoverErr.RetryableOnSameAccount { retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { + if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai.pool_mode_same_account_retry", @@ -1267,7 +1267,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { // 池模式:同账号重试 if failoverErr.RetryableOnSameAccount { retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { + if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai_messages.pool_mode_same_account_retry", diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index da41bf6aa3..a82c7b7193 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -305,18 +305,20 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { } if failoverErr.RetryableOnSameAccount { retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { + if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) { sameAccountRetryCount[account.ID]++ + retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai.images.pool_mode_same_account_retry", zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("retry_limit", retryLimit), zap.Int("retry_count", sameAccountRetryCount[account.ID]), + zap.Duration("retry_delay", retryDelay), ) select { case <-requestCtx.Done(): return - case <-time.After(sameAccountRetryDelay): + case <-time.After(retryDelay): } continue } diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 43be1bc995..bdd6478cff 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -99,6 +99,7 @@ const ( upstreamProtocolModeOpenAIH1 = "openai_h1" upstreamProtocolModeOpenAIH2 = "openai_h2" upstreamProtocolModeOpenAIH1Fallback = "openai_h1_fallback" + upstreamProtocolModeGrok = "grok" ) var errUpstreamClientLimitReached = errors.New("upstream client cache limit reached") @@ -910,7 +911,7 @@ func (s *httpUpstreamService) applyProfilePoolSettings(settings poolSettings, pr // generic 600s gateway timeout from turning one request into a 10-minute // resource hold; streaming after headers is unaffected. settings.responseHeaderTimeout = 120 * time.Second - if s != nil && s.cfg != nil && s.cfg.Gateway.GrokResponseHeaderTimeout > 0 { + if s != nil && s.cfg != nil { settings.responseHeaderTimeout = time.Duration(s.cfg.Gateway.GrokResponseHeaderTimeout) * time.Second } } @@ -991,6 +992,9 @@ func (s *httpUpstreamService) resolveOpenAIHTTP2Settings() openAIHTTP2Settings { } func (s *httpUpstreamService) resolveProtocolMode(profile service.HTTPUpstreamProfile, proxyKey string, parsedProxy *url.URL) string { + if profile == service.HTTPUpstreamProfileGrok { + return upstreamProtocolModeGrok + } if profile != service.HTTPUpstreamProfileOpenAI { return upstreamProtocolModeDefault } diff --git a/backend/internal/service/grok_stream_idle.go b/backend/internal/service/grok_stream_idle.go index 3bdfa27fc9..d984ae1335 100644 --- a/backend/internal/service/grok_stream_idle.go +++ b/backend/internal/service/grok_stream_idle.go @@ -32,7 +32,11 @@ func grokStreamIdleFailoverError(account *Account, idle time.Duration) *Upstream StatusCode: 502, ResponseBody: []byte(`{"error":{"code":"empty_upstream","message":"` + strings.ReplaceAll(msg, `"`, `'`) + `"}}`), SafeToFailoverAfterWrite: true, - // Allow pool-mode retries; normal OAuth switches account via handler. - RetryableOnSameAccount: account != nil && account.IsPoolMode(), + // An idle upstream stream is transient and should get the configured + // same-account retry budget before switching credentials. This applies + // to both pooled and dedicated Grok accounts; the handler still enforces + // the request's retry limit. + RetryableOnSameAccount: account != nil && account.Platform == PlatformGrok, + RequestScopedTransient: true, } } diff --git a/backend/internal/service/grok_stream_idle_test.go b/backend/internal/service/grok_stream_idle_test.go index e3c6d16943..c8ee0443b6 100644 --- a/backend/internal/service/grok_stream_idle_test.go +++ b/backend/internal/service/grok_stream_idle_test.go @@ -21,5 +21,14 @@ func TestGrokStreamIdleFailoverError(t *testing.T) { require.NotNil(t, err) require.Equal(t, 502, err.StatusCode) require.True(t, err.SafeToFailoverAfterWrite) + require.True(t, err.RetryableOnSameAccount) + require.True(t, err.RequestScopedTransient) require.Contains(t, string(err.ResponseBody), "empty_upstream") } + +func TestGrokStreamIdleFailoverErrorRequiresGrokAccount(t *testing.T) { + openAI := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + err := grokStreamIdleFailoverError(openAI, time.Second) + require.False(t, err.RetryableOnSameAccount) + require.True(t, err.RequestScopedTransient) +} diff --git a/backend/internal/service/grok_upstream_failure.go b/backend/internal/service/grok_upstream_failure.go index 475c6cff16..b84413dd2b 100644 --- a/backend/internal/service/grok_upstream_failure.go +++ b/backend/internal/service/grok_upstream_failure.go @@ -417,6 +417,19 @@ func grokSameAccountRetryMetadata(account *Account, statusCode int, responseBody return true, 500 * time.Millisecond, time.Now().Add(30 * time.Second) } +// shouldMarkGrokTeamModelRateLimit controls the process-local sibling-account +// overlay. Model-capacity responses are request pressure, not a team quota; +// marking them would hide healthy sibling credentials while the bounded +// same-account retry is still in progress. Ordinary 429s and free-usage +// exhaustion retain the existing quota/team isolation behavior. +func shouldMarkGrokTeamModelRateLimit(statusCode int, responseBody []byte) bool { + decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") + if decision.Class == GrokFailureModelCapacity { + return false + } + return statusCode == http.StatusTooManyRequests || decision.Class == GrokFailureFreeUsage +} + func isGrokCompatibilityError(low, code string) bool { combined := strings.ToLower(strings.TrimSpace(low + " " + code)) // Compaction blobs are account/session-bound and frequently fail with 400 @@ -427,11 +440,6 @@ func isGrokCompatibilityError(low, code string) bool { "decode the compaction blob", "ensure it is unmodified from the compact response", "compaction blob", - "failed to deserialize the json body", - "data did not match any variant", - "untagged enum content", - "invalid content shape", - "invalid response history", } { if strings.Contains(combined, phrase) { return true @@ -440,8 +448,6 @@ func isGrokCompatibilityError(low, code string) bool { for _, marker := range []string{ "invalid_compaction", "compaction_decode_error", - "response_history_incompatible", - "invalid_content_shape", } { if strings.Contains(combined, marker) { return true diff --git a/backend/internal/service/grok_upstream_failure_test.go b/backend/internal/service/grok_upstream_failure_test.go index 358c1595d3..2424eb8dff 100644 --- a/backend/internal/service/grok_upstream_failure_test.go +++ b/backend/internal/service/grok_upstream_failure_test.go @@ -77,6 +77,11 @@ func TestGrokRetryableOnSameAccount_CapacityAndRateLimit(t *testing.T) { []byte(`{"error":{"message":"rate limit exceeded"}}`))) require.False(t, grokRetryableOnSameAccount(account, http.StatusPaymentRequired, []byte(`{"error":{"message":"You have run out of credits or need a Grok subscription"}}`))) + poolAccount := &Account{ID: 9108, Platform: PlatformGrok, Type: AccountTypeOAuth, + Credentials: map[string]any{"pool_mode": true}} + require.False(t, grokRetryableOnSameAccount(poolAccount, http.StatusTooManyRequests, + []byte(`{"error":{"code":"subscription:free-usage-exhausted"}}`)), + "pool free-usage must fail over instead of retrying the exhausted account") require.False(t, grokRetryableOnSameAccount(account, http.StatusBadRequest, []byte(`{"error":{"message":"capacity field is invalid"}}`))) nonGrok := &Account{ID: 9106, Platform: PlatformOpenAI, Type: AccountTypeOAuth} @@ -84,6 +89,17 @@ func TestGrokRetryableOnSameAccount_CapacityAndRateLimit(t *testing.T) { []byte(`{"error":{"message":"model at capacity"}}`))) } +func TestShouldMarkGrokTeamModelRateLimit_ExcludesCapacity(t *testing.T) { + require.False(t, shouldMarkGrokTeamModelRateLimit(http.StatusTooManyRequests, + []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`))) + require.True(t, shouldMarkGrokTeamModelRateLimit(http.StatusTooManyRequests, + []byte(`{"error":{"message":"rate limit exceeded"}}`))) + require.True(t, shouldMarkGrokTeamModelRateLimit(http.StatusBadRequest, + []byte(`{"error":{"code":"subscription:free-usage-exhausted"}}`))) + require.False(t, shouldMarkGrokTeamModelRateLimit(http.StatusBadRequest, + []byte(`{"error":{"message":"invalid request"}}`))) +} + func TestGrokSameAccountRetryMetadata_CapacityDeadline(t *testing.T) { account := &Account{ID: 9107, Platform: PlatformGrok, Type: AccountTypeOAuth} retryable, delay, deadline := grokSameAccountRetryMetadata(account, http.StatusTooManyRequests, @@ -116,7 +132,6 @@ func TestClassifyGrokUpstreamFailure_FreeUsageWinsOver5xx(t *testing.T) { func TestClassifyGrokUpstreamFailure_CompatibilityDoesNotCooldown(t *testing.T) { cases := []string{ `{"error":{"message":"Could not decode the compaction blob. Ensure it is unmodified from the compact response"}}`, - `{"error":{"message":"data did not match any variant of the untagged enum content"}}`, `{"code":"compaction_decode_error","message":"invalid response history"}`, } for _, body := range cases { @@ -128,6 +143,13 @@ func TestClassifyGrokUpstreamFailure_CompatibilityDoesNotCooldown(t *testing.T) } } +func TestClassifyGrokUpstreamFailure_GenericShapeErrorDoesNotFailover(t *testing.T) { + d := classifyGrokUpstreamFailure(http.StatusBadRequest, + []byte(`{"error":{"message":"data did not match any variant of the untagged enum content"}}`), "grok-4.6") + require.NotEqual(t, GrokFailureCompatibility, d.Class) + require.False(t, d.ShouldFailover) +} + func TestShouldFailoverGrokUpstreamError_FreeUsageBody(t *testing.T) { svc := &OpenAIGatewayService{} body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"free usage exhausted"}}`) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 9cb3b01246..589c2844b2 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -167,9 +167,9 @@ func (s *OpenAIGatewayService) forwardGrokResponses( }) errCtx := withGrokTeamRateLimitModel(ctx, upstreamModel) s.handleGrokAccountUpstreamError(errCtx, account, resp.StatusCode, resp.Header, respBody) - // 429 / free-usage: stamp team+model cool so sibling accounts skip this model. - if resp.StatusCode == http.StatusTooManyRequests || - classifyGrokUpstreamFailure(resp.StatusCode, respBody, upstreamModel).Class == GrokFailureFreeUsage { + // Quota/rate-limit responses stamp the team+model overlay. Capacity is + // request pressure and must not hide sibling accounts. + if shouldMarkGrokTeamModelRateLimit(resp.StatusCode, respBody) { markGrokTeamModelRateLimit(account, upstreamModel, resolveGrokTeamRateLimitUntil(time.Now().Add(grokTeamRateLimitDefaultTTL), time.Now())) } if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { @@ -179,6 +179,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( ResponseBody: respBody, ResponseHeaders: resp.Header.Clone(), RetryableOnSameAccount: retryable, + RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, SameAccountRetryDelay: retryDelay, SameAccountRetryDeadline: retryDeadline, } @@ -649,7 +650,7 @@ func grokSupportsXHighReasoningEffort(model string) bool { func grokSupportsReasoningEffort(model string) bool { model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model))) switch model { - case xai.DefaultTextModel, "grok-4.5-latest", "grok-4.6", "grok-4.6-latest", + case xai.DefaultTextModel, "grok-4.5-latest", "grok-4.6-latest", "grok-4.3", "grok-4.3-latest", "grok-3-mini", "grok-3-mini-fast", "grok-4.20-0309-reasoning", "grok-4.20-reasoning", "grok-4.20-multi-agent-0309": @@ -1189,6 +1190,7 @@ func (s *OpenAIGatewayService) describeGrokComposerImage( ResponseBody: respBody, ResponseHeaders: resp.Header.Clone(), RetryableOnSameAccount: retryable, + RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, SameAccountRetryDelay: retryDelay, SameAccountRetryDeadline: retryDeadline, } @@ -1744,7 +1746,7 @@ func persistGrokTransientModelCooldown(account *Account, decision GrokUpstreamFa return false } model := strings.TrimSpace(decision.Model) - if model == "" || !isGrokHeavyTransientModel(model) { + if model == "" { return false } cooldown := decision.Cooldown diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 5577e90e4b..5558093d11 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -1182,8 +1182,26 @@ func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { inputTokens = value.Get("prompt_tokens").Int() } outputTokens := value.Get("output_tokens").Int() + outputTokensFromResponses := outputTokens != 0 if outputTokens == 0 { outputTokens = value.Get("completion_tokens").Int() + outputTokensFromResponses = false + } + // xAI Chat Completions reports visible completion tokens separately from + // reasoning_tokens. Billing must include both. Responses usage generally + // already folds reasoning into output_tokens; use total_tokens when present + // to avoid double-counting that canonical shape. + reasoningTokens := max(int(firstPositiveGJSONInt( + value.Get("completion_tokens_details.reasoning_tokens"), + value.Get("output_tokens_details.reasoning_tokens"), + )), 0) + if reasoningTokens > 0 { + totalTokens := value.Get("total_tokens").Int() + if !outputTokensFromResponses { + outputTokens += int64(reasoningTokens) + } else if totalTokens > inputTokens && totalTokens >= outputTokens+int64(reasoningTokens) { + outputTokens += int64(reasoningTokens) + } } cacheReadTokens := openAICacheReadTokensFromUsage(value) cacheCreationTokens := openAICacheCreationTokensFromUsage(value) diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 1d18d3d70c..f3385b3c4e 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -3453,6 +3453,20 @@ func TestExtractOpenAIUsageFromJSONBytes_AcceptsResponseAndChatUsageShapes(t *te require.Zero(t, usage.CacheReadInputTokens, "官方嵌套缓存读取字段显式为零时仍应优先于兼容顶层别名") } +func TestExtractOpenAIUsageFromJSONBytes_IncludesGrokReasoningTokens(t *testing.T) { + usage, ok := extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"prompt_tokens":32,"completion_tokens":9,"total_tokens":135,"completion_tokens_details":{"reasoning_tokens":94}}}`)) + require.True(t, ok) + require.Equal(t, 103, usage.OutputTokens, "Grok Chat usage bills visible completion plus reasoning tokens") + + usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":32,"output_tokens":103,"total_tokens":135,"output_tokens_details":{"reasoning_tokens":94}}}`)) + require.True(t, ok) + require.Equal(t, 103, usage.OutputTokens, "Responses output_tokens already includes reasoning when total confirms it") + + usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":32,"output_tokens":9,"total_tokens":135,"output_tokens_details":{"reasoning_tokens":94}}}`)) + require.True(t, ok) + require.Equal(t, 103, usage.OutputTokens, "Responses detail-only shape is normalized when total exposes the full output") +} + func TestExtractCodexFinalResponse_SampleReplay(t *testing.T) { body := strings.Join([]string{ `event: message`, diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 8f134dd609..38a50d0607 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -214,6 +214,9 @@ gateway: # OpenAI/Codex upstream response header timeout (seconds, 0=disabled) # OpenAI/Codex 等待上游响应头超时时间(秒,0=禁用本地响应头超时) openai_response_header_timeout: 0 + # Grok text Responses first-byte timeout (seconds, 0=disabled; default 120) + # Grok 文本 Responses 首字节超时时间(秒,0=禁用;默认 120) + grok_response_header_timeout: 120 # Native OpenAI HTTP Responses first semantic output timeout (seconds, 0=disabled) # Includes response-header wait; does not apply to passthrough or WebSocket transports. # A timed-out request may already have incurred upstream usage; account failover can therefore duplicate upstream billing. From 611a7c8ed35f1b997164ba687a452eaeaa09eacb Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:32:51 -0700 Subject: [PATCH 06/28] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Grok=20Realtime=20?= =?UTF-8?q?=E9=A2=84=E6=8E=A5=E5=85=A5=E5=88=87=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/grok_audio.go | 90 +++++++++++++++----------- backend/internal/service/grok_audio.go | 1 - 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/backend/internal/handler/grok_audio.go b/backend/internal/handler/grok_audio.go index a31ec802f6..bbe7a4210c 100644 --- a/backend/internal/handler/grok_audio.go +++ b/backend/internal/handler/grok_audio.go @@ -44,53 +44,65 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { return } - selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability( - c.Request.Context(), - apiKey.GroupID, - "", - "", - "grok-4.5", - nil, - service.OpenAIUpstreamTransportHTTPSSE, - // Grok only advertises chat_completions + media capabilities on HEAD. - service.OpenAIEndpointCapabilityChatCompletions, - false, - false, - false, - service.PlatformGrok, - ) - if err != nil || selection == nil || selection.Account == nil { - h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts") - return - } - - var streamStarted bool reqLog := requestLogger(c, "handler.openai_gateway.grok_realtime") - release, slotStatus := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, true, &streamStarted, reqLog) - if slotStatus != openAISlotAcquireOK { - return - } - defer release() - - token, _, err := h.gatewayService.GetRequestCredential(c.Request.Context(), c, selection.Account) - if err != nil { - h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok credential unavailable") - return - } model := c.Query("model") if strings.TrimSpace(model) == "" { model = "grok-voice-latest" } - // Complete the upstream handshake before sending HTTP 101. This keeps - // pre-accept failures representable as normal JSON errors to the client. - probeCtx, cancelProbe := context.WithTimeout(c.Request.Context(), 15*time.Second) - probeErr := h.gatewayService.ProbeGrokRealtime(probeCtx, selection.Account, token, model) - cancelProbe() - if probeErr != nil { - reqLog.Warn("grok_realtime.pre_accept_failed", zap.Error(probeErr)) + // Keep the HTTP response uncommitted while selecting and probing an account. + // Realtime is not an HTTP streaming response; using reqStream=true here would + // let the wait queue flush an SSE ping before the WebSocket handshake succeeds. + failed := map[int64]struct{}{} + var selection *service.AccountSelectionResult + var release func() + var token string + for attempts := 0; attempts < 4; attempts++ { + candidate, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability( + c.Request.Context(), apiKey.GroupID, "", "", "grok-4.6", failed, + service.OpenAIUpstreamTransportHTTPSSE, + service.OpenAIEndpointCapabilityChatCompletions, + false, false, false, service.PlatformGrok, + ) + if selectErr != nil || candidate == nil || candidate.Account == nil { + break + } + account := candidate.Account + var streamStarted bool + var slotStatus openAISlotAcquireResult + release, slotStatus = h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", candidate, false, &streamStarted, reqLog) + if slotStatus != openAISlotAcquireOK { + if slotStatus == openAISlotAcquireFailed { + return + } + failed[account.ID] = struct{}{} + continue + } + var credErr error + token, _, credErr = h.gatewayService.GetRequestCredential(c.Request.Context(), c, account) + if credErr != nil { + release() + release = nil + failed[account.ID] = struct{}{} + continue + } + probeCtx, cancelProbe := context.WithTimeout(c.Request.Context(), 15*time.Second) + probeErr := h.gatewayService.ProbeGrokRealtime(probeCtx, account, token, model) + cancelProbe() + if probeErr != nil { + reqLog.Warn("grok_realtime.pre_accept_failed", zap.Int64("account_id", account.ID), zap.Error(probeErr)) + release() + release = nil + failed[account.ID] = struct{}{} + continue + } + selection = candidate + break + } + if selection == nil || selection.Account == nil || release == nil { h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable") return } + defer release() conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) if err != nil { diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index 63a2e28b00..9b769a5fa4 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -63,7 +63,6 @@ func (s *OpenAIGatewayService) ForwardGrokVoice(ctx context.Context, c *gin.Cont } upstreamCtx, release := detachUpstreamContext(ctx) defer release() - upstreamCtx = WithHTTPUpstreamProfile(upstreamCtx, HTTPUpstreamProfileGrok) method := http.MethodPost if c != nil && c.Request != nil && strings.TrimSpace(c.Request.Method) != "" { method = c.Request.Method From 61c2f5ad28731f7a2d8f8366e2a4bf5720ab732f Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:38:51 -0700 Subject: [PATCH 07/28] =?UTF-8?q?=E5=A4=8D=E7=94=A8=20Grok=20Realtime=20?= =?UTF-8?q?=E9=A2=84=E6=8F=A1=E6=89=8B=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/grok_audio.go | 14 ++++--- backend/internal/service/grok_audio.go | 53 +++++++++++++++++++------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/backend/internal/handler/grok_audio.go b/backend/internal/handler/grok_audio.go index bbe7a4210c..5c856c80e4 100644 --- a/backend/internal/handler/grok_audio.go +++ b/backend/internal/handler/grok_audio.go @@ -56,6 +56,7 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { var selection *service.AccountSelectionResult var release func() var token string + var upstream *service.GrokRealtimeUpstream for attempts := 0; attempts < 4; attempts++ { candidate, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability( c.Request.Context(), apiKey.GroupID, "", "", "grok-4.6", failed, @@ -86,23 +87,24 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { continue } probeCtx, cancelProbe := context.WithTimeout(c.Request.Context(), 15*time.Second) - probeErr := h.gatewayService.ProbeGrokRealtime(probeCtx, account, token, model) + candidateUpstream, openErr := h.gatewayService.OpenGrokRealtime(probeCtx, account, token, model) cancelProbe() - if probeErr != nil { - reqLog.Warn("grok_realtime.pre_accept_failed", zap.Int64("account_id", account.ID), zap.Error(probeErr)) + if openErr != nil { + reqLog.Warn("grok_realtime.pre_accept_failed", zap.Int64("account_id", account.ID), zap.Error(openErr)) release() release = nil failed[account.ID] = struct{}{} continue } - selection = candidate + selection, upstream = candidate, candidateUpstream break } - if selection == nil || selection.Account == nil || release == nil { + if selection == nil || selection.Account == nil || release == nil || upstream == nil { h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable") return } defer release() + defer upstream.Close() conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) if err != nil { @@ -111,7 +113,7 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { defer func() { _ = conn.CloseNow() }() started := time.Now() - audioObserved, proxyErr := h.gatewayService.ProxyGrokRealtime(c.Request.Context(), c, conn, selection.Account, token, model) + audioObserved, proxyErr := h.gatewayService.ProxyGrokRealtimeConn(c.Request.Context(), c, conn, upstream) elapsed := time.Since(started) if proxyErr != nil { reqLog.Info("grok_realtime.proxy_failed", zap.Error(proxyErr)) diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index 9b769a5fa4..7aeb80236b 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -125,35 +125,60 @@ func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Con if account.Platform != PlatformGrok { return false, fmt.Errorf("account platform %s is not supported for grok realtime", account.Platform) } - base, err := buildGrokVoiceURL(account, s.cfg, "realtime") + upstream, err := s.OpenGrokRealtime(ctx, account, token, model) if err != nil { return false, err } + defer upstream.Close() + return s.ProxyGrokRealtimeConn(ctx, c, client, upstream) +} + +type GrokRealtimeUpstream struct{ conn openAIWSClientConn } + +func (u *GrokRealtimeUpstream) Close() error { + if u == nil || u.conn == nil { + return nil + } + return u.conn.Close() +} + +func (s *OpenAIGatewayService) OpenGrokRealtime(ctx context.Context, account *Account, token, model string) (*GrokRealtimeUpstream, error) { + if s == nil || account == nil || account.Platform != PlatformGrok { + return nil, fmt.Errorf("grok realtime account is required") + } + base, err := buildGrokVoiceURL(account, s.cfg, "realtime") + if err != nil { + return nil, err + } u, err := url.Parse(base) if err != nil { - return false, err + return nil, err } u.Scheme = "wss" - u.RawQuery = "model=" + url.QueryEscape(firstNonEmpty(model, "grok-voice-latest")) + q := u.Query() + q.Set("model", firstNonEmpty(model, "grok-voice-latest")) + u.RawQuery = q.Encode() headers := http.Header{"Authorization": []string{"Bearer " + token}} - // Match media/voice HTTP: CLI headers only on CLI proxy hosts. if account.IsGrokOAuth() && isGrokCLIProxyTarget(u.String()) { applyGrokCLIHeaders(headers) } - if account != nil { - account.ApplyHeaderOverrides(headers) - } - - dialer := s.getOpenAIWSPassthroughDialer() + account.ApplyHeaderOverrides(headers) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - upstream, _, _, err := dialer.Dial(ctx, u.String(), headers, proxyURL) + conn, _, _, err := s.getOpenAIWSPassthroughDialer().Dial(ctx, u.String(), headers, proxyURL) if err != nil { - return false, err + return nil, err } - defer func() { _ = upstream.Close() }() + return &GrokRealtimeUpstream{conn: conn}, nil +} + +func (s *OpenAIGatewayService) ProxyGrokRealtimeConn(ctx context.Context, c *gin.Context, client *coderws.Conn, upstream *GrokRealtimeUpstream) (bool, error) { + if s == nil || client == nil || upstream == nil || upstream.conn == nil { + return false, fmt.Errorf("realtime connection is required") + } + conn := upstream.conn ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -163,7 +188,7 @@ func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Con // Upstream → client go func() { for { - msg, readErr := upstream.ReadMessage(ctx) + msg, readErr := conn.ReadMessage(ctx) if readErr != nil { errCh <- readErr return @@ -197,7 +222,7 @@ func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Con errCh <- fmt.Errorf("invalid realtime event: %w", unmarshalErr) return } - if writeErr := upstream.WriteJSON(ctx, raw); writeErr != nil { + if writeErr := conn.WriteJSON(ctx, raw); writeErr != nil { errCh <- writeErr return } From e85348be80a3ea87bad7df0c5a4ad282ad8dc0e0 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:39:13 -0700 Subject: [PATCH 08/28] =?UTF-8?q?=E8=B0=83=E6=95=B4=20Grok=20=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E8=B6=85=E6=97=B6=E4=B8=8E=E9=87=8D=E8=AF=95=E8=AF=AD?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/grok_media.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 2155f7eb99..7c4a12cd0b 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -658,7 +658,6 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( } upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) defer releaseUpstreamCtx() - upstreamCtx = WithHTTPUpstreamProfile(upstreamCtx, HTTPUpstreamProfileGrok) upstreamReq, err := http.NewRequestWithContext(upstreamCtx, endpoint.httpMethod(), targetURL, bodyReader) if err != nil { return nil, err @@ -1295,6 +1294,7 @@ func (s *OpenAIGatewayService) handleGrokMediaErrorResponse( ResponseBody: body, ResponseHeaders: resp.Header.Clone(), RetryableOnSameAccount: retryable, + RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, SameAccountRetryDelay: retryDelay, SameAccountRetryDeadline: retryDeadline, } From 0e05c61d3622cbd9e69886e803f50f93f2707692 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:26:02 -0700 Subject: [PATCH 09/28] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Grok=20=E5=AE=B9?= =?UTF-8?q?=E9=87=8F=E5=86=B7=E5=8D=B4=E4=B8=8E=E7=94=A8=E9=87=8F=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/grok_stream_idle.go | 4 ++++ backend/internal/service/grok_stream_idle_test.go | 1 + backend/internal/service/grok_upstream_failure.go | 2 +- .../service/grok_upstream_failure_test.go | 8 ++++++++ backend/internal/service/openai_gateway_grok.go | 15 +++++++++++---- .../service/openai_gateway_response_handling.go | 13 ++++--------- .../service/openai_gateway_service_test.go | 10 ++++++++++ 7 files changed, 39 insertions(+), 14 deletions(-) diff --git a/backend/internal/service/grok_stream_idle.go b/backend/internal/service/grok_stream_idle.go index d984ae1335..cbe1fdc767 100644 --- a/backend/internal/service/grok_stream_idle.go +++ b/backend/internal/service/grok_stream_idle.go @@ -38,5 +38,9 @@ func grokStreamIdleFailoverError(account *Account, idle time.Duration) *Upstream // the request's retry limit. RetryableOnSameAccount: account != nil && account.Platform == PlatformGrok, RequestScopedTransient: true, + // Permit at most one same-account replay after the idle failure. The + // deadline is anchored at failure time, so a hung stream cannot consume + // the normal three-attempt budget before failover. + SameAccountRetryDeadline: time.Now().Add(idle), } } diff --git a/backend/internal/service/grok_stream_idle_test.go b/backend/internal/service/grok_stream_idle_test.go index c8ee0443b6..69fdcc1da9 100644 --- a/backend/internal/service/grok_stream_idle_test.go +++ b/backend/internal/service/grok_stream_idle_test.go @@ -24,6 +24,7 @@ func TestGrokStreamIdleFailoverError(t *testing.T) { require.True(t, err.RetryableOnSameAccount) require.True(t, err.RequestScopedTransient) require.Contains(t, string(err.ResponseBody), "empty_upstream") + require.WithinDuration(t, time.Now().Add(180*time.Second), err.SameAccountRetryDeadline, 2*time.Second) } func TestGrokStreamIdleFailoverErrorRequiresGrokAccount(t *testing.T) { diff --git a/backend/internal/service/grok_upstream_failure.go b/backend/internal/service/grok_upstream_failure.go index b84413dd2b..3d6aa05c6e 100644 --- a/backend/internal/service/grok_upstream_failure.go +++ b/backend/internal/service/grok_upstream_failure.go @@ -147,7 +147,7 @@ func classifyGrokUpstreamFailure(statusCode int, responseBody []byte, requestedM return GrokUpstreamFailureDecision{ Class: GrokFailureModelCapacity, Model: model, - Cooldown: 3 * time.Minute, + Cooldown: time.Minute, ShouldCooldown: true, ShouldFailover: true, BlockModel: false, diff --git a/backend/internal/service/grok_upstream_failure_test.go b/backend/internal/service/grok_upstream_failure_test.go index 2424eb8dff..d6e6ec7ace 100644 --- a/backend/internal/service/grok_upstream_failure_test.go +++ b/backend/internal/service/grok_upstream_failure_test.go @@ -54,6 +54,14 @@ func TestClassifyGrokUpstreamFailure_EmptyUpstream(t *testing.T) { require.Equal(t, 4*time.Minute, d.Cooldown) } +func TestClassifyGrokUpstreamFailure_ModelCapacityUsesShortCooldown(t *testing.T) { + d := classifyGrokUpstreamFailure(http.StatusTooManyRequests, + []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`), "grok-4.6") + require.Equal(t, GrokFailureModelCapacity, d.Class) + require.Equal(t, time.Minute, d.Cooldown) + require.False(t, d.BlockModel) +} + func TestClassifyGrokUpstreamFailure_Billing(t *testing.T) { d := classifyGrokUpstreamFailure(http.StatusForbidden, []byte(`{"code":"personal-team-blocked:spending-limit","error":"spending limit reached"}`), "") require.Equal(t, GrokFailureBilling, d.Class) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 589c2844b2..1933cd2550 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -650,7 +650,7 @@ func grokSupportsXHighReasoningEffort(model string) bool { func grokSupportsReasoningEffort(model string) bool { model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model))) switch model { - case xai.DefaultTextModel, "grok-4.5-latest", "grok-4.6-latest", + case "grok-4.5", "grok-4.5-latest", "grok-4.6", "grok-4.6-latest", "grok-4.3", "grok-4.3-latest", "grok-3-mini", "grok-3-mini-fast", "grok-4.20-0309-reasoning", "grok-4.20-reasoning", "grok-4.20-multi-agent-0309": @@ -1366,6 +1366,10 @@ func applyGrokCLIHeaders(headers http.Header) { } func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, account *Account, snapshot *xai.QuotaSnapshot) { + s.updateGrokUsageSnapshotWithRateLimit(ctx, account, snapshot, true) +} + +func (s *OpenAIGatewayService) updateGrokUsageSnapshotWithRateLimit(ctx context.Context, account *Account, snapshot *xai.QuotaSnapshot, installRateLimit bool) { if s == nil || account == nil || account.ID <= 0 || snapshot == nil { return } @@ -1413,7 +1417,7 @@ func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, acco // API keys retain the snapshot for observability but leave account health to // the upstream pool. Other accounts install the immediate runtime and durable // rate-limit state when the observed window is exhausted. - if hasActiveLimit && !account.IsPoolMode() { + if installRateLimit && hasActiveLimit && !account.IsPoolMode() { s.rateLimitGrok(stateCtx, account, resetAt) } else if recovery { clearGrokRateLimitAfterRecovery(stateCtx, s.accountRepo, account) @@ -1765,14 +1769,17 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex return } now := time.Now() + decision := classifyGrokUpstreamFailure(statusCode, responseBody, grokRequestedModelFromCtx(ctx)) snapshot := parseGrokQuotaSnapshot(headers, statusCode, now) stampGrokQuotaSnapshotForPlan(account, snapshot, grokRequestedModelFromCtx(ctx)) - s.updateGrokUsageSnapshot(ctx, account, snapshot) + // Capacity 429 is model pressure, not account quota exhaustion. Keep the + // snapshot for observability but do not install account-level rate limiting; + // the failover decision below applies a bounded model-scoped block instead. + s.updateGrokUsageSnapshotWithRateLimit(ctx, account, snapshot, decision.Class != GrokFailureModelCapacity) // Body-first free-usage / empty / billing / capacity must run before the // status switch so non-429 free-usage bodies still cool the account. // Pool-mode still skips durable mutation unless an explicit temp rule matches. - decision := classifyGrokUpstreamFailure(statusCode, responseBody, grokRequestedModelFromCtx(ctx)) if decision.ShouldCooldown && decision.Class != GrokFailureNone && decision.Class != GrokFailureRateLimit { if account.IsPoolMode() { // Allow configured temp rules (403) below; skip default body cools. diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 5558093d11..45a8e06de6 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -1182,24 +1182,19 @@ func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { inputTokens = value.Get("prompt_tokens").Int() } outputTokens := value.Get("output_tokens").Int() - outputTokensFromResponses := outputTokens != 0 if outputTokens == 0 { outputTokens = value.Get("completion_tokens").Int() - outputTokensFromResponses = false } - // xAI Chat Completions reports visible completion tokens separately from - // reasoning_tokens. Billing must include both. Responses usage generally - // already folds reasoning into output_tokens; use total_tokens when present - // to avoid double-counting that canonical shape. + // xAI may report reasoning_tokens separately from visible output tokens. + // Only add it when total_tokens proves it is an independent component; + // OpenAI's canonical completion_tokens already includes reasoning details. reasoningTokens := max(int(firstPositiveGJSONInt( value.Get("completion_tokens_details.reasoning_tokens"), value.Get("output_tokens_details.reasoning_tokens"), )), 0) if reasoningTokens > 0 { totalTokens := value.Get("total_tokens").Int() - if !outputTokensFromResponses { - outputTokens += int64(reasoningTokens) - } else if totalTokens > inputTokens && totalTokens >= outputTokens+int64(reasoningTokens) { + if totalTokens > 0 && totalTokens == inputTokens+outputTokens+int64(reasoningTokens) { outputTokens += int64(reasoningTokens) } } diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index f3385b3c4e..998ab6a154 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -3451,6 +3451,16 @@ func TestExtractOpenAIUsageFromJSONBytes_AcceptsResponseAndChatUsageShapes(t *te usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":20,"output_tokens":2,"cache_read_input_tokens":19,"input_tokens_details":{"cached_tokens":0}}}`)) require.True(t, ok) require.Zero(t, usage.CacheReadInputTokens, "官方嵌套缓存读取字段显式为零时仍应优先于兼容顶层别名") + + // xAI reports reasoning_tokens outside visible output_tokens. Only the + // arithmetic-consistent shape is independent; OpenAI's canonical shape + // already includes reasoning in completion/output_tokens. + usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":10000,"output_tokens":500,"total_tokens":10800,"output_tokens_details":{"reasoning_tokens":300}}}`)) + require.True(t, ok) + require.Equal(t, 800, usage.OutputTokens) + usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":10000,"output_tokens":500,"total_tokens":10500,"output_tokens_details":{"reasoning_tokens":300}}}`)) + require.True(t, ok) + require.Equal(t, 500, usage.OutputTokens) } func TestExtractOpenAIUsageFromJSONBytes_IncludesGrokReasoningTokens(t *testing.T) { From f7145c75080072b1016dba79987a460624a332b3 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:26:15 -0700 Subject: [PATCH 10/28] =?UTF-8?q?=E8=BF=81=E7=A7=BB=20Grok=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=A8=A1=E5=9E=8B=E5=88=B0=204.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/server/api_contract_test.go | 4 +-- .../service/setting_gateway_runtime.go | 31 +++++++++++++++++++ .../setting_service_codex_policy_test.go | 31 +++++++++++++++++++ backend/internal/service/wire.go | 3 ++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 7f3b25f763..2bca68d3be 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -886,7 +886,7 @@ func TestAPIContracts(t *testing.T) { "invitation_code_enabled": false, "home_content": "", "hide_ccs_import_button": false, - "grok_default_text_model": "grok-4.5", + "grok_default_text_model": "grok-4.6", "grok_default_base_url_mode": "cli", "grok_cross_client_model_map_enabled": true, "purchase_subscription_enabled": false, @@ -1169,7 +1169,7 @@ func TestAPIContracts(t *testing.T) { "doc_url": "", "home_content": "", "hide_ccs_import_button": false, - "grok_default_text_model": "grok-4.5", + "grok_default_text_model": "grok-4.6", "grok_default_base_url_mode": "cli", "grok_cross_client_model_map_enabled": true, "purchase_subscription_enabled": false, diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index 2f13278e3b..e7eda7c30e 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -457,6 +457,37 @@ func (s *SettingService) MigrateOpenAIAllowClaudeCodeCodexPluginSetting(ctx cont return nil } +// MigrateGrokDefaultTextModel upgrades the pre-4.6 built-in default for +// existing installations. Explicit operator choices are left untouched. +func (s *SettingService) MigrateGrokDefaultTextModel(ctx context.Context) error { + if s == nil || s.settingRepo == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) + defer cancel() + + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyGrokDefaultTextModel) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return nil + } + return fmt.Errorf("get %s setting: %w", SettingKeyGrokDefaultTextModel, err) + } + // Only migrate the value that was previously shipped as the built-in + // default. Any other value is an explicit operator choice or a future + // default and must remain unchanged. + if strings.TrimSpace(value) != "grok-4.5" { + return nil + } + if err := s.settingRepo.Set(dbCtx, SettingKeyGrokDefaultTextModel, "grok-4.6"); err != nil { + return fmt.Errorf("set %s setting: %w", SettingKeyGrokDefaultTextModel, err) + } + return nil +} + // MigrateCodexBodyFingerprintToSignals 把已废弃的 codex_cli_only_allow_body_engine_fingerprint // 开关并入引擎指纹信号列表。幂等:信号键已存在(非空)则不动;缺失时写默认种子, // 并把 body 路径行的 Required 设为旧 body 开关的值(旧 true ⇒ 勾上 body 行)。 diff --git a/backend/internal/service/setting_service_codex_policy_test.go b/backend/internal/service/setting_service_codex_policy_test.go index 1c17b91ef8..e426dea2c5 100644 --- a/backend/internal/service/setting_service_codex_policy_test.go +++ b/backend/internal/service/setting_service_codex_policy_test.go @@ -202,3 +202,34 @@ func TestMigrateCodexBodyFingerprintToSignals(t *testing.T) { require.Equal(t, openai.DefaultEngineFingerprintSignalsJSON(), repo.values[SettingKeyCodexCLIOnlyEngineFingerprintSignals]) }) } + +func TestMigrateGrokDefaultTextModel(t *testing.T) { + t.Run("upgrades legacy built-in default", func(t *testing.T) { + repo := &codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyGrokDefaultTextModel: "grok-4.5", + }} + svc := NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.MigrateGrokDefaultTextModel(context.Background())) + require.Equal(t, "grok-4.6", repo.values[SettingKeyGrokDefaultTextModel]) + require.Equal(t, "grok-4.6", repo.sets[SettingKeyGrokDefaultTextModel]) + }) + + t.Run("does not overwrite an explicit model", func(t *testing.T) { + repo := &codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyGrokDefaultTextModel: "grok-4.3", + }} + svc := NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.MigrateGrokDefaultTextModel(context.Background())) + require.Equal(t, "grok-4.3", repo.values[SettingKeyGrokDefaultTextModel]) + _, wrote := repo.sets[SettingKeyGrokDefaultTextModel] + require.False(t, wrote) + }) + + t.Run("missing setting is left for normal defaults", func(t *testing.T) { + repo := &codexPolicyMigrationRepoStub{values: map[string]string{}} + svc := NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.MigrateGrokDefaultTextModel(context.Background())) + _, wrote := repo.sets[SettingKeyGrokDefaultTextModel] + require.False(t, wrote) + }) +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 88c3a1f185..2e34ce7b9c 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -745,6 +745,9 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit if err := svc.MigrateCodexBodyFingerprintToSignals(context.Background()); err != nil { logger.LegacyPrintf("service.setting", "Warning: migrate codex body fingerprint to signals failed: %v", err) } + if err := svc.MigrateGrokDefaultTextModel(context.Background()); err != nil { + logger.LegacyPrintf("service.setting", "Warning: migrate Grok default text model failed: %v", err) + } antigravity.SetUserAgentVersionResolver(svc.GetAntigravityUserAgentVersion) // enforceCodexIdentityHeaders 是所有 Codex 出站路径共用的纯函数收口点,拿不到 ctx, // 故注入无参解析器;解析器内部自带 60s TTL 缓存,热路径不触库。 From 3243983b72ee7d7ad29a343738a1cd421b3e45b2 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:26:24 -0700 Subject: [PATCH 11/28] =?UTF-8?q?=E5=AE=8C=E5=96=84=20Grok=20Realtime=20?= =?UTF-8?q?=E4=B8=8E=E9=BB=98=E8=AE=A4=E6=98=A0=E5=B0=84=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/grok_audio.go | 17 ++++++++++++++--- .../handler/openai_gateway_handler_test.go | 2 +- .../internal/service/account_test_service.go | 2 +- backend/internal/service/grok_audio.go | 4 ++++ .../openai_gateway_grok_chat_bridge_test.go | 14 +++++++------- .../service/openai_gateway_grok_test.go | 8 ++++---- .../service/openai_ws_http_bridge_test.go | 2 +- 7 files changed, 32 insertions(+), 17 deletions(-) diff --git a/backend/internal/handler/grok_audio.go b/backend/internal/handler/grok_audio.go index 5c856c80e4..08a8ff3c6c 100644 --- a/backend/internal/handler/grok_audio.go +++ b/backend/internal/handler/grok_audio.go @@ -57,9 +57,15 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { var release func() var token string var upstream *service.GrokRealtimeUpstream + var candidateSeen bool for attempts := 0; attempts < 4; attempts++ { + // Realtime's voice model is not a text-model capability. Passing a + // concrete text model here would reject accounts mapped only to an + // older/default text model before the upstream handshake can decide. + // An empty requested model keeps account selection capability-based; + // the actual voice model remains in the upstream WS query below. candidate, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability( - c.Request.Context(), apiKey.GroupID, "", "", "grok-4.6", failed, + c.Request.Context(), apiKey.GroupID, "", "", "", failed, service.OpenAIUpstreamTransportHTTPSSE, service.OpenAIEndpointCapabilityChatCompletions, false, false, false, service.PlatformGrok, @@ -67,6 +73,7 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { if selectErr != nil || candidate == nil || candidate.Account == nil { break } + candidateSeen = true account := candidate.Account var streamStarted bool var slotStatus openAISlotAcquireResult @@ -86,7 +93,7 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { failed[account.ID] = struct{}{} continue } - probeCtx, cancelProbe := context.WithTimeout(c.Request.Context(), 15*time.Second) + probeCtx, cancelProbe := context.WithTimeout(c.Request.Context(), service.DefaultGrokRealtimeDialTimeout) candidateUpstream, openErr := h.gatewayService.OpenGrokRealtime(probeCtx, account, token, model) cancelProbe() if openErr != nil { @@ -100,7 +107,11 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { break } if selection == nil || selection.Account == nil || release == nil || upstream == nil { - h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable") + if !candidateSeen { + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts") + } else { + h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable") + } return } defer release() diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 898449cc29..bd9cef056b 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -672,7 +672,7 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { Platform: service.PlatformGrok, }, } - require.Equal(t, "grok-4.5", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5")) + require.Equal(t, "grok-4.6", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5")) require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "grok")) }) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 49d5c76295..68c59f25af 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -101,7 +101,7 @@ const ( AccountTestModeGrokRealtime = "realtime" defaultGrokRealtimeTestModel = "grok-voice-latest" - grokRealtimeProbeTimeout = 12 * time.Second + grokRealtimeProbeTimeout = DefaultGrokRealtimeDialTimeout ) // isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2). diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index 7aeb80236b..a2fb33f12d 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -16,6 +16,10 @@ import ( "github.com/tidwall/gjson" ) +// DefaultGrokRealtimeDialTimeout bounds the pre-accept upstream handshake. +// The timeout only covers dialing; an established session is not interrupted. +const DefaultGrokRealtimeDialTimeout = 12 * time.Second + // supportedGrokVoiceHTTPEndpoints are xAI Voice HTTP paths we forward as-is. var supportedGrokVoiceHTTPEndpoints = map[string]struct{}{ "tts": {}, diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go index 373907f0d7..5904c8ce3c 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go @@ -251,7 +251,7 @@ func TestForwardGrokChatViaResponsesNonStreamingCachesAndReturnsChat(t *testing. require.NotNil(t, result) require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint) - require.Equal(t, "grok-4.5", result.UpstreamModel) + require.Equal(t, "grok-4.6", result.UpstreamModel) require.Equal(t, 9908, result.Usage.InputTokens) require.Equal(t, 12, result.Usage.OutputTokens) require.Equal(t, 9856, result.Usage.CacheReadInputTokens) @@ -352,7 +352,7 @@ func TestForwardGrokChatViaResponsesCodeBuddyUsesStableConversationHeader(t *tes c.Request.Header.Set("X-Request-ID", "generic-"+tt.requestID) c.Set("api_key", &APIKey{ID: 7111}) - identity := resolveGrokCacheIdentity(c, tt.body, "", "grok-4.5") + identity := resolveGrokCacheIdentity(c, tt.body, "", "grok-4.6") require.NotEmpty(t, identity) if index == 0 { stableIdentity = identity @@ -406,8 +406,8 @@ func TestForwardGrokChatViaResponsesTraeToolHistoryKeepsCacheRoute(t *testing.T) accountRepo: repo, } - firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5") - extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5") + firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.6") + extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.6") require.NotEmpty(t, firstTurnIdentity) require.Equal(t, firstTurnIdentity, extendedTurnIdentity) @@ -463,8 +463,8 @@ func TestForwardGrokChatViaResponsesTraeCompatibilityFieldsKeepCacheRoute(t *tes accountRepo: repo, } - firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5") - extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5") + firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.6") + extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.6") require.NotEmpty(t, firstTurnIdentity) require.Equal(t, firstTurnIdentity, extendedTurnIdentity) @@ -534,7 +534,7 @@ func TestForwardGrokChatRuntimeGateFallsBackToRaw(t *testing.T) { mappedModel string wantUpstream string }{ - {name: "missing cache identity", wantUpstream: "grok-4.5"}, + {name: "missing cache identity", wantUpstream: "grok-4.6"}, {name: "non cache capable mapped model", setAPIKey: true, mappedModel: "grok-4.3", wantUpstream: "grok-4.3"}, } diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index cbeacdff6e..541e3a3216 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -1791,7 +1791,7 @@ func TestForwardAsChatCompletionsForGrokStopFallsBackToXAIChatCompletions(t *tes require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.NotEmpty(t, upstream.lastReq.Header.Get(grokConversationIDHeader)) require.NotEqual(t, "raw-client-cache-key", upstream.lastReq.Header.Get(grokConversationIDHeader)) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists()) require.Equal(t, "grok", result.Model) require.Equal(t, "grok-4.5", result.UpstreamModel) @@ -1848,7 +1848,7 @@ func TestForwardGrokResponsesStreamingDefaultsEmptyModelTo45AndSnapshots(t *test require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) require.Equal(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String(), upstream.lastReq.Header.Get(grokConversationIDHeader)) require.Equal(t, "web_search", gjson.GetBytes(upstream.lastBody, "tools.0.type").String()) @@ -1908,7 +1908,7 @@ func TestForwardGrokResponsesAPIKeyUsesXAIResponses(t *testing.T) { require.Equal(t, "Bearer xai-test-key", upstream.lastReq.Header.Get("Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "resp_grok_api_key", result.ResponseID) require.Equal(t, 2, result.Usage.InputTokens) require.Equal(t, 1, result.Usage.OutputTokens) @@ -2410,7 +2410,7 @@ func TestForwardGrokResponsesNonStreamingUsesCacheIdentityAndCachedUsage(t *test require.Equal(t, 7, result.Usage.InputTokens) require.Equal(t, 2, result.Usage.OutputTokens) require.Equal(t, 4, result.Usage.CacheReadInputTokens) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) identity := gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String() require.NotEmpty(t, identity) require.Equal(t, identity, upstream.lastReq.Header.Get(grokConversationIDHeader)) diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 49b4c70735..72bcb061cf 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -1187,7 +1187,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridgeAndPreservesMa require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.bodies[0], "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.bodies[0], "model").String()) require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.bodies[1], "model").String()) require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.bodies[2], "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) From 726de30101a75145e6b5a4bfc605e5a9d79007b7 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:41:26 -0700 Subject: [PATCH 12/28] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Grok=20WebSearch=20S?= =?UTF-8?q?SE=20action=20=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openai_gateway_response_handling.go | 60 ++++++++++++++++++- .../service/openai_gateway_service_test.go | 11 ++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 45a8e06de6..5bc2d895e7 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -439,7 +439,9 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. sendErrorEvent("stream_read_error") return resultWithUsage(), fmt.Errorf("stream read error: %w", scanErr), true } - processSSELine := func(line string, queueDrained bool) { + var pendingGrokWebSearchCompleted string + var processSSELine func(line string, queueDrained bool) + processSSELine = func(line string, queueDrained bool) { if streamEarlyErr != nil { return } @@ -448,6 +450,26 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. dataBytes := []byte(data) eventTypeRaw := gjson.GetBytes(dataBytes, "type").String() eventType := strings.TrimSpace(eventTypeRaw) + // Grok Build's xAI decoder requires action on the completed search + // event, while OpenAI-compatible upstreams often provide it only on + // the following output_item.done event. Hold that one event until the + // matching item arrives, then replay it with action injected. + if account != nil && account.Platform == PlatformGrok && account.Type == AccountTypeAPIKey && + eventType == "response.web_search_call.completed" && + !gjson.GetBytes(dataBytes, "action").Exists() { + pendingGrokWebSearchCompleted = line + return + } + if pendingGrokWebSearchCompleted != "" && eventType == "response.output_item.done" { + if adapted, adaptedOK := adaptGrokWebSearchCompletedAction( + []byte(strings.TrimSpace(strings.TrimPrefix(pendingGrokWebSearchCompleted, "data:"))), dataBytes, + ); adaptedOK { + pendingGrokWebSearchCompleted = "data: " + string(adapted) + } + pending := pendingGrokWebSearchCompleted + pendingGrokWebSearchCompleted = "" + processSSELine(pending, queueDrained) + } observer.ObserveOpenAI(dataBytes, eventTypeRaw) // 初始上游 data 的 type 只解析一次:原始值保持终止事件的精确匹配,规范化值供后续分支复用。 if openAIStreamEventIsTerminalWithType(data, eventTypeRaw) { @@ -640,6 +662,11 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. // A blank line dispatches a guarded event from the attempt-local stage. if stageFirstOutput && line == "" { + if pendingGrokWebSearchCompleted != "" { + pending := pendingGrokWebSearchCompleted + pendingGrokWebSearchCompleted = "" + processSSELine(pending, queueDrained) + } if !clientDisconnected { if _, err := writePendingString("\n"); err != nil { handlePendingWriteError(err) @@ -654,6 +681,11 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. // or queue-drain flush must never split an open SSE event. shouldFlush := false if line == "" { + if pendingGrokWebSearchCompleted != "" { + pending := pendingGrokWebSearchCompleted + pendingGrokWebSearchCompleted = "" + processSSELine(pending, queueDrained) + } shouldFlush = eventShouldFlush || (queueDrained && clientOutputStarted) eventShouldFlush = false } @@ -1173,6 +1205,32 @@ func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *g logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) } +// adaptGrokWebSearchCompletedAction fills the xAI-specific action field that +// Grok Build expects on response.web_search_call.completed. OpenAI-compatible +// upstreams commonly emit the action only on the later output_item.done event. +func adaptGrokWebSearchCompletedAction(completed, outputItem []byte) ([]byte, bool) { + if strings.TrimSpace(gjson.GetBytes(completed, "type").String()) != "response.web_search_call.completed" { + return completed, false + } + itemID := strings.TrimSpace(gjson.GetBytes(completed, "item_id").String()) + item := gjson.GetBytes(outputItem, "item") + if itemID == "" || !item.Exists() || strings.TrimSpace(item.Get("type").String()) != "web_search_call" { + return completed, false + } + if itemID != strings.TrimSpace(item.Get("id").String()) { + return completed, false + } + action := item.Get("action") + if !action.Exists() || strings.TrimSpace(action.Raw) == "" || action.Raw == "null" { + return completed, false + } + updated, err := sjson.SetRawBytes(completed, "action", []byte(action.Raw)) + if err != nil { + return completed, false + } + return updated, true +} + func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { if !value.Exists() || !value.IsObject() { return OpenAIUsage{}, false diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 998ab6a154..2b0344c309 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -3463,6 +3463,17 @@ func TestExtractOpenAIUsageFromJSONBytes_AcceptsResponseAndChatUsageShapes(t *te require.Equal(t, 500, usage.OutputTokens) } +func TestAdaptGrokWebSearchCompletedAction(t *testing.T) { + completed := []byte(`{"type":"response.web_search_call.completed","item_id":"call_1"}`) + done := []byte(`{"type":"response.output_item.done","item":{"type":"web_search_call","id":"call_1","action":{"type":"search","query":"latest news"}}}`) + got, ok := adaptGrokWebSearchCompletedAction(completed, done) + require.True(t, ok) + require.Equal(t, "search", gjson.GetBytes(got, "action.type").String()) + require.Equal(t, "latest news", gjson.GetBytes(got, "action.query").String()) + _, ok = adaptGrokWebSearchCompletedAction(completed, []byte(`{"type":"response.output_item.done","item":{"type":"web_search_call","id":"other"}}`)) + require.False(t, ok) +} + func TestExtractOpenAIUsageFromJSONBytes_IncludesGrokReasoningTokens(t *testing.T) { usage, ok := extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"prompt_tokens":32,"completion_tokens":9,"total_tokens":135,"completion_tokens_details":{"reasoning_tokens":94}}}`)) require.True(t, ok) From 6c3edc095640fe651d1b4524c39ed1e550ca298b Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:23:40 -0700 Subject: [PATCH 13/28] feat(429): add configurable cooldown and retry strategies --- .../handler/admin/setting_handler_runtime.go | 32 +- backend/internal/handler/dto/settings.go | 172 +++++---- backend/internal/handler/failover_loop.go | 91 ++++- .../openai_account_runtime_block_fastpath.go | 190 ++++++++- .../service/openai_gateway_cc_pipeline.go | 18 +- .../service/openai_gateway_forward.go | 134 +++---- .../service/openai_gateway_passthrough.go | 362 ++---------------- .../service/openai_gateway_service.go | 106 ++++- backend/internal/service/setting_features.go | 37 ++ backend/internal/service/settings_view.go | 78 +++- frontend/src/api/admin/settings.ts | 4 + frontend/src/views/admin/SettingsView.vue | 24 +- 12 files changed, 693 insertions(+), 555 deletions(-) diff --git a/backend/internal/handler/admin/setting_handler_runtime.go b/backend/internal/handler/admin/setting_handler_runtime.go index eb89b5472c..ef16e8979f 100644 --- a/backend/internal/handler/admin/setting_handler_runtime.go +++ b/backend/internal/handler/admin/setting_handler_runtime.go @@ -112,15 +112,23 @@ func (h *SettingHandler) GetRateLimit429CooldownSettings(c *gin.Context) { } response.Success(c, dto.RateLimit429CooldownSettings{ - Enabled: settings.Enabled, - CooldownSeconds: settings.CooldownSeconds, + Enabled: settings.Enabled, + CooldownSeconds: settings.CooldownSeconds, + Strategy: settings.Strategy, + RetryIntervalMs: settings.RetryIntervalMs, + RetryMaxDurationSeconds: settings.RetryMaxDurationSeconds, + MaxAccountSwitches: settings.MaxAccountSwitches, }) } // UpdateRateLimit429CooldownSettingsRequest 更新429默认回避配置请求 type UpdateRateLimit429CooldownSettingsRequest struct { - Enabled bool `json:"enabled"` - CooldownSeconds int `json:"cooldown_seconds"` + Strategy string `json:"strategy"` + RetryIntervalMs int `json:"retry_interval_ms"` + RetryMaxDurationSeconds int `json:"retry_max_duration_seconds"` + MaxAccountSwitches int `json:"max_account_switches"` + Enabled bool `json:"enabled"` + CooldownSeconds int `json:"cooldown_seconds"` } // UpdateRateLimit429CooldownSettings 更新429默认回避配置 @@ -133,8 +141,12 @@ func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) { } settings := &service.RateLimit429CooldownSettings{ - Enabled: req.Enabled, - CooldownSeconds: req.CooldownSeconds, + Strategy: req.Strategy, + RetryIntervalMs: req.RetryIntervalMs, + RetryMaxDurationSeconds: req.RetryMaxDurationSeconds, + MaxAccountSwitches: req.MaxAccountSwitches, + Enabled: req.Enabled, + CooldownSeconds: req.CooldownSeconds, } if err := h.settingService.SetRateLimit429CooldownSettings(c.Request.Context(), settings); err != nil { @@ -149,8 +161,12 @@ func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) { } response.Success(c, dto.RateLimit429CooldownSettings{ - Enabled: updatedSettings.Enabled, - CooldownSeconds: updatedSettings.CooldownSeconds, + Enabled: updatedSettings.Enabled, + CooldownSeconds: updatedSettings.CooldownSeconds, + Strategy: updatedSettings.Strategy, + RetryIntervalMs: updatedSettings.RetryIntervalMs, + RetryMaxDurationSeconds: updatedSettings.RetryMaxDurationSeconds, + MaxAccountSwitches: updatedSettings.MaxAccountSwitches, }) } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 798de2fed9..c0ef93e8cd 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -148,21 +148,23 @@ type SystemSettings struct { GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"` GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - CompactHomeEnabled bool `json:"compact_home_enabled"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` - CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + SupportQRCodes []service.SupportQRCodeEntry `json:"support_qr_codes"` + DownloadToolsURL string `json:"download_tools_url"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + CompactHomeEnabled bool `json:"compact_home_enabled"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` @@ -170,6 +172,9 @@ type SystemSettings struct { AffiliateRebateFreezeHours int `json:"affiliate_rebate_freeze_hours"` AffiliateRebateDurationDays int `json:"affiliate_rebate_duration_days"` AffiliateRebatePerInviteeCap float64 `json:"affiliate_rebate_per_invitee_cap"` + AffiliateRebateCap float64 `json:"affiliate_rebate_cap"` + AffiliateRebateInviteeLimit int `json:"affiliate_rebate_invitee_limit"` + AffiliateSignupBonus float64 `json:"affiliate_signup_bonus"` AdminRechargeRebateEnabled bool `json:"affiliate_admin_recharge_enabled"` DefaultUserRPMLimit int `json:"default_user_rpm_limit"` DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"` @@ -305,13 +310,23 @@ type SystemSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` - ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings; empty account mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` GrokCrossClientModelMapEnabled bool `json:"grok_cross_client_model_map_enabled"` GrokDefaultBaseURLMode string `json:"grok_default_base_url_mode"` + // Kiro runtime defaults (admin-only; not exposed on PublicSettings). + KiroDefaultVersion string `json:"kiro_version"` + KiroDefaultCommit string `json:"kiro_commit"` + KiroDefaultSystemVersion string `json:"system_version"` + KiroDefaultNodeVersion string `json:"node_version"` + KiroCacheHitRateScale int `json:"cache_hit_rate_scale"` + KiroCacheMinBlockTokens int `json:"cache_min_block_tokens"` + KiroCacheIndependentTTLSeconds int `json:"cache_independent_ttl_seconds"` + KiroCachePrefixTTLSeconds int `json:"cache_prefix_ttl_seconds"` + KiroCodeExecutionSandboxCommand string `json:"kiro_code_execution_sandbox_command"` + // Available Channels feature switch (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` @@ -330,6 +345,14 @@ type SystemSettings struct { // Affiliate (邀请返利) feature switch AffiliateEnabled bool `json:"affiliate_enabled"` + // Ticket feature switch (default enabled) + TicketEnabled bool `json:"ticket_enabled"` + + IPMultiAccountBanEnabled bool `json:"ip_multi_account_ban_enabled"` + IPMultiAccountBanWindowMinutes int `json:"ip_multi_account_ban_window_minutes"` + IPMultiAccountBanThreshold int `json:"ip_multi_account_ban_threshold"` + IPMultiAccountBanLearningUntil string `json:"ip_multi_account_ban_learning_until"` + // OpenAI fast/flex policy OpenAIFastPolicySettings *OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"` @@ -349,58 +372,61 @@ type DefaultSubscriptionSetting struct { } type PublicSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 - PasskeyEnabled bool `json:"passkey_enabled"` - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementRevision string `json:"login_agreement_revision"` - LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` - TencentCaptchaAppID string `json:"tencent_captcha_app_id"` - TencentCaptchaRegion string `json:"tencent_captcha_region"` - AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` - AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` - AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` - AliyunCaptchaRegion string `json:"aliyun_captcha_region"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - CompactHomeEnabled bool `json:"compact_home_enabled"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` - CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` - DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` - WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` - WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` - WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` - OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` - OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` - GitHubOAuthEnabled bool `json:"github_oauth_enabled"` - GoogleOAuthEnabled bool `json:"google_oauth_enabled"` - BackendModeEnabled bool `json:"backend_mode_enabled"` - PaymentEnabled bool `json:"payment_enabled"` - Version string `json:"version"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + PasskeyEnabled bool `json:"passkey_enabled"` + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementRevision string `json:"login_agreement_revision"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + TencentCaptchaRegion string `json:"tencent_captcha_region"` + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + SupportQRCodes []service.SupportQRCodeEntry `json:"support_qr_codes"` + DownloadToolsURL string `json:"download_tools_url"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + CompactHomeEnabled bool `json:"compact_home_enabled"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` + WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` + WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` + WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + SoraClientEnabled bool `json:"sora_client_enabled"` + BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` + Version string `json:"version"` // 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。 // 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。 ServerTimezone string `json:"server_timezone"` @@ -414,7 +440,6 @@ type PublicSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` - ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` @@ -422,6 +447,7 @@ type PublicSettings struct { ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` AffiliateEnabled bool `json:"affiliate_enabled"` + TicketEnabled bool `json:"ticket_enabled"` RiskControlEnabled bool `json:"risk_control_enabled"` @@ -442,8 +468,12 @@ type OverloadCooldownSettings struct { // RateLimit429CooldownSettings 429默认回避配置 DTO type RateLimit429CooldownSettings struct { - Enabled bool `json:"enabled"` - CooldownSeconds int `json:"cooldown_seconds"` + Strategy string `json:"strategy"` + RetryIntervalMs int `json:"retry_interval_ms"` + RetryMaxDurationSeconds int `json:"retry_max_duration_seconds"` + MaxAccountSwitches int `json:"max_account_switches"` + Enabled bool `json:"enabled"` + CooldownSeconds int `json:"cooldown_seconds"` } // PanelRateLimitSettings 面板 API 限流配置 DTO diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go index d349980fc6..71a0f0615d 100644 --- a/backend/internal/handler/failover_loop.go +++ b/backend/internal/handler/failover_loop.go @@ -56,13 +56,16 @@ const ( const profitVetoExhaustedMessage = "No available accounts: all candidates rejected by group profit control" func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryCount int) time.Duration { - if failoverErr == nil { - return sameAccountRetryDelay + // An OAuth 429 with a request-scoped deadline intentionally retries + // immediately; zero is meaningful here and must not fall back to 500ms. + if failoverErr != nil && failoverErr.StatusCode == http.StatusTooManyRequests && + !failoverErr.SameAccountRetryDeadline.IsZero() && failoverErr.SameAccountRetryDelay <= 0 { + return 0 } - if failoverErr.SameAccountRetryDelay > 0 { + if failoverErr != nil && failoverErr.SameAccountRetryDelay > 0 { return failoverErr.SameAccountRetryDelay } - if !failoverErr.RequestScopedTransient || retryCount <= 1 { + if failoverErr == nil || !failoverErr.RequestScopedTransient || retryCount <= 1 { return sameAccountRetryDelay } @@ -76,10 +79,43 @@ func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryC return delay } -// sameAccountRetryDeadlineAllows prevents a retry from starting after the -// service-provided same-account retry window has elapsed. -func sameAccountRetryDeadlineAllows(failoverErr *service.UpstreamFailoverError) bool { - return failoverErr == nil || failoverErr.SameAccountRetryDeadline.IsZero() || time.Now().Before(failoverErr.SameAccountRetryDeadline) +func sameAccountRetryAllowed(failoverErr *service.UpstreamFailoverError, retryCount, retryLimit int) bool { + if failoverErr == nil || !failoverErr.RetryableOnSameAccount { + return false + } + if retryLimit > 0 && retryCount >= retryLimit { + return false + } + if !failoverErr.SameAccountRetryDeadline.IsZero() { + return time.Now().Before(failoverErr.SameAccountRetryDeadline) + } + return retryCount < retryLimit +} + +func pinSameAccountRetryContext( + ctx context.Context, + fs *FailoverState, + accountID int64, + groupID *int64, + failoverErr *service.UpstreamFailoverError, + prevRetryCount int, + prevSwitchCount int, + bridgeOldKeys bool, +) context.Context { + if ctx == nil || fs == nil || failoverErr == nil || !failoverErr.RetryableOnSameAccount { + return ctx + } + if fs.SwitchCount != prevSwitchCount { + return ctx + } + if fs.SameAccountRetryCount[accountID] != prevRetryCount+1 { + return ctx + } + prefetchedGroupID := int64(0) + if groupID != nil { + prefetchedGroupID = *groupID + } + return service.WithPrefetchedStickySession(ctx, accountID, prefetchedGroupID, bridgeOldKeys) } // FailoverState 跨循环迭代共享的 failover 状态 @@ -132,6 +168,24 @@ func (s *FailoverState) RecordProfitVeto(accountID int64) FailoverAction { return FailoverContinue } +// RecordConcurrencyTimeout excludes a busy account after the slot ladder +// (deadline two-shot or wait-queue full) and continues onto another account +// while the original sticky binding is preserved by the caller. +func (s *FailoverState) RecordConcurrencyTimeout(accountID int64) FailoverAction { + if s == nil { + return FailoverExhausted + } + if s.FailedAccountIDs == nil { + s.FailedAccountIDs = make(map[int64]struct{}) + } + s.FailedAccountIDs[accountID] = struct{}{} + if s.SwitchCount >= s.MaxSwitches { + return FailoverExhausted + } + s.SwitchCount++ + return FailoverContinue +} + // ProfitVetoCount 返回本次请求累计的利润否决次数(供日志使用)。 func (s *FailoverState) ProfitVetoCount() int { return s.profitVetoCount } @@ -169,27 +223,27 @@ func (s *FailoverState) HandleFailoverError( return FailoverExhausted } - // 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。 - retryCount := s.SameAccountRetryCount[accountID] - sameAccountRetryAllowed := failoverErr.RetryableOnSameAccount && retryLimit > 0 && retryCount < retryLimit - if sameAccountRetryAllowed && !failoverErr.SameAccountRetryDeadline.IsZero() { - sameAccountRetryAllowed = time.Now().Before(failoverErr.SameAccountRetryDeadline) + retryMax := retryLimit + if failoverErr.SameAccountRetryMax > 0 { + retryMax = failoverErr.SameAccountRetryMax } - sameAccountRetry := sameAccountRetryAllowed + + // 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。 + sameAccountRetry := sameAccountRetryAllowed(failoverErr, s.SameAccountRetryCount[accountID], retryMax) if needForceCacheBilling(s.hasBoundSession, failoverErr, sameAccountRetry) { s.ForceCacheBilling = true } // 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试。 // 重试次数上限 retryLimit 由调用方传入(账号级 pool_mode_retry_count 配置)。 - if sameAccountRetryAllowed { + if sameAccountRetryAllowed(failoverErr, s.SameAccountRetryCount[accountID], retryMax) { s.SameAccountRetryCount[accountID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, s.SameAccountRetryCount[accountID]) logger.FromContext(ctx).Warn("gateway.failover_same_account_retry", zap.Int64("account_id", accountID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("same_account_retry_count", s.SameAccountRetryCount[accountID]), - zap.Int("same_account_retry_max", retryLimit), + zap.Int("same_account_retry_max", retryMax), zap.Duration("retry_delay", retryDelay), ) if !sleepWithContext(ctx, retryDelay) { @@ -205,6 +259,11 @@ func (s *FailoverState) HandleFailoverError( // 加入失败列表 s.FailedAccountIDs[accountID] = struct{}{} + for _, excludedAccountID := range failoverErr.ExcludedAccountIDs { + if excludedAccountID > 0 { + s.FailedAccountIDs[excludedAccountID] = struct{}{} + } + } // 检查是否耗尽 if s.SwitchCount >= s.MaxSwitches { diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 6853cc8487..dd683262f4 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -11,13 +11,34 @@ import ( const ( openAIAccountStateUpdateTimeout = 5 * time.Second - openAIOAuth429FallbackCooldown = 5 * time.Second + openAIOAuth429RetryWindow = 2 * time.Minute + openAIOAuth429RetryDelay = 0 openAIStopSchedulingBridgeCooldown = 2 * time.Minute + openAIOAuth429MaxAccountAttempts = 3 openAIOAuth429StormWindow = 10 * time.Second openAIOAuth429StormThreshold = 20 openAIOAuth429StormMaxAccountSwitches = 1 ) +func (s *OpenAIGatewayService) rateLimit429StrategySettings() RateLimit429CooldownSettings { + defaults := DefaultRateLimit429CooldownSettings() + if s == nil || s.settingService == nil { + return *defaults + } + s.openai429StrategyMu.Lock() + defer s.openai429StrategyMu.Unlock() + if time.Since(s.openai429StrategyCachedAt) < 5*time.Second { + return s.openai429StrategyCached + } + settings := *defaults + if loaded, err := s.settingService.GetRateLimit429CooldownSettings(context.Background()); err == nil && loaded != nil { + settings = *loaded + } + s.openai429StrategyCached = settings + s.openai429StrategyCachedAt = time.Now() + return settings +} + // OpenAIOAuth429FailoverState tracks the request-local follow-up budget after // the first Grok OAuth 429. Once that 429 occurs, exactly one different account // may be attempted; any failure from that follow-up account ends failover. @@ -55,11 +76,6 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if s != nil { scheduleOllamaCloudUsageActivity(s.deferredService, account) } - // Capacity shedding describes this request, not account health. Keep the - // account schedulable while the request-local retry budget handles recovery. - if account != nil && account.Platform == PlatformOpenAI && isOpenAIRequestScopedCapacityShed("", responseBody) { - return false - } stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() @@ -77,10 +93,6 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if s == nil || account == nil { return false } - // Team 联动熔断必须先于 model-not-found 与账户级临时不可调度规则的早退。 - if s.rateLimitService != nil { - s.rateLimitService.maybeHandleOpenAITeamLinkedError(stateCtx, account, statusCode, responseBody) - } stateCtx = withTempUnschedulableModel(stateCtx, canonicalModel) if s.rateLimitService != nil && len(canonicalModel) > 0 && s.rateLimitService.HandleUpstreamModelNotFound(stateCtx, account, canonicalModel[0], statusCode, responseBody) { return true @@ -149,20 +161,164 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context return } s.recordOpenAIOAuth429() + if s.ShouldRetryOpenAIOAuth429(account, headers, responseBody) { + return + } - cooldownUntil := time.Now().Add(openAIOAuth429FallbackCooldown) + cooldownUntil := time.Time{} + hasCooldown := false if s.rateLimitService != nil { if resetAt := s.rateLimitService.calculateOpenAI429ResetTime(headers); resetAt != nil && resetAt.After(time.Now()) { cooldownUntil = *resetAt + hasCooldown = true } else if resetUnix := parseOpenAIRateLimitResetTime(responseBody); resetUnix != nil { if resetAt := time.Unix(*resetUnix, 0); resetAt.After(time.Now()) { cooldownUntil = resetAt + hasCooldown = true } } else if cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account); ok && cooldown > 0 { cooldownUntil = time.Now().Add(cooldown) + hasCooldown = true } } + if !hasCooldown { + // The request-local retry window has expired without an upstream reset + // signal. Keep the account out of new selections while this request + // switches to another candidate, rather than immediately selecting it + // again on a concurrent request. + cooldownUntil = time.Now().Add(openAIStopSchedulingBridgeCooldown) + } s.BlockAccountScheduling(account, cooldownUntil, "429") + s.openaiOAuth429RetryStartedAt.Delete(account.ID) +} + +// shouldRetryOpenAIOAuth429OnSameAccount keeps an OAuth account pinned while +// a transient 429 is still inside its retry window. API-key accounts keep the +// existing pool-mode behavior. +func (s *OpenAIGatewayService) shouldRetryOpenAIOAuth429OnSameAccount(account *Account, statusCode int, shouldDisable bool) bool { + if shouldDisable || account == nil { + return false + } + if statusCode == http.StatusTooManyRequests && isOpenAIOAuthAccount(account) && !account.IsShadow() { + if s.settingService != nil && s.rateLimit429StrategySettings().Strategy != "same_account_retry" { + return false + } + // A prior retry window may already have expired and parked this account. + // Do not create a fresh window while that runtime block is active. + if s.isOpenAIAccountRuntimeBlocked(account) { + return false + } + return s.openAIOAuth429RetryWindowActive(account) + } + return account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode) +} + +// ShouldRetryOpenAIOAuth429 is used before persisting a scheduler block. An +// upstream-provided reset takes precedence; only temporary 429s without one +// stay on the same OAuth account during the retry window. +func (s *OpenAIGatewayService) ShouldRetryOpenAIOAuth429(account *Account, headers http.Header, responseBody []byte) bool { + if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() { + return false + } + if s.isOpenAIAccountRuntimeBlocked(account) { + return false + } + if s.settingService != nil && s.rateLimit429StrategySettings().Strategy != "same_account_retry" { + return false + } + if s.rateLimitService != nil && s.rateLimitService.calculateOpenAI429ResetTime(headers) != nil { + return false + } + if parseOpenAIRateLimitResetTime(responseBody) != nil { + return false + } + return s.openAIOAuth429RetryWindowActive(account) +} + +func (s *OpenAIGatewayService) openAIOAuth429RetryWindowActive(account *Account) bool { + if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() { + return false + } + now := time.Now() + value, _ := s.openaiOAuth429RetryStartedAt.LoadOrStore(account.ID, now) + startedAt, ok := value.(time.Time) + if !ok { + s.openaiOAuth429RetryStartedAt.Store(account.ID, now) + startedAt = now + } + window := openAIOAuth429RetryWindow + if s.settingService != nil { + window = time.Duration(s.rateLimit429StrategySettings().RetryMaxDurationSeconds) * time.Second + } + return now.Sub(startedAt) < window +} + +func openAIOAuth429SameAccountRetryDelay(statusCode int, account *Account) time.Duration { + if statusCode == http.StatusTooManyRequests && isOpenAIOAuthAccount(account) && !account.IsShadow() { + return openAIOAuth429RetryDelay + } + return 0 +} + +func (s *OpenAIGatewayService) openAIOAuth429SameAccountRetryDelay(statusCode int, account *Account) time.Duration { + if statusCode == http.StatusTooManyRequests && isOpenAIOAuthAccount(account) && !account.IsShadow() && s != nil && s.settingService != nil { + return time.Duration(s.rateLimit429StrategySettings().RetryIntervalMs) * time.Millisecond + } + return openAIOAuth429SameAccountRetryDelay(statusCode, account) +} + +// openAIOAuth429RetryDeadline returns the request-local retry window end that +// was established when the account first saw a temporary OAuth 429. +func (s *OpenAIGatewayService) openAIOAuth429RetryDeadline(account *Account) time.Time { + if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() { + return time.Time{} + } + value, ok := s.openaiOAuth429RetryStartedAt.Load(account.ID) + if !ok { + return time.Time{} + } + startedAt, ok := value.(time.Time) + if !ok { + return time.Time{} + } + window := openAIOAuth429RetryWindow + if s.settingService != nil { + window = time.Duration(s.rateLimit429StrategySettings().RetryMaxDurationSeconds) * time.Second + } + return startedAt.Add(window) +} + +// SameAccountRetryLimit returns the request-local retry budget. OAuth 429s +// deliberately use a time-derived budget rather than an account pool setting. +func SameAccountRetryLimit(account *Account, failoverErr *UpstreamFailoverError) int { + if failoverErr != nil && failoverErr.StatusCode == http.StatusTooManyRequests && + isOpenAIOAuthAccount(account) && !account.IsShadow() { + if failoverErr.SameAccountRetryMax > 0 { + return failoverErr.SameAccountRetryMax + } + return 24 + } + if account == nil { + return 0 + } + return account.GetPoolModeRetryCount() +} + +func (s *OpenAIGatewayService) openAIOAuth429SameAccountRetryMax() int { + if s == nil || s.settingService == nil { + return 24 + } + settings := s.rateLimit429StrategySettings() + interval := time.Duration(settings.RetryIntervalMs) * time.Millisecond + window := time.Duration(settings.RetryMaxDurationSeconds) * time.Second + max := int(window / interval) + if max < 1 { + max = 1 + } + if max > 240 { + max = 240 + } + return max } func (s *OpenAIGatewayService) BlockAccountScheduling(account *Account, until time.Time, reason string) { @@ -345,7 +501,11 @@ func (s *OpenAIGatewayService) isOpenAIOAuth429Storm() bool { } func (s *OpenAIGatewayService) ShouldStopOpenAIOAuth429Failover(account *Account, statusCode int, failedSwitches int, state *OpenAIOAuth429FailoverState) bool { - if failedSwitches < openAIOAuth429StormMaxAccountSwitches { + maxSwitches := openAIOAuth429StormMaxAccountSwitches + if s != nil && s.settingService != nil { + maxSwitches = s.rateLimit429StrategySettings().MaxAccountSwitches + } + if failedSwitches < maxSwitches { return false } if state != nil && state.grokOAuth429FollowupPending { @@ -368,5 +528,9 @@ func (s *OpenAIGatewayService) ShouldStopOpenAIOAuth429Failover(account *Account if statusCode != http.StatusTooManyRequests || !isOpenAIOAuthAccount(account) { return false } - return s.isOpenAIOAuth429Storm() + // failedSwitches is incremented after each exhausted candidate. Therefore, + // a value of three means this request has already given three distinct OAuth + // accounts their full same-account retry window. A 429 storm is diagnostic + // only; it must not skip those candidates and return a client 429 early. + return failedSwitches >= maxSwitches+1 } diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index ce9dd54d37..66eb53575d 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -125,13 +125,19 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError( if account.Platform != PlatformGrok && !tempUnscheduled { shouldDisable = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel) } - return newOpenAIUpstreamFailoverError( + failoverErr := newOpenAIUpstreamFailoverError( resp.StatusCode, resp.Header, respBody, upstreamMsg, - !shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), + s.shouldRetryOpenAIOAuth429OnSameAccount(account, resp.StatusCode, shouldDisable) || (!shouldDisable && account.IsPoolMode() && isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), ) + if failoverErr.RetryableOnSameAccount { + failoverErr.SameAccountRetryDelay = s.openAIOAuth429SameAccountRetryDelay(resp.StatusCode, account) + failoverErr.SameAccountRetryDeadline = s.openAIOAuth429RetryDeadline(account) + failoverErr.SameAccountRetryMax = s.openAIOAuth429SameAccountRetryMax() + } + return failoverErr } // openAIChatCompletionsTargetURL 解析账号的(非 Grok)Chat Completions 上游端点。 @@ -150,7 +156,7 @@ func (s *OpenAIGatewayService) openAIChatCompletionsTargetURL(account *Account) // resolveCCFallbackTarget 解析两条 CC 回退路径共用的账号凭证与上游端点 // (回退路径仅面向 APIKey 账号,凭证恒为 openai api_key)。 func (s *OpenAIGatewayService) resolveCCFallbackTarget(account *Account) (apiKey string, targetURL string, err error) { - apiKey = strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + apiKey = account.GetOpenAIApiKey() if apiKey == "" { return "", "", fmt.Errorf("account %d missing api_key", account.ID) } @@ -208,7 +214,9 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( if account.Platform == PlatformGrok { if account.IsGrokOAuth() { - applyGrokCLIHeaders(upstreamReq.Header) + if err := applyGrokInteractiveUpstreamHeadersFromAccount(ctx, upstreamReq, account); err != nil { + return nil, err + } } applyGrokCacheHeaders(upstreamReq.Header, grokCacheIdentity) } @@ -220,7 +228,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( if account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.EffectiveConcurrency()) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index ec5fafdb72..95f742b6e4 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -21,7 +21,6 @@ import ( func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) clearGrokResponsesClientToolMapping(c) - clearOpenAIResponsesClientToolMapping(c) clearOpenAIResponsesNamespaceNames(c) startTime := time.Now() // 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。 @@ -40,6 +39,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco }) return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed") } + if c != nil && c.Request != nil { + maybeLearnOfficialDeviceProfile(ctx, account, c.Request.Header) + } normalizedBody, normalized, err := normalizeOpenAICodexCompactReasoningEffortForAccount(c, account, body) if err != nil { @@ -104,21 +106,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco requestView := newOpenAIRequestView(body) reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey originalModel := reqModel - nativeDeepSeekResponses := account.Platform == PlatformDeepseek && - (account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol()) if account.Platform == PlatformGrok { return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) } - // CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合 - // (Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。 - // 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。 - if account.IsAnthropicProtocol() { - return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel) - } - - if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) } if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey { @@ -288,7 +281,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco instructions := gjson.GetBytes(body, "instructions") instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && !compatMessagesBridge && !nativeDeepSeekResponses { + if instructionsEmpty && !compatMessagesBridge { markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) } @@ -420,35 +413,22 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if codexResult.Modified { markDecodedModified() } - // 带真实 device_id 时补齐 client_metadata 安装标识,与真实 Codex 对齐(compact 形态不同,跳过)。 - if !isCompactRequest && applyCodexClientMetadata(decoded, account) { - markDecodedModified() - } - stageCodexFingerprintIDs(c, nil) - // 指纹收敛:一次性解析收敛 ID,请求体和出站头共享同一份 IDs(保证 turn_id 等随机字段一致)。 - // fingerprintIDs 在此处解析,后续 buildUpstreamRequest 中使用同一份。 + // 一次加载档案:请求体 client_metadata 与出站头共享同一份 fingerprint IDs。 + // compact 形态不同,跳过。fpIDs == nil 时不单独盖 installation id。 if !isCompactRequest { var clientHeaders http.Header if c != nil && c.Request != nil { clientHeaders = c.Request.Header } - fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + fpIDs := applyCodexForwardRequestIdentity(ctx, c, decoded, account, clientHeaders) if fpIDs != nil { - if applyCodexFingerprintClientMetadata(decoded, fpIDs) { - markDecodedModified() - } + markDecodedModified() } - // 将 fpIDs 存入 gin context,供 buildUpstreamRequest 中头改写使用。 - // 无条件覆写(含 nil):failover 从收敛账号切到 off 账号时,上一 - // 账号的 IDs 不得残留(stageCodexFingerprintIDs 注释)。 - stageCodexFingerprintIDs(c, fpIDs) } if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel } - if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" { - promptCacheKey = currentPromptCacheKey - } else if codexResult.PromptCacheKey != "" { + if codexResult.PromptCacheKey != "" { promptCacheKey = codexResult.PromptCacheKey } } @@ -461,7 +441,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco maxOutputTokens := gjson.GetBytes(body, "max_output_tokens") if maxOutputTokens.Exists() { switch account.Platform { - case PlatformOpenAI, PlatformDeepseek: + case PlatformOpenAI: // Preserve Responses-native output limits unless the selected upstream // explicitly rejects the field in the bounded HTTP retry loop below. case PlatformAnthropic: @@ -855,7 +835,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // Send request upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doAccountHTTP(ctx, c, account, upstreamReq, proxyURL, "responses") SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if headerGuard != nil && headerGuard.stopHeaderWait() { if resp != nil && resp.Body != nil { @@ -949,13 +929,19 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco }) shouldDisable := s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel) - return nil, newOpenAIUpstreamFailoverError( + failoverErr := newOpenAIUpstreamFailoverError( resp.StatusCode, resp.Header, respBody, upstreamMsg, - !shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), + s.shouldRetryOpenAIOAuth429OnSameAccount(account, resp.StatusCode, shouldDisable) || (!shouldDisable && account.IsPoolMode() && isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), ) + if failoverErr.RetryableOnSameAccount { + failoverErr.SameAccountRetryDelay = s.openAIOAuth429SameAccountRetryDelay(resp.StatusCode, account) + failoverErr.SameAccountRetryDeadline = s.openAIOAuth429RetryDeadline(account) + failoverErr.SameAccountRetryMax = s.openAIOAuth429SameAccountRetryMax() + } + return nil, failoverErr } return s.handleErrorResponse(ctx, resp, c, account, body, billingModel) } @@ -1041,25 +1027,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } -func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { - if account == nil || account.Type != AccountTypeAPIKey { - return false - } - if account.IsCNProvider() { - // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生 - // Responses,Kimi/GLM 回退 Chat Completions。 - switch account.GetAPIProtocol() { - case APIProtocolChatCompletions: - return true - case APIProtocolAdaptive: - return account.Platform != PlatformDeepseek - default: - return false - } - } - return !openai_compat.ShouldUseResponsesAPI(account.Extra) -} - func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) { // Determine target URL based on account type var targetURL string @@ -1070,9 +1037,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. case AccountTypeAPIKey: // API Key accounts use Platform API or custom base URL baseURL := account.GetOpenAIBaseURL() - if account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol() { - baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses) - } if baseURL == "" { targetURL = openaiPlatformAPIURL } else { @@ -1080,17 +1044,13 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if err != nil { return nil, err } - targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) + targetURL = buildOpenAIResponsesURL(validatedURL) } default: targetURL = openaiPlatformAPIURL } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) - // DeepSeek 原生 Responses 端点为无状态实现:强制 store=false、清除 - // previous_response_id,避免携带状态字段被上游拒绝。 - body = normalizeDeepSeekResponsesRequestBody(account, body) - req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, err @@ -1127,9 +1087,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. } } } - // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号), - // 剥离后再出站——异账号 blob 与本账号的(指纹收敛后)出站身份自相矛盾。 - s.guardOpenAICodexTurnStateEcho(c, account, req.Header) if account.Type == AccountTypeOAuth { compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body) // 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。 @@ -1144,18 +1101,19 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) } apiKeyID := getAPIKeyIDFromContext(c) + profile := resolveOpenAIOutboundDeviceProfile(ctx, c, account) if isOpenAIResponsesCompactPath(c) { req.Header.Set("accept", "application/json") if req.Header.Get("version") == "" { - req.Header.Set("version", CodexCanonicalClientVersion()) + req.Header.Set("version", codexCLIVersion) } compactSession := resolveOpenAICompactSessionID(c) - req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession)) + req.Header.Set("session_id", openaiOutboundSessionIDFromProfile(profile, apiKeyID, compactSession)) } else { req.Header.Set("accept", "text/event-stream") } if promptCacheKey != "" { - isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey) + isolated := openaiOutboundSessionIDFromProfile(profile, apiKeyID, promptCacheKey) req.Header.Set("session_id", isolated) if !compatMessagesBridge || clientConversationID != "" { req.Header.Set("conversation_id", isolated) @@ -1173,19 +1131,28 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("user-agent", customUA) } - // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为规范 Codex 身份。 + // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为 Codex CLI。 // 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。 if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("user-agent", CodexCanonicalUserAgent()) + req.Header.Set("user-agent", codexCLIUserAgent) } // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。 - applyStagedCodexFingerprintHeaders(c, account, req.Header) + // leftover 5 session/full 模式会用账号级恒定 session_id 覆盖上面的 + // isolate+namespace 值;那是「一号一安装」收敛,不是 leftover 11 的缺口。 + // leftover 11 在 off/device、以及不走指纹的 passthrough/WS/compat 路径生效。 + if account.Type == AccountTypeOAuth && c != nil { + if fpIDs, ok := c.Get("codex_fingerprint_ids"); ok { + if ids, ok := fpIDs.(*codexFingerprintIDs); ok && fingerprintIDsBelongToAccount(ids, account) { + applyCodexFingerprintHeaders(req.Header, ids) + } + } + } // 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。 // 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。 if account.Type == AccountTypeOAuth { - enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) + s.enforceCodexIdentityFromLoadedProfile(req.Header, account, outboundDeviceProfileFromGin(c, account)) } // Ensure required headers exist @@ -1195,9 +1162,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) - // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, - // 保证不被覆盖丢失)。 - applyOpenAICodexBetaFeatures(c, account, req.Header) setOpenAICodexRoutingHintFromBody(req.Header, account, body) logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http", req.Header, body, "not_applicable") @@ -1213,3 +1177,25 @@ func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string } return account.GetOpenAIUserAgent() } + +func (s *OpenAIGatewayService) enforceCodexIdentityFromAccount(ctx context.Context, h http.Header, account *Account) { + if account == nil { + s.enforceCodexIdentityFromLoadedProfile(h, account, nil) + return + } + profile, err := LoadOutboundDeviceProfile(ctx, account) + if err != nil { + profile = nil + } + s.enforceCodexIdentityFromLoadedProfile(h, account, profile) +} + +func (s *OpenAIGatewayService) enforceCodexIdentityFromLoadedProfile(h http.Header, account *Account, profile *AccountDeviceProfile) { + fallback := s.codexIdentityOverrideUA(account) + if profile == nil { + enforceCodexIdentityHeadersWithUA(h, fallback) + return + } + identity := resolveCodexOutboundIdentityFromProfile(profile, fallback) + enforceCodexIdentityHeadersWithUA(h, identity.userAgent) +} diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index c15a78ef48..b54fb4d594 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -16,8 +16,8 @@ import ( "strings" "time" - "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -25,84 +25,6 @@ import ( "go.uber.org/zap" ) -const openAIResponsesClientToolMappingContextKey = "openai_responses_client_tool_mapping" - -func hasOpenAIResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool { - return len(mapping.CustomTools) > 0 || mapping.ToolSearch || len(mapping.NamespaceTools) > 0 -} - -func adaptOpenAIResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) { - if !needsOpenAIResponsesClientToolAdaptation(body) { - return body, apicompat.ResponsesClientToolMapping{}, nil - } - - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.UseNumber() - var requestBody map[string]any - if err := decoder.Decode(&requestBody); err != nil { - return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools: %w", err) - } - var trailingValue any - if err := decoder.Decode(&trailingValue); !errors.Is(err, io.EOF) { - if err == nil { - err = errors.New("multiple JSON values") - } - return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools trailing data: %w", err) - } - mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody) - if err != nil || !changed { - return body, mapping, err - } - rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) - if err != nil { - return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("encode OpenAI Responses client tools: %w", err) - } - return rebuilt, mapping, nil -} - -func needsOpenAIResponsesClientToolAdaptation(body []byte) bool { - needsAdaptation := false - var visit func(gjson.Result) bool - visit = func(value gjson.Result) bool { - if value.IsObject() { - switch strings.TrimSpace(value.Get("type").String()) { - case "custom", "custom_tool_call", "custom_tool_call_output", - "tool_search", "tool_search_call", "tool_search_output": - needsAdaptation = true - return false - } - } - if value.IsObject() || value.IsArray() { - value.ForEach(func(_, child gjson.Result) bool { - return visit(child) - }) - } - return !needsAdaptation - } - visit(gjson.ParseBytes(body)) - return needsAdaptation -} - -func openAIResponsesClientToolMapping(c *gin.Context) (apicompat.ResponsesClientToolMapping, bool) { - if c == nil { - return apicompat.ResponsesClientToolMapping{}, false - } - value, ok := c.Get(openAIResponsesClientToolMappingContextKey) - mapping, typed := value.(apicompat.ResponsesClientToolMapping) - return mapping, ok && typed && hasOpenAIResponsesClientToolMapping(mapping) -} - -// clearOpenAIResponsesClientToolMapping removes mapping state from the prior -// forwarding attempt. Forward retries accounts on the same Gin context. -func clearOpenAIResponsesClientToolMapping(c *gin.Context) { - if c == nil { - return - } - if _, exists := c.Get(openAIResponsesClientToolMappingContextKey); exists { - c.Set(openAIResponsesClientToolMappingContextKey, apicompat.ResponsesClientToolMapping{}) - } -} - func (s *OpenAIGatewayService) forwardOpenAIPassthrough( ctx context.Context, c *gin.Context, @@ -158,39 +80,6 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( body = normalizedBody } reqStream = gjson.GetBytes(body, "stream").Bool() - - stageCodexFingerprintIDs(c, nil) - // 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。 - // 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科 - // 手术,透传热路径禁全量 Unmarshal),出站头改写由请求构造器读取 - // context 中的同一份 IDs 完成(turn_id 等随机字段两侧必须一致)。 - if !isOpenAIResponsesCompactPath(c) { - var clientHeaders http.Header - if c != nil && c.Request != nil { - clientHeaders = c.Request.Header - } - fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) - if fpIDs != nil { - fpBody, fpChanged, fpErr := applyCodexFingerprintClientMetadataRaw(body, fpIDs) - if fpErr != nil { - return nil, fpErr - } - if fpChanged { - body = fpBody - } - } - stageCodexFingerprintIDs(c, fpIDs) - } - } - - if account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && - !isOpenAIResponsesCompactPath(c) && needsOpenAIResponsesClientToolAdaptation(body) { - adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body) - if adaptErr != nil { - return nil, adaptErr - } - body = adaptedBody - c.Set(openAIResponsesClientToolMappingContextKey, mapping) } sanitizedBody, sanitized, err := sanitizeEmptyBase64InputImagesInOpenAIBody(body) @@ -313,7 +202,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } upstreamStart := time.Now() - resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err = s.doAccountHTTP(ctx, c, account, upstreamReq, proxyURL, "responses") SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { // Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to @@ -347,23 +236,9 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( return nil, s.handleErrorResponsePassthrough(ctx, resp, c, account, body, probeBody) } defer func() { _ = resp.Body.Close() }() - if mapping, ok := openAIResponsesClientToolMapping(c); ok && isEventStreamResponse(resp.Header) { - maxLineSize := defaultMaxLineSize - if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { - maxLineSize = s.cfg.Gateway.MaxLineSize - } - resp.Body = newGrokResponsesClientToolStreamBody(resp.Body, mapping, maxLineSize) - } serviceTier := extractOpenAIServiceTierFromBody(body) - // x-codex-turn-state 溯源:下游回传由 writeOpenAIPassthroughResponseHeaders - // 在各 handler 的写头点强制放行,铸造账号在此统一记录,供出站守卫剥离 - // failover 换号后的跨账号回带(openai_codex_turn_state.go)。 - if extractOpenAICodexTurnState(resp.Header) != "" { - s.noteOpenAICodexTurnStateProvenance(c, account) - } - var usage *OpenAIUsage var firstTokenMs *int responseID := "" @@ -476,14 +351,11 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if err != nil { return nil, err } - targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) + targetURL = buildOpenAIResponsesURL(validatedURL) } } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) - // DeepSeek 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。 - body = normalizeDeepSeekResponsesRequestBody(account, body) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { return nil, err @@ -504,10 +376,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( } } - // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号), - // 剥离后再出站(openai_codex_turn_state.go)。 - s.guardOpenAICodexTurnStateEcho(c, account, req.Header) - // 覆盖入站鉴权残留,并注入上游认证 req.Header.Del("authorization") req.Header.Del("x-api-key") @@ -540,7 +408,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if isOpenAIResponsesCompactPath(c) { req.Header.Set("accept", "application/json") if req.Header.Get("version") == "" { - req.Header.Set("version", CodexCanonicalClientVersion()) + req.Header.Set("version", codexCLIVersion) } if clientSessionID == "" { clientSessionID = resolveOpenAICompactSessionID(c) @@ -549,7 +417,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("accept", "text/event-stream") } if req.Header.Get("originator") == "" { - req.Header.Set("originator", resolveCodexOutboundIdentity("").originator) + req.Header.Set("originator", openai.CodexDefaultOriginator) } // 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。 if clientSessionID == "" { @@ -558,11 +426,14 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if clientConversationID == "" { clientConversationID = promptCacheKey } - if clientSessionID != "" { - req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, clientSessionID)) - } - if clientConversationID != "" { - req.Header.Set("conversation_id", isolateOpenAISessionID(apiKeyID, clientConversationID)) + if clientSessionID != "" || clientConversationID != "" { + sessionID, conversationID := openaiOutboundSessionPair(ctx, account, apiKeyID, clientSessionID, clientConversationID) + if clientSessionID != "" { + req.Header.Set("session_id", sessionID) + } + if clientConversationID != "" { + req.Header.Set("conversation_id", conversationID) + } } } else if isOpenAIResponsesCompactPath(c) { // 透传白名单会放行客户端的 Accept: text/event-stream;compact 上游是 @@ -577,16 +448,12 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("user-agent", customUA) } if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("user-agent", CodexCanonicalUserAgent()) + req.Header.Set("user-agent", codexCLIUserAgent) } - // 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头, - // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置: - // 会话隔离之后、终态身份收口之前)。 - applyStagedCodexFingerprintHeaders(c, account, req.Header) // 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份 // (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。 if account.Type == AccountTypeOAuth { - enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) + s.enforceCodexIdentityFromAccount(ctx, req.Header, account) } if req.Header.Get("content-type") == "" { @@ -595,9 +462,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) - // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, - // 保证不被覆盖丢失)。 - applyOpenAICodexBetaFeatures(c, account, req.Header) setOpenAICodexRoutingHintFromBody(req.Header, account, body) logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http_passthrough", req.Header, body, "not_applicable") @@ -774,13 +638,19 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough( Detail: upstreamDetail, UpstreamResponseBody: upstreamDetail, }) - return newOpenAIUpstreamFailoverError( + failoverErr := newOpenAIUpstreamFailoverError( resp.StatusCode, resp.Header, body, upstreamMsg, - !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + s.shouldRetryOpenAIOAuth429OnSameAccount(account, resp.StatusCode, shouldDisable), ) + if failoverErr.RetryableOnSameAccount { + failoverErr.SameAccountRetryDelay = s.openAIOAuth429SameAccountRetryDelay(resp.StatusCode, account) + failoverErr.SameAccountRetryDeadline = s.openAIOAuth429RetryDeadline(account) + failoverErr.SameAccountRetryMax = s.openAIOAuth429SameAccountRetryMax() + } + return failoverErr } func (s *OpenAIGatewayService) handleErrorResponsePassthrough( @@ -908,19 +778,6 @@ type openaiNonStreamingResultPassthrough struct { imageOutputSizes []string } -const openAIStreamKeepaliveBytesKey = "openai_stream_keepalive_bytes" - -func recordOpenAIStreamKeepaliveBytes(c *gin.Context, written int) { - if c == nil || written <= 0 { - return - } - current := 0 - if value, ok := c.Get(openAIStreamKeepaliveBytesKey); ok { - current, _ = value.(int) - } - c.Set(openAIStreamKeepaliveBytesKey, current+written) -} - func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool { if localStarted { return true @@ -943,85 +800,6 @@ func openAIStreamEventIsPreamble(eventType string) bool { } } -func openAIStreamAddedEventStartsClientOutput(payload []byte, eventType string) bool { - if len(payload) == 0 || !gjson.ValidBytes(payload) { - return true - } - - switch strings.TrimSpace(eventType) { - case "response.output_item.added": - item := gjson.GetBytes(payload, "item") - if !item.Exists() || !item.IsObject() { - return true - } - switch strings.TrimSpace(item.Get("type").String()) { - case "reasoning": - if item.Get("encrypted_content").String() != "" { - return true - } - summary := item.Get("summary") - if !summary.IsArray() { - return false - } - for _, part := range summary.Array() { - if strings.TrimSpace(part.Get("type").String()) != "summary_text" || part.Get("text").String() != "" { - return true - } - } - return false - case "message": - content := item.Get("content") - if !content.IsArray() { - return false - } - for _, part := range content.Array() { - switch strings.TrimSpace(part.Get("type").String()) { - case "output_text": - if part.Get("text").String() != "" { - return true - } - case "refusal": - if part.Get("refusal").String() != "" { - return true - } - default: - return true - } - } - return false - case "function_call": - return item.Get("arguments").String() != "" - case "custom_tool_call": - return item.Get("input").String() != "" - case "compaction": - return item.Get("encrypted_content").String() != "" - default: - return true - } - case "response.content_part.added": - part := gjson.GetBytes(payload, "part") - if !part.Exists() || !part.IsObject() { - return true - } - switch strings.TrimSpace(part.Get("type").String()) { - case "output_text": - return part.Get("text").String() != "" - case "refusal": - return part.Get("refusal").String() != "" - default: - return true - } - case "response.reasoning_summary_part.added": - part := gjson.GetBytes(payload, "part") - if !part.Exists() || !part.IsObject() || strings.TrimSpace(part.Get("type").String()) != "summary_text" { - return true - } - return part.Get("text").String() != "" - default: - return true - } -} - func openAIStreamDataStartsClientOutput(data, eventType string) bool { trimmed := strings.TrimSpace(data) if trimmed == "" { @@ -1038,8 +816,6 @@ func openAIStreamDataStartsClientOutput(data, eventType string) bool { // (content_policy / invalid_request 等)维持原样转发,保留上游错误细节。 payload := []byte(trimmed) return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload)) - case "response.output_item.added", "response.content_part.added", "response.reasoning_summary_part.added": - return openAIStreamAddedEventStartsClientOutput([]byte(trimmed), eventType) } return !openAIStreamEventIsPreamble(eventType) } @@ -1118,34 +894,9 @@ func isOpenAIUpstreamCapacityShedEvent(payload []byte) bool { switch openAIStreamFailedEventErrorCode(payload) { case "server_is_overloaded", "slow_down": return true + default: + return false } - for _, path := range []string{"response.error.message", "error.message", "message"} { - if isOpenAICapacityShedMessage(gjson.GetBytes(payload, path).String()) { - return true - } - } - return false -} - -func logOpenAICapacityFailoverSuppressed( - ctx context.Context, - account *Account, - path string, - upstreamRequestID string, - eventType string, -) { - fields := []zap.Field{ - zap.String("path", path), - zap.String("event_type", strings.TrimSpace(eventType)), - zap.String("upstream_request_id", strings.TrimSpace(upstreamRequestID)), - } - if account != nil { - fields = append(fields, - zap.Int64("account_id", account.ID), - zap.String("platform", account.Platform), - ) - } - logger.FromContext(ctx).Warn("gateway.failover_suppressed_after_semantic_output", fields...) } // openAICapacityShedRetryableClientCode 是把上游容量降载错误转发给客户端时改写 @@ -1168,12 +919,9 @@ func sanitizeOpenAICapacityShedErrorCodeForClient(payload []byte) ([]byte, bool) updated := payload changed := false for _, path := range []string{"response.error.code", "error.code"} { - parent := strings.TrimSuffix(path, ".code") - if !gjson.GetBytes(updated, parent).Exists() { - continue - } - code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(updated, path).String())) - if code != "" && code != "server_is_overloaded" && code != "slow_down" { + switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(updated, path).String())) { + case "server_is_overloaded", "slow_down": + default: continue } next, err := sjson.SetBytes(updated, path, openAICapacityShedRetryableClientCode) @@ -1206,7 +954,7 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int { return http.StatusUnauthorized case strings.Contains(combined, "permission") || strings.Contains(combined, "forbidden") || strings.Contains(combined, "access denied"): return http.StatusForbidden - case isOpenAIUpstreamCapacityShedEvent(payload): + case code == "server_is_overloaded" || code == "slow_down": return http.StatusServiceUnavailable default: return http.StatusBadGateway @@ -1340,16 +1088,6 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool return true } -func openAIStreamErrorEventShouldFailover(payload []byte, message string) bool { - if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) != "error" { - return false - } - if isOpenAIContextWindowError(message, payload) { - return false - } - return isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) -} - func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool { if account == nil { return false @@ -1492,7 +1230,6 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( sawTerminalEvent := false sawFailedEvent := false semanticOutputSeen := false - capacityFailoverSuppressedLogged := false failedMessage := "" clientOutputStarted := false upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) @@ -1579,32 +1316,6 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( } } eventType := strings.TrimSpace(gjson.Get(trimmedData, "type").String()) - if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI && - (eventType == "error" || eventType == "response.failed") && - openAIStreamClientOutputStarted(c, clientOutputStarted) && - isOpenAIUpstreamCapacityShedEvent(dataBytes) { - logOpenAICapacityFailoverSuppressed(ctx, account, "passthrough_sse", upstreamRequestID, eventType) - capacityFailoverSuppressedLogged = true - } - if eventType == "error" && !openAIStreamClientOutputStarted(c, clientOutputStarted) { - errorMessage := extractOpenAISSEErrorMessage(dataBytes) - if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, errorMessage); matched { - s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, errorMessage) - MarkResponseCommitted(c) - c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") - c.JSON(status, gin.H{ - "error": gin.H{ - "type": errType, - "message": errMsg, - }, - }) - return resultWithUsage(), fmt.Errorf("upstream error event: passthrough rule matched message=%s", errMsg) - } - if openAIStreamErrorEventShouldFailover(dataBytes, errorMessage) { - return resultWithUsage(), - s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, errorMessage, resp.Header) - } - } if eventType == "response.failed" { failedMessage = extractOpenAISSEErrorMessage(dataBytes) // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析 @@ -1818,12 +1529,6 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( if err != nil { return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", err) } - if mapping, ok := openAIResponsesClientToolMapping(c); ok && json.Valid(body) { - body, _, err = apicompat.RestoreResponsesClientToolPayload(body, mapping) - if err != nil { - return nil, fmt.Errorf("restore OpenAI Responses client tools: %w", err) - } - } if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) { c.Data(resp.StatusCode, contentType, body) } @@ -1954,13 +1659,4 @@ func writeOpenAIPassthroughResponseHeaders(dst http.Header, src http.Header, fil dst.Add(key, v) } } - - // x-codex-turn-state:Codex 回合状态头,客户端会在同回合后续请求回带。 - // 与上面的用量头不同,这里在上游缺失时也主动清除——failover 换号后残留 - // 上一账号的 blob 会构成跨账号矛盾(openai_codex_turn_state.go)。 - turnStateKey := http.CanonicalHeaderKey(openAICodexTurnStateHeader) - dst.Del(turnStateKey) - for _, v := range getCaseInsensitiveValues(src, openAICodexTurnStateHeader) { - dst.Add(turnStateKey, v) - } } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 16082a16a4..dee8a6dbf2 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -61,7 +61,7 @@ const ( // 陈旧版本会被优先丢弃(HTTP 200 + 流内 server_is_overloaded);非官方客户端配不出 // 官方身份时整体回退到本常量,因此它必须跟随官方 CLI 的当前发布版本, // 落后多个版本会让这些请求稳定落在被优先丢弃的一侧。 - codexCLIVersion = "0.146.0" + codexCLIVersion = "0.147.0" // Codex 限额快照仅用于后台展示/诊断,不需要每个成功请求都立即落库。 openAICodexSnapshotPersistMinInterval = 30 * time.Second // 配额自动暂停时,超过该时长仍未刷新的 used% 快照视为陈旧,不再据此暂停账号。 @@ -284,9 +284,8 @@ type OpenAIForwardResult struct { // AudioUsage carries Voice billing units when present. AudioUsage *AudioUsage - wsReplayInput []json.RawMessage - wsReplayInputExists bool - wsAccountFailoverReplayInput []json.RawMessage + wsReplayInput []json.RawMessage + wsReplayInputExists bool } // SucceededForScheduling reports whether this result is an upstream success @@ -399,8 +398,8 @@ func (t *accountWriteThrottle) Allow(id int64, now time.Time) bool { var defaultOpenAICodexSnapshotPersistThrottle = newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval) -// ErrNoAvailableCompactAccounts indicates a legacy /responses/compact request -// needs compact support but no compatible account is available. +// ErrNoAvailableCompactAccounts indicates the request needs /responses/compact +// support but no compatible account is available. var ErrNoAvailableCompactAccounts = errors.New("no available accounts support /responses/compact") // OpenAIGatewayService handles OpenAI API gateway operations @@ -429,6 +428,8 @@ type OpenAIGatewayService struct { channelService *ChannelService balanceNotifyService *BalanceNotifyService settingService *SettingService + tlsFPProfileService *TLSFingerprintProfileService + tlsFPRouterService *TLSFingerprintRouterService userPlatformQuotaRepo UserPlatformQuotaRepository liveAttestation liveattestation.Provider liveAttestationCipher SecretEncryptor @@ -454,6 +455,10 @@ type OpenAIGatewayService struct { openaiAccountRuntimeBlockLocks sync.Map // key: int64(accountID), value: *sync.Mutex openaiAccountRuntimeBlockGeneration sync.Map // key: int64(accountID), value: uint64 openaiAccountRuntimeBlockSequence atomic.Uint64 + openaiOAuth429RetryStartedAt sync.Map // key: int64(accountID), value: time.Time + openai429StrategyMu sync.Mutex + openai429StrategyCachedAt time.Time + openai429StrategyCached RateLimit429CooldownSettings grokCredentialMutationLocks sync.Map // key: int64(accountID), value: *sync.Mutex openaiOAuth429WindowStartUnixNano atomic.Int64 openaiOAuth429WindowCount atomic.Int64 @@ -463,11 +468,6 @@ type OpenAIGatewayService struct { codexModelsManifestCache codexModelsManifestCache openaiCompatSessionResponses sync.Map openaiCompatAnthropicDigestSessions sync.Map - // openaiCodexTurnStateOrigins: 下游会话 seed → openAICodexTurnStateOrigin, - // 记录最近一次向该会话下发 x-codex-turn-state 的铸造账号,供出站守卫 - // 剥离跨账号回带(openai_codex_turn_state.go)。 - openaiCodexTurnStateOrigins sync.Map - openaiCodexTurnStateWrites atomic.Uint64 } // NewOpenAIGatewayService creates a new OpenAIGatewayService @@ -548,6 +548,27 @@ func NewOpenAIGatewayService( return svc } +func (s *OpenAIGatewayService) SetTLSFingerprintServices(profile *TLSFingerprintProfileService, router *TLSFingerprintRouterService) { + if s == nil { + return + } + s.tlsFPProfileService = profile + s.tlsFPRouterService = router +} + +func (s *OpenAIGatewayService) doAccountHTTP(ctx context.Context, c *gin.Context, account *Account, req *http.Request, proxyURL, protocol string) (*http.Response, error) { + if account != nil && account.Platform == PlatformGrok { + return doLeftoverAccountHTTP(ctx, s.httpUpstream, req, proxyURL, account, s.tlsFPProfileService, s.tlsFPRouterService, inboundUserAgentFromGin(c), "http", protocol) + } + // Last mutation before send: callers may Header.Set/Get after buildUpstreamRequest + // (images Content-Type, messages identity + turn-state). leftover 5 session_id + // values are unchanged; originator stays lowercase. + if account != nil && account.Type == AccountTypeOAuth && req != nil { + applyCodexHeaderWireCasing(req.Header) + } + return doAccountHTTPUpstreamFromGin(ctx, c, s.httpUpstream, req, proxyURL, account, s.tlsFPProfileService, s.tlsFPRouterService, "http", protocol) +} + // ResolveChannelMapping 解析渠道级模型映射(代理到 ChannelService) func (s *OpenAIGatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult { if s.channelService == nil { @@ -604,10 +625,6 @@ func (s *OpenAIGatewayService) isUpstreamModelRestrictedByChannel(ctx context.Co if s.channelService == nil { return false } - if compactForwardModel, ok := openAIForwardModelFromContext(ctx); ok { - requestedModel = compactForwardModel.model - requireCompact = compactForwardModel.useCompactModelMapping - } upstreamModel := resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact) if upstreamModel == "" { return false @@ -1056,6 +1073,9 @@ func getAPIKeyIDFromContext(c *gin.Context) int64 { // isolateOpenAISessionID 将 apiKeyID 混入 session 标识符, // 确保不同 API Key 的用户即使使用相同的原始 session_id/conversation_id, // 到达上游的标识符也不同,防止跨用户会话碰撞。 +// +// Outbound session/conversation headers should use openaiOutboundSessionID +// or openaiOutboundSessionUUID so a valid device-profile namespace is folded in. func isolateOpenAISessionID(apiKeyID int64, raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -1067,6 +1087,60 @@ func isolateOpenAISessionID(apiKeyID int64, raw string) string { return fmt.Sprintf("%016x", h.Sum64()) } +func loadOpenAIOutboundSessionProfile(ctx context.Context, account *Account) *AccountDeviceProfile { + if account == nil || !account.IsOpenAIOAuth() { + return nil + } + return loadOutboundCodexProfile(ctx, account) +} + +func deriveOpenAIOutboundSessionIDFromProfile(profile *AccountDeviceProfile, isolated string) string { + if profile == nil || isolated == "" { + return "" + } + sessionID, _, _, err := DeriveSessionIDs(profile.SessionNamespace, isolated) + if err != nil || sessionID == "" { + return "" + } + return sessionID +} + +func deriveOpenAIOutboundSessionID(ctx context.Context, account *Account, apiKeyID int64, raw string) string { + isolated := isolateOpenAISessionID(apiKeyID, raw) + if isolated == "" { + return "" + } + return deriveOpenAIOutboundSessionIDFromProfile(loadOpenAIOutboundSessionProfile(ctx, account), isolated) +} + +func openaiOutboundSessionIDFromProfile(profile *AccountDeviceProfile, apiKeyID int64, raw string) string { + isolated := isolateOpenAISessionID(apiKeyID, raw) + if derived := deriveOpenAIOutboundSessionIDFromProfile(profile, isolated); derived != "" { + return derived + } + return isolated +} + +func openaiOutboundSessionID(ctx context.Context, account *Account, apiKeyID int64, raw string) string { + if derived := deriveOpenAIOutboundSessionID(ctx, account, apiKeyID, raw); derived != "" { + return derived + } + return isolateOpenAISessionID(apiKeyID, raw) +} + +func openaiOutboundSessionUUID(ctx context.Context, account *Account, apiKeyID int64, raw string) string { + if derived := deriveOpenAIOutboundSessionID(ctx, account, apiKeyID, raw); derived != "" { + return derived + } + return generateSessionUUID(isolateOpenAISessionID(apiKeyID, raw)) +} + +func openaiOutboundSessionPair(ctx context.Context, account *Account, apiKeyID int64, sessionRaw, conversationRaw string) (sessionID, conversationID string) { + profile := loadOpenAIOutboundSessionProfile(ctx, account) + return openaiOutboundSessionIDFromProfile(profile, apiKeyID, sessionRaw), + openaiOutboundSessionIDFromProfile(profile, apiKeyID, conversationRaw) +} + func logCodexCLIOnlyDetection(ctx context.Context, c *gin.Context, account *Account, apiKeyID int64, result CodexClientRestrictionDetectionResult, body []byte) { if !result.Enabled { return @@ -1206,7 +1280,7 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco } return apiKey, "apikey", nil } - apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + apiKey := account.GetOpenAIApiKey() if apiKey == "" { return "", "", errors.New("api_key not found in credentials") } diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 648fa1dc21..69b23a8fe1 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -739,6 +739,27 @@ func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (* if settings.CooldownSeconds > 7200 { settings.CooldownSeconds = 7200 } + if settings.Strategy != "same_account_retry" { + settings.Strategy = "cooldown" + } + if settings.RetryIntervalMs < 100 { + settings.RetryIntervalMs = 500 + } + if settings.RetryIntervalMs > 60000 { + settings.RetryIntervalMs = 60000 + } + if settings.RetryMaxDurationSeconds < 1 { + settings.RetryMaxDurationSeconds = 120 + } + if settings.RetryMaxDurationSeconds > 600 { + settings.RetryMaxDurationSeconds = 600 + } + if settings.MaxAccountSwitches < 0 { + settings.MaxAccountSwitches = 0 + } + if settings.MaxAccountSwitches > 10 { + settings.MaxAccountSwitches = 10 + } return &settings, nil } @@ -748,6 +769,10 @@ func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, se if settings == nil { return fmt.Errorf("settings cannot be nil") } + if settings.Strategy == "" { settings.Strategy = "cooldown" } + if settings.RetryIntervalMs == 0 { settings.RetryIntervalMs = 500 } + if settings.RetryMaxDurationSeconds == 0 { settings.RetryMaxDurationSeconds = 120 } + if settings.MaxAccountSwitches < 0 { settings.MaxAccountSwitches = 0 } if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 { if settings.Enabled { @@ -755,6 +780,18 @@ func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, se } settings.CooldownSeconds = 5 } + if settings.Strategy != "cooldown" && settings.Strategy != "same_account_retry" { + return fmt.Errorf("strategy must be cooldown or same_account_retry") + } + if settings.RetryIntervalMs < 100 || settings.RetryIntervalMs > 60000 { + return fmt.Errorf("retry_interval_ms must be between 100-60000") + } + if settings.RetryMaxDurationSeconds < 1 || settings.RetryMaxDurationSeconds > 600 { + return fmt.Errorf("retry_max_duration_seconds must be between 1-600") + } + if settings.MaxAccountSwitches < 0 || settings.MaxAccountSwitches > 10 { + return fmt.Errorf("max_account_switches must be between 0-10") + } data, err := json.Marshal(settings) if err != nil { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index a939000947..7f7cbbc67f 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -154,6 +154,8 @@ type SystemSettings struct { SiteSubtitle string APIBaseURL string ContactInfo string + SupportQRCodes string + DownloadToolsURL string DocURL string HomeContent string CompactHomeEnabled bool @@ -165,19 +167,36 @@ type SystemSettings struct { CustomMenuItems string // JSON array of custom menu items CustomEndpoints string // JSON array of custom endpoints - DefaultConcurrency int - DefaultBalance float64 - RiskControlEnabled bool - CyberSessionBlockEnabled bool - CyberSessionBlockTTLSeconds int - AffiliateEnabled bool - AffiliateRebateRate float64 - AffiliateRebateFreezeHours int - AffiliateRebateDurationDays int - AffiliateRebatePerInviteeCap float64 - AdminRechargeRebateEnabled bool - DefaultUserRPMLimit int - DefaultSubscriptions []DefaultSubscriptionSetting + DefaultConcurrency int + DefaultBalance float64 + RiskControlEnabled bool + CyberSessionBlockEnabled bool + CyberSessionBlockTTLSeconds int + AffiliateEnabled bool + AffiliateRebateRate float64 + AffiliateRebateFreezeHours int + AffiliateRebateDurationDays int + AffiliateRebatePerInviteeCap float64 + AffiliateRebateCap float64 + AffiliateRebateInviteeLimit int + AffiliateSignupBonus float64 + AdminRechargeRebateEnabled bool + TicketEnabled bool + KiroDefaultVersion string + KiroDefaultCommit string + KiroDefaultSystemVersion string + KiroDefaultNodeVersion string + KiroCacheHitRateScale int + KiroCacheMinBlockTokens int + KiroCacheIndependentTTLSeconds int + KiroCachePrefixTTLSeconds int + KiroCodeExecutionSandboxCommand string + IPMultiAccountBanEnabled bool + IPMultiAccountBanWindowMinutes int + IPMultiAccountBanThreshold int + IPMultiAccountBanLearningUntil string + DefaultUserRPMLimit int + DefaultSubscriptions []DefaultSubscriptionSetting // Model fallback configuration EnableModelFallback bool `json:"enable_model_fallback"` @@ -201,7 +220,6 @@ type SystemSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` - ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings; empty mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -313,6 +331,18 @@ type DefaultSubscriptionSetting struct { ValidityDays int `json:"validity_days"` } +type DefaultAccountModelConfig struct { + ModelWhitelist []string `json:"model_whitelist,omitempty"` + ModelMapping map[string]string `json:"model_mapping,omitempty"` + CompactModelMapping map[string]string `json:"compact_model_mapping,omitempty"` + KiroSubscriptionTypeModelMap map[string]DefaultAccountModelConfig `json:"kiro_subscription_type_model_config,omitempty"` + + TempUnschedulableEnabled bool `json:"temp_unschedulable_enabled,omitempty"` + TempUnschedulableRules []TempUnschedulableRule `json:"temp_unschedulable_rules,omitempty"` + CustomErrorCodesEnabled bool `json:"custom_error_codes_enabled,omitempty"` + CustomErrorCodes []int `json:"custom_error_codes,omitempty"` +} + type PublicSettings struct { RegistrationEnabled bool EmailVerifyEnabled bool @@ -343,6 +373,8 @@ type PublicSettings struct { SiteSubtitle string APIBaseURL string ContactInfo string + SupportQRCodes string + DownloadToolsURL string DocURL string HomeContent string CompactHomeEnabled bool @@ -379,7 +411,6 @@ type PublicSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` - ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -396,6 +427,9 @@ type PublicSettings struct { // Affiliate (邀请返利) feature toggle AffiliateEnabled bool `json:"affiliate_enabled"` + // Ticket feature toggle (default enabled) + TicketEnabled bool `json:"ticket_enabled"` + // 风控中心功能开关 RiskControlEnabled bool `json:"risk_control_enabled"` @@ -560,7 +594,11 @@ type RateLimit429CooldownSettings struct { // Enabled 是否在无法解析上游重置时间时应用默认429回避 Enabled bool `json:"enabled"` // CooldownSeconds 默认回避时长(秒) - CooldownSeconds int `json:"cooldown_seconds"` + CooldownSeconds int `json:"cooldown_seconds"` + Strategy string `json:"strategy"` + RetryIntervalMs int `json:"retry_interval_ms"` + RetryMaxDurationSeconds int `json:"retry_max_duration_seconds"` + MaxAccountSwitches int `json:"max_account_switches"` } // DefaultOverloadCooldownSettings 返回默认的过载冷却配置(启用,10分钟) @@ -574,8 +612,12 @@ func DefaultOverloadCooldownSettings() *OverloadCooldownSettings { // DefaultRateLimit429CooldownSettings 返回默认的429回避配置(启用,5秒) func DefaultRateLimit429CooldownSettings() *RateLimit429CooldownSettings { return &RateLimit429CooldownSettings{ - Enabled: true, - CooldownSeconds: 5, + Enabled: true, + CooldownSeconds: 5, + Strategy: "cooldown", + RetryIntervalMs: 500, + RetryMaxDurationSeconds: 120, + MaxAccountSwitches: 2, } } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index f5f19918a8..a956a150a5 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -1289,6 +1289,10 @@ export async function updateOverloadCooldownSettings( export interface RateLimit429CooldownSettings { enabled: boolean; cooldown_seconds: number; + strategy: "cooldown" | "same_account_retry"; + retry_interval_ms: number; + retry_max_duration_seconds: number; + max_account_switches: number; } export async function getRateLimit429CooldownSettings(): Promise { diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 80af0b1940..cc63ae24de 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -340,8 +340,16 @@ +
+ + +
+
@@ -371,6 +379,12 @@
+
+ + + +
+
@@ -8937,6 +8951,10 @@ const rateLimit429CooldownSaving = ref(false); const rateLimit429CooldownForm = reactive({ enabled: true, cooldown_seconds: 5, + strategy: "cooldown" as "cooldown" | "same_account_retry", + retry_interval_ms: 500, + retry_max_duration_seconds: 120, + max_account_switches: 2, }); // Panel API Rate Limit 状态 @@ -11837,6 +11855,10 @@ async function saveRateLimit429CooldownSettings() { const updated = await adminAPI.settings.updateRateLimit429CooldownSettings({ enabled: rateLimit429CooldownForm.enabled, cooldown_seconds: rateLimit429CooldownForm.cooldown_seconds, + strategy: rateLimit429CooldownForm.strategy, + retry_interval_ms: rateLimit429CooldownForm.retry_interval_ms, + retry_max_duration_seconds: rateLimit429CooldownForm.retry_max_duration_seconds, + max_account_switches: rateLimit429CooldownForm.max_account_switches, }); Object.assign(rateLimit429CooldownForm, updated); appStore.showSuccess(t("admin.settings.rateLimit429Cooldown.saved")); From e62ec2c42fa8abf3a9d03d6454f4cdadace95069 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:44:21 -0700 Subject: [PATCH 14/28] Revert "feat(429): add configurable cooldown and retry strategies" This reverts commit 6c3edc095640fe651d1b4524c39ed1e550ca298b. --- .../handler/admin/setting_handler_runtime.go | 32 +- backend/internal/handler/dto/settings.go | 172 ++++----- backend/internal/handler/failover_loop.go | 91 +---- .../openai_account_runtime_block_fastpath.go | 190 +-------- .../service/openai_gateway_cc_pipeline.go | 18 +- .../service/openai_gateway_forward.go | 134 ++++--- .../service/openai_gateway_passthrough.go | 362 ++++++++++++++++-- .../service/openai_gateway_service.go | 106 +---- backend/internal/service/setting_features.go | 37 -- backend/internal/service/settings_view.go | 78 +--- frontend/src/api/admin/settings.ts | 4 - frontend/src/views/admin/SettingsView.vue | 24 +- 12 files changed, 555 insertions(+), 693 deletions(-) diff --git a/backend/internal/handler/admin/setting_handler_runtime.go b/backend/internal/handler/admin/setting_handler_runtime.go index ef16e8979f..eb89b5472c 100644 --- a/backend/internal/handler/admin/setting_handler_runtime.go +++ b/backend/internal/handler/admin/setting_handler_runtime.go @@ -112,23 +112,15 @@ func (h *SettingHandler) GetRateLimit429CooldownSettings(c *gin.Context) { } response.Success(c, dto.RateLimit429CooldownSettings{ - Enabled: settings.Enabled, - CooldownSeconds: settings.CooldownSeconds, - Strategy: settings.Strategy, - RetryIntervalMs: settings.RetryIntervalMs, - RetryMaxDurationSeconds: settings.RetryMaxDurationSeconds, - MaxAccountSwitches: settings.MaxAccountSwitches, + Enabled: settings.Enabled, + CooldownSeconds: settings.CooldownSeconds, }) } // UpdateRateLimit429CooldownSettingsRequest 更新429默认回避配置请求 type UpdateRateLimit429CooldownSettingsRequest struct { - Strategy string `json:"strategy"` - RetryIntervalMs int `json:"retry_interval_ms"` - RetryMaxDurationSeconds int `json:"retry_max_duration_seconds"` - MaxAccountSwitches int `json:"max_account_switches"` - Enabled bool `json:"enabled"` - CooldownSeconds int `json:"cooldown_seconds"` + Enabled bool `json:"enabled"` + CooldownSeconds int `json:"cooldown_seconds"` } // UpdateRateLimit429CooldownSettings 更新429默认回避配置 @@ -141,12 +133,8 @@ func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) { } settings := &service.RateLimit429CooldownSettings{ - Strategy: req.Strategy, - RetryIntervalMs: req.RetryIntervalMs, - RetryMaxDurationSeconds: req.RetryMaxDurationSeconds, - MaxAccountSwitches: req.MaxAccountSwitches, - Enabled: req.Enabled, - CooldownSeconds: req.CooldownSeconds, + Enabled: req.Enabled, + CooldownSeconds: req.CooldownSeconds, } if err := h.settingService.SetRateLimit429CooldownSettings(c.Request.Context(), settings); err != nil { @@ -161,12 +149,8 @@ func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) { } response.Success(c, dto.RateLimit429CooldownSettings{ - Enabled: updatedSettings.Enabled, - CooldownSeconds: updatedSettings.CooldownSeconds, - Strategy: updatedSettings.Strategy, - RetryIntervalMs: updatedSettings.RetryIntervalMs, - RetryMaxDurationSeconds: updatedSettings.RetryMaxDurationSeconds, - MaxAccountSwitches: updatedSettings.MaxAccountSwitches, + Enabled: updatedSettings.Enabled, + CooldownSeconds: updatedSettings.CooldownSeconds, }) } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index c0ef93e8cd..798de2fed9 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -148,23 +148,21 @@ type SystemSettings struct { GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"` GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - SupportQRCodes []service.SupportQRCodeEntry `json:"support_qr_codes"` - DownloadToolsURL string `json:"download_tools_url"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - CompactHomeEnabled bool `json:"compact_home_enabled"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` - CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + CompactHomeEnabled bool `json:"compact_home_enabled"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` @@ -172,9 +170,6 @@ type SystemSettings struct { AffiliateRebateFreezeHours int `json:"affiliate_rebate_freeze_hours"` AffiliateRebateDurationDays int `json:"affiliate_rebate_duration_days"` AffiliateRebatePerInviteeCap float64 `json:"affiliate_rebate_per_invitee_cap"` - AffiliateRebateCap float64 `json:"affiliate_rebate_cap"` - AffiliateRebateInviteeLimit int `json:"affiliate_rebate_invitee_limit"` - AffiliateSignupBonus float64 `json:"affiliate_signup_bonus"` AdminRechargeRebateEnabled bool `json:"affiliate_admin_recharge_enabled"` DefaultUserRPMLimit int `json:"default_user_rpm_limit"` DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"` @@ -310,23 +305,13 @@ type SystemSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings; empty account mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` GrokCrossClientModelMapEnabled bool `json:"grok_cross_client_model_map_enabled"` GrokDefaultBaseURLMode string `json:"grok_default_base_url_mode"` - // Kiro runtime defaults (admin-only; not exposed on PublicSettings). - KiroDefaultVersion string `json:"kiro_version"` - KiroDefaultCommit string `json:"kiro_commit"` - KiroDefaultSystemVersion string `json:"system_version"` - KiroDefaultNodeVersion string `json:"node_version"` - KiroCacheHitRateScale int `json:"cache_hit_rate_scale"` - KiroCacheMinBlockTokens int `json:"cache_min_block_tokens"` - KiroCacheIndependentTTLSeconds int `json:"cache_independent_ttl_seconds"` - KiroCachePrefixTTLSeconds int `json:"cache_prefix_ttl_seconds"` - KiroCodeExecutionSandboxCommand string `json:"kiro_code_execution_sandbox_command"` - // Available Channels feature switch (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` @@ -345,14 +330,6 @@ type SystemSettings struct { // Affiliate (邀请返利) feature switch AffiliateEnabled bool `json:"affiliate_enabled"` - // Ticket feature switch (default enabled) - TicketEnabled bool `json:"ticket_enabled"` - - IPMultiAccountBanEnabled bool `json:"ip_multi_account_ban_enabled"` - IPMultiAccountBanWindowMinutes int `json:"ip_multi_account_ban_window_minutes"` - IPMultiAccountBanThreshold int `json:"ip_multi_account_ban_threshold"` - IPMultiAccountBanLearningUntil string `json:"ip_multi_account_ban_learning_until"` - // OpenAI fast/flex policy OpenAIFastPolicySettings *OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"` @@ -372,61 +349,58 @@ type DefaultSubscriptionSetting struct { } type PublicSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 - PasskeyEnabled bool `json:"passkey_enabled"` - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementRevision string `json:"login_agreement_revision"` - LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` - TencentCaptchaAppID string `json:"tencent_captcha_app_id"` - TencentCaptchaRegion string `json:"tencent_captcha_region"` - AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` - AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` - AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` - AliyunCaptchaRegion string `json:"aliyun_captcha_region"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - SupportQRCodes []service.SupportQRCodeEntry `json:"support_qr_codes"` - DownloadToolsURL string `json:"download_tools_url"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - CompactHomeEnabled bool `json:"compact_home_enabled"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` - CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` - DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` - WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` - WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` - WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` - OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` - OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` - GitHubOAuthEnabled bool `json:"github_oauth_enabled"` - GoogleOAuthEnabled bool `json:"google_oauth_enabled"` - SoraClientEnabled bool `json:"sora_client_enabled"` - BackendModeEnabled bool `json:"backend_mode_enabled"` - PaymentEnabled bool `json:"payment_enabled"` - Version string `json:"version"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + PasskeyEnabled bool `json:"passkey_enabled"` + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementRevision string `json:"login_agreement_revision"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + TencentCaptchaRegion string `json:"tencent_captcha_region"` + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + CompactHomeEnabled bool `json:"compact_home_enabled"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` + WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` + WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` + WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` + Version string `json:"version"` // 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。 // 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。 ServerTimezone string `json:"server_timezone"` @@ -440,6 +414,7 @@ type PublicSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` @@ -447,7 +422,6 @@ type PublicSettings struct { ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` AffiliateEnabled bool `json:"affiliate_enabled"` - TicketEnabled bool `json:"ticket_enabled"` RiskControlEnabled bool `json:"risk_control_enabled"` @@ -468,12 +442,8 @@ type OverloadCooldownSettings struct { // RateLimit429CooldownSettings 429默认回避配置 DTO type RateLimit429CooldownSettings struct { - Strategy string `json:"strategy"` - RetryIntervalMs int `json:"retry_interval_ms"` - RetryMaxDurationSeconds int `json:"retry_max_duration_seconds"` - MaxAccountSwitches int `json:"max_account_switches"` - Enabled bool `json:"enabled"` - CooldownSeconds int `json:"cooldown_seconds"` + Enabled bool `json:"enabled"` + CooldownSeconds int `json:"cooldown_seconds"` } // PanelRateLimitSettings 面板 API 限流配置 DTO diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go index 71a0f0615d..d349980fc6 100644 --- a/backend/internal/handler/failover_loop.go +++ b/backend/internal/handler/failover_loop.go @@ -56,16 +56,13 @@ const ( const profitVetoExhaustedMessage = "No available accounts: all candidates rejected by group profit control" func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryCount int) time.Duration { - // An OAuth 429 with a request-scoped deadline intentionally retries - // immediately; zero is meaningful here and must not fall back to 500ms. - if failoverErr != nil && failoverErr.StatusCode == http.StatusTooManyRequests && - !failoverErr.SameAccountRetryDeadline.IsZero() && failoverErr.SameAccountRetryDelay <= 0 { - return 0 + if failoverErr == nil { + return sameAccountRetryDelay } - if failoverErr != nil && failoverErr.SameAccountRetryDelay > 0 { + if failoverErr.SameAccountRetryDelay > 0 { return failoverErr.SameAccountRetryDelay } - if failoverErr == nil || !failoverErr.RequestScopedTransient || retryCount <= 1 { + if !failoverErr.RequestScopedTransient || retryCount <= 1 { return sameAccountRetryDelay } @@ -79,43 +76,10 @@ func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryC return delay } -func sameAccountRetryAllowed(failoverErr *service.UpstreamFailoverError, retryCount, retryLimit int) bool { - if failoverErr == nil || !failoverErr.RetryableOnSameAccount { - return false - } - if retryLimit > 0 && retryCount >= retryLimit { - return false - } - if !failoverErr.SameAccountRetryDeadline.IsZero() { - return time.Now().Before(failoverErr.SameAccountRetryDeadline) - } - return retryCount < retryLimit -} - -func pinSameAccountRetryContext( - ctx context.Context, - fs *FailoverState, - accountID int64, - groupID *int64, - failoverErr *service.UpstreamFailoverError, - prevRetryCount int, - prevSwitchCount int, - bridgeOldKeys bool, -) context.Context { - if ctx == nil || fs == nil || failoverErr == nil || !failoverErr.RetryableOnSameAccount { - return ctx - } - if fs.SwitchCount != prevSwitchCount { - return ctx - } - if fs.SameAccountRetryCount[accountID] != prevRetryCount+1 { - return ctx - } - prefetchedGroupID := int64(0) - if groupID != nil { - prefetchedGroupID = *groupID - } - return service.WithPrefetchedStickySession(ctx, accountID, prefetchedGroupID, bridgeOldKeys) +// sameAccountRetryDeadlineAllows prevents a retry from starting after the +// service-provided same-account retry window has elapsed. +func sameAccountRetryDeadlineAllows(failoverErr *service.UpstreamFailoverError) bool { + return failoverErr == nil || failoverErr.SameAccountRetryDeadline.IsZero() || time.Now().Before(failoverErr.SameAccountRetryDeadline) } // FailoverState 跨循环迭代共享的 failover 状态 @@ -168,24 +132,6 @@ func (s *FailoverState) RecordProfitVeto(accountID int64) FailoverAction { return FailoverContinue } -// RecordConcurrencyTimeout excludes a busy account after the slot ladder -// (deadline two-shot or wait-queue full) and continues onto another account -// while the original sticky binding is preserved by the caller. -func (s *FailoverState) RecordConcurrencyTimeout(accountID int64) FailoverAction { - if s == nil { - return FailoverExhausted - } - if s.FailedAccountIDs == nil { - s.FailedAccountIDs = make(map[int64]struct{}) - } - s.FailedAccountIDs[accountID] = struct{}{} - if s.SwitchCount >= s.MaxSwitches { - return FailoverExhausted - } - s.SwitchCount++ - return FailoverContinue -} - // ProfitVetoCount 返回本次请求累计的利润否决次数(供日志使用)。 func (s *FailoverState) ProfitVetoCount() int { return s.profitVetoCount } @@ -223,27 +169,27 @@ func (s *FailoverState) HandleFailoverError( return FailoverExhausted } - retryMax := retryLimit - if failoverErr.SameAccountRetryMax > 0 { - retryMax = failoverErr.SameAccountRetryMax - } - // 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。 - sameAccountRetry := sameAccountRetryAllowed(failoverErr, s.SameAccountRetryCount[accountID], retryMax) + retryCount := s.SameAccountRetryCount[accountID] + sameAccountRetryAllowed := failoverErr.RetryableOnSameAccount && retryLimit > 0 && retryCount < retryLimit + if sameAccountRetryAllowed && !failoverErr.SameAccountRetryDeadline.IsZero() { + sameAccountRetryAllowed = time.Now().Before(failoverErr.SameAccountRetryDeadline) + } + sameAccountRetry := sameAccountRetryAllowed if needForceCacheBilling(s.hasBoundSession, failoverErr, sameAccountRetry) { s.ForceCacheBilling = true } // 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试。 // 重试次数上限 retryLimit 由调用方传入(账号级 pool_mode_retry_count 配置)。 - if sameAccountRetryAllowed(failoverErr, s.SameAccountRetryCount[accountID], retryMax) { + if sameAccountRetryAllowed { s.SameAccountRetryCount[accountID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, s.SameAccountRetryCount[accountID]) logger.FromContext(ctx).Warn("gateway.failover_same_account_retry", zap.Int64("account_id", accountID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("same_account_retry_count", s.SameAccountRetryCount[accountID]), - zap.Int("same_account_retry_max", retryMax), + zap.Int("same_account_retry_max", retryLimit), zap.Duration("retry_delay", retryDelay), ) if !sleepWithContext(ctx, retryDelay) { @@ -259,11 +205,6 @@ func (s *FailoverState) HandleFailoverError( // 加入失败列表 s.FailedAccountIDs[accountID] = struct{}{} - for _, excludedAccountID := range failoverErr.ExcludedAccountIDs { - if excludedAccountID > 0 { - s.FailedAccountIDs[excludedAccountID] = struct{}{} - } - } // 检查是否耗尽 if s.SwitchCount >= s.MaxSwitches { diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index dd683262f4..6853cc8487 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -11,34 +11,13 @@ import ( const ( openAIAccountStateUpdateTimeout = 5 * time.Second - openAIOAuth429RetryWindow = 2 * time.Minute - openAIOAuth429RetryDelay = 0 + openAIOAuth429FallbackCooldown = 5 * time.Second openAIStopSchedulingBridgeCooldown = 2 * time.Minute - openAIOAuth429MaxAccountAttempts = 3 openAIOAuth429StormWindow = 10 * time.Second openAIOAuth429StormThreshold = 20 openAIOAuth429StormMaxAccountSwitches = 1 ) -func (s *OpenAIGatewayService) rateLimit429StrategySettings() RateLimit429CooldownSettings { - defaults := DefaultRateLimit429CooldownSettings() - if s == nil || s.settingService == nil { - return *defaults - } - s.openai429StrategyMu.Lock() - defer s.openai429StrategyMu.Unlock() - if time.Since(s.openai429StrategyCachedAt) < 5*time.Second { - return s.openai429StrategyCached - } - settings := *defaults - if loaded, err := s.settingService.GetRateLimit429CooldownSettings(context.Background()); err == nil && loaded != nil { - settings = *loaded - } - s.openai429StrategyCached = settings - s.openai429StrategyCachedAt = time.Now() - return settings -} - // OpenAIOAuth429FailoverState tracks the request-local follow-up budget after // the first Grok OAuth 429. Once that 429 occurs, exactly one different account // may be attempted; any failure from that follow-up account ends failover. @@ -76,6 +55,11 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if s != nil { scheduleOllamaCloudUsageActivity(s.deferredService, account) } + // Capacity shedding describes this request, not account health. Keep the + // account schedulable while the request-local retry budget handles recovery. + if account != nil && account.Platform == PlatformOpenAI && isOpenAIRequestScopedCapacityShed("", responseBody) { + return false + } stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() @@ -93,6 +77,10 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if s == nil || account == nil { return false } + // Team 联动熔断必须先于 model-not-found 与账户级临时不可调度规则的早退。 + if s.rateLimitService != nil { + s.rateLimitService.maybeHandleOpenAITeamLinkedError(stateCtx, account, statusCode, responseBody) + } stateCtx = withTempUnschedulableModel(stateCtx, canonicalModel) if s.rateLimitService != nil && len(canonicalModel) > 0 && s.rateLimitService.HandleUpstreamModelNotFound(stateCtx, account, canonicalModel[0], statusCode, responseBody) { return true @@ -161,164 +149,20 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context return } s.recordOpenAIOAuth429() - if s.ShouldRetryOpenAIOAuth429(account, headers, responseBody) { - return - } - cooldownUntil := time.Time{} - hasCooldown := false + cooldownUntil := time.Now().Add(openAIOAuth429FallbackCooldown) if s.rateLimitService != nil { if resetAt := s.rateLimitService.calculateOpenAI429ResetTime(headers); resetAt != nil && resetAt.After(time.Now()) { cooldownUntil = *resetAt - hasCooldown = true } else if resetUnix := parseOpenAIRateLimitResetTime(responseBody); resetUnix != nil { if resetAt := time.Unix(*resetUnix, 0); resetAt.After(time.Now()) { cooldownUntil = resetAt - hasCooldown = true } } else if cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account); ok && cooldown > 0 { cooldownUntil = time.Now().Add(cooldown) - hasCooldown = true } } - if !hasCooldown { - // The request-local retry window has expired without an upstream reset - // signal. Keep the account out of new selections while this request - // switches to another candidate, rather than immediately selecting it - // again on a concurrent request. - cooldownUntil = time.Now().Add(openAIStopSchedulingBridgeCooldown) - } s.BlockAccountScheduling(account, cooldownUntil, "429") - s.openaiOAuth429RetryStartedAt.Delete(account.ID) -} - -// shouldRetryOpenAIOAuth429OnSameAccount keeps an OAuth account pinned while -// a transient 429 is still inside its retry window. API-key accounts keep the -// existing pool-mode behavior. -func (s *OpenAIGatewayService) shouldRetryOpenAIOAuth429OnSameAccount(account *Account, statusCode int, shouldDisable bool) bool { - if shouldDisable || account == nil { - return false - } - if statusCode == http.StatusTooManyRequests && isOpenAIOAuthAccount(account) && !account.IsShadow() { - if s.settingService != nil && s.rateLimit429StrategySettings().Strategy != "same_account_retry" { - return false - } - // A prior retry window may already have expired and parked this account. - // Do not create a fresh window while that runtime block is active. - if s.isOpenAIAccountRuntimeBlocked(account) { - return false - } - return s.openAIOAuth429RetryWindowActive(account) - } - return account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode) -} - -// ShouldRetryOpenAIOAuth429 is used before persisting a scheduler block. An -// upstream-provided reset takes precedence; only temporary 429s without one -// stay on the same OAuth account during the retry window. -func (s *OpenAIGatewayService) ShouldRetryOpenAIOAuth429(account *Account, headers http.Header, responseBody []byte) bool { - if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() { - return false - } - if s.isOpenAIAccountRuntimeBlocked(account) { - return false - } - if s.settingService != nil && s.rateLimit429StrategySettings().Strategy != "same_account_retry" { - return false - } - if s.rateLimitService != nil && s.rateLimitService.calculateOpenAI429ResetTime(headers) != nil { - return false - } - if parseOpenAIRateLimitResetTime(responseBody) != nil { - return false - } - return s.openAIOAuth429RetryWindowActive(account) -} - -func (s *OpenAIGatewayService) openAIOAuth429RetryWindowActive(account *Account) bool { - if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() { - return false - } - now := time.Now() - value, _ := s.openaiOAuth429RetryStartedAt.LoadOrStore(account.ID, now) - startedAt, ok := value.(time.Time) - if !ok { - s.openaiOAuth429RetryStartedAt.Store(account.ID, now) - startedAt = now - } - window := openAIOAuth429RetryWindow - if s.settingService != nil { - window = time.Duration(s.rateLimit429StrategySettings().RetryMaxDurationSeconds) * time.Second - } - return now.Sub(startedAt) < window -} - -func openAIOAuth429SameAccountRetryDelay(statusCode int, account *Account) time.Duration { - if statusCode == http.StatusTooManyRequests && isOpenAIOAuthAccount(account) && !account.IsShadow() { - return openAIOAuth429RetryDelay - } - return 0 -} - -func (s *OpenAIGatewayService) openAIOAuth429SameAccountRetryDelay(statusCode int, account *Account) time.Duration { - if statusCode == http.StatusTooManyRequests && isOpenAIOAuthAccount(account) && !account.IsShadow() && s != nil && s.settingService != nil { - return time.Duration(s.rateLimit429StrategySettings().RetryIntervalMs) * time.Millisecond - } - return openAIOAuth429SameAccountRetryDelay(statusCode, account) -} - -// openAIOAuth429RetryDeadline returns the request-local retry window end that -// was established when the account first saw a temporary OAuth 429. -func (s *OpenAIGatewayService) openAIOAuth429RetryDeadline(account *Account) time.Time { - if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() { - return time.Time{} - } - value, ok := s.openaiOAuth429RetryStartedAt.Load(account.ID) - if !ok { - return time.Time{} - } - startedAt, ok := value.(time.Time) - if !ok { - return time.Time{} - } - window := openAIOAuth429RetryWindow - if s.settingService != nil { - window = time.Duration(s.rateLimit429StrategySettings().RetryMaxDurationSeconds) * time.Second - } - return startedAt.Add(window) -} - -// SameAccountRetryLimit returns the request-local retry budget. OAuth 429s -// deliberately use a time-derived budget rather than an account pool setting. -func SameAccountRetryLimit(account *Account, failoverErr *UpstreamFailoverError) int { - if failoverErr != nil && failoverErr.StatusCode == http.StatusTooManyRequests && - isOpenAIOAuthAccount(account) && !account.IsShadow() { - if failoverErr.SameAccountRetryMax > 0 { - return failoverErr.SameAccountRetryMax - } - return 24 - } - if account == nil { - return 0 - } - return account.GetPoolModeRetryCount() -} - -func (s *OpenAIGatewayService) openAIOAuth429SameAccountRetryMax() int { - if s == nil || s.settingService == nil { - return 24 - } - settings := s.rateLimit429StrategySettings() - interval := time.Duration(settings.RetryIntervalMs) * time.Millisecond - window := time.Duration(settings.RetryMaxDurationSeconds) * time.Second - max := int(window / interval) - if max < 1 { - max = 1 - } - if max > 240 { - max = 240 - } - return max } func (s *OpenAIGatewayService) BlockAccountScheduling(account *Account, until time.Time, reason string) { @@ -501,11 +345,7 @@ func (s *OpenAIGatewayService) isOpenAIOAuth429Storm() bool { } func (s *OpenAIGatewayService) ShouldStopOpenAIOAuth429Failover(account *Account, statusCode int, failedSwitches int, state *OpenAIOAuth429FailoverState) bool { - maxSwitches := openAIOAuth429StormMaxAccountSwitches - if s != nil && s.settingService != nil { - maxSwitches = s.rateLimit429StrategySettings().MaxAccountSwitches - } - if failedSwitches < maxSwitches { + if failedSwitches < openAIOAuth429StormMaxAccountSwitches { return false } if state != nil && state.grokOAuth429FollowupPending { @@ -528,9 +368,5 @@ func (s *OpenAIGatewayService) ShouldStopOpenAIOAuth429Failover(account *Account if statusCode != http.StatusTooManyRequests || !isOpenAIOAuthAccount(account) { return false } - // failedSwitches is incremented after each exhausted candidate. Therefore, - // a value of three means this request has already given three distinct OAuth - // accounts their full same-account retry window. A 429 storm is diagnostic - // only; it must not skip those candidates and return a client 429 early. - return failedSwitches >= maxSwitches+1 + return s.isOpenAIOAuth429Storm() } diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 66eb53575d..ce9dd54d37 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -125,19 +125,13 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError( if account.Platform != PlatformGrok && !tempUnscheduled { shouldDisable = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel) } - failoverErr := newOpenAIUpstreamFailoverError( + return newOpenAIUpstreamFailoverError( resp.StatusCode, resp.Header, respBody, upstreamMsg, - s.shouldRetryOpenAIOAuth429OnSameAccount(account, resp.StatusCode, shouldDisable) || (!shouldDisable && account.IsPoolMode() && isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), + !shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), ) - if failoverErr.RetryableOnSameAccount { - failoverErr.SameAccountRetryDelay = s.openAIOAuth429SameAccountRetryDelay(resp.StatusCode, account) - failoverErr.SameAccountRetryDeadline = s.openAIOAuth429RetryDeadline(account) - failoverErr.SameAccountRetryMax = s.openAIOAuth429SameAccountRetryMax() - } - return failoverErr } // openAIChatCompletionsTargetURL 解析账号的(非 Grok)Chat Completions 上游端点。 @@ -156,7 +150,7 @@ func (s *OpenAIGatewayService) openAIChatCompletionsTargetURL(account *Account) // resolveCCFallbackTarget 解析两条 CC 回退路径共用的账号凭证与上游端点 // (回退路径仅面向 APIKey 账号,凭证恒为 openai api_key)。 func (s *OpenAIGatewayService) resolveCCFallbackTarget(account *Account) (apiKey string, targetURL string, err error) { - apiKey = account.GetOpenAIApiKey() + apiKey = strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if apiKey == "" { return "", "", fmt.Errorf("account %d missing api_key", account.ID) } @@ -214,9 +208,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( if account.Platform == PlatformGrok { if account.IsGrokOAuth() { - if err := applyGrokInteractiveUpstreamHeadersFromAccount(ctx, upstreamReq, account); err != nil { - return nil, err - } + applyGrokCLIHeaders(upstreamReq.Header) } applyGrokCacheHeaders(upstreamReq.Header, grokCacheIdentity) } @@ -228,7 +220,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( if account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.EffectiveConcurrency()) + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 95f742b6e4..ec5fafdb72 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -21,6 +21,7 @@ import ( func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) clearGrokResponsesClientToolMapping(c) + clearOpenAIResponsesClientToolMapping(c) clearOpenAIResponsesNamespaceNames(c) startTime := time.Now() // 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。 @@ -39,9 +40,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco }) return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed") } - if c != nil && c.Request != nil { - maybeLearnOfficialDeviceProfile(ctx, account, c.Request.Header) - } normalizedBody, normalized, err := normalizeOpenAICodexCompactReasoningEffortForAccount(c, account, body) if err != nil { @@ -106,12 +104,21 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco requestView := newOpenAIRequestView(body) reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey originalModel := reqModel + nativeDeepSeekResponses := account.Platform == PlatformDeepseek && + (account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol()) if account.Platform == PlatformGrok { return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) } - if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { + // CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合 + // (Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。 + // 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。 + if account.IsAnthropicProtocol() { + return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel) + } + + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) } if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey { @@ -281,7 +288,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco instructions := gjson.GetBytes(body, "instructions") instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && !compatMessagesBridge { + if instructionsEmpty && !compatMessagesBridge && !nativeDeepSeekResponses { markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) } @@ -413,22 +420,35 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if codexResult.Modified { markDecodedModified() } - // 一次加载档案:请求体 client_metadata 与出站头共享同一份 fingerprint IDs。 - // compact 形态不同,跳过。fpIDs == nil 时不单独盖 installation id。 + // 带真实 device_id 时补齐 client_metadata 安装标识,与真实 Codex 对齐(compact 形态不同,跳过)。 + if !isCompactRequest && applyCodexClientMetadata(decoded, account) { + markDecodedModified() + } + stageCodexFingerprintIDs(c, nil) + // 指纹收敛:一次性解析收敛 ID,请求体和出站头共享同一份 IDs(保证 turn_id 等随机字段一致)。 + // fingerprintIDs 在此处解析,后续 buildUpstreamRequest 中使用同一份。 if !isCompactRequest { var clientHeaders http.Header if c != nil && c.Request != nil { clientHeaders = c.Request.Header } - fpIDs := applyCodexForwardRequestIdentity(ctx, c, decoded, account, clientHeaders) + fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) if fpIDs != nil { - markDecodedModified() + if applyCodexFingerprintClientMetadata(decoded, fpIDs) { + markDecodedModified() + } } + // 将 fpIDs 存入 gin context,供 buildUpstreamRequest 中头改写使用。 + // 无条件覆写(含 nil):failover 从收敛账号切到 off 账号时,上一 + // 账号的 IDs 不得残留(stageCodexFingerprintIDs 注释)。 + stageCodexFingerprintIDs(c, fpIDs) } if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel } - if codexResult.PromptCacheKey != "" { + if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" { + promptCacheKey = currentPromptCacheKey + } else if codexResult.PromptCacheKey != "" { promptCacheKey = codexResult.PromptCacheKey } } @@ -441,7 +461,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco maxOutputTokens := gjson.GetBytes(body, "max_output_tokens") if maxOutputTokens.Exists() { switch account.Platform { - case PlatformOpenAI: + case PlatformOpenAI, PlatformDeepseek: // Preserve Responses-native output limits unless the selected upstream // explicitly rejects the field in the bounded HTTP retry loop below. case PlatformAnthropic: @@ -835,7 +855,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // Send request upstreamStart := time.Now() - resp, err := s.doAccountHTTP(ctx, c, account, upstreamReq, proxyURL, "responses") + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if headerGuard != nil && headerGuard.stopHeaderWait() { if resp != nil && resp.Body != nil { @@ -929,19 +949,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco }) shouldDisable := s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel) - failoverErr := newOpenAIUpstreamFailoverError( + return nil, newOpenAIUpstreamFailoverError( resp.StatusCode, resp.Header, respBody, upstreamMsg, - s.shouldRetryOpenAIOAuth429OnSameAccount(account, resp.StatusCode, shouldDisable) || (!shouldDisable && account.IsPoolMode() && isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), + !shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), ) - if failoverErr.RetryableOnSameAccount { - failoverErr.SameAccountRetryDelay = s.openAIOAuth429SameAccountRetryDelay(resp.StatusCode, account) - failoverErr.SameAccountRetryDeadline = s.openAIOAuth429RetryDeadline(account) - failoverErr.SameAccountRetryMax = s.openAIOAuth429SameAccountRetryMax() - } - return nil, failoverErr } return s.handleErrorResponse(ctx, resp, c, account, body, billingModel) } @@ -1027,6 +1041,25 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } +func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { + if account == nil || account.Type != AccountTypeAPIKey { + return false + } + if account.IsCNProvider() { + // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生 + // Responses,Kimi/GLM 回退 Chat Completions。 + switch account.GetAPIProtocol() { + case APIProtocolChatCompletions: + return true + case APIProtocolAdaptive: + return account.Platform != PlatformDeepseek + default: + return false + } + } + return !openai_compat.ShouldUseResponsesAPI(account.Extra) +} + func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) { // Determine target URL based on account type var targetURL string @@ -1037,6 +1070,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. case AccountTypeAPIKey: // API Key accounts use Platform API or custom base URL baseURL := account.GetOpenAIBaseURL() + if account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol() { + baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses) + } if baseURL == "" { targetURL = openaiPlatformAPIURL } else { @@ -1044,13 +1080,17 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if err != nil { return nil, err } - targetURL = buildOpenAIResponsesURL(validatedURL) + targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) } default: targetURL = openaiPlatformAPIURL } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) + // DeepSeek 原生 Responses 端点为无状态实现:强制 store=false、清除 + // previous_response_id,避免携带状态字段被上游拒绝。 + body = normalizeDeepSeekResponsesRequestBody(account, body) + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, err @@ -1087,6 +1127,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. } } } + // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号), + // 剥离后再出站——异账号 blob 与本账号的(指纹收敛后)出站身份自相矛盾。 + s.guardOpenAICodexTurnStateEcho(c, account, req.Header) if account.Type == AccountTypeOAuth { compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body) // 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。 @@ -1101,19 +1144,18 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) } apiKeyID := getAPIKeyIDFromContext(c) - profile := resolveOpenAIOutboundDeviceProfile(ctx, c, account) if isOpenAIResponsesCompactPath(c) { req.Header.Set("accept", "application/json") if req.Header.Get("version") == "" { - req.Header.Set("version", codexCLIVersion) + req.Header.Set("version", CodexCanonicalClientVersion()) } compactSession := resolveOpenAICompactSessionID(c) - req.Header.Set("session_id", openaiOutboundSessionIDFromProfile(profile, apiKeyID, compactSession)) + req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession)) } else { req.Header.Set("accept", "text/event-stream") } if promptCacheKey != "" { - isolated := openaiOutboundSessionIDFromProfile(profile, apiKeyID, promptCacheKey) + isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey) req.Header.Set("session_id", isolated) if !compatMessagesBridge || clientConversationID != "" { req.Header.Set("conversation_id", isolated) @@ -1131,28 +1173,19 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("user-agent", customUA) } - // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为 Codex CLI。 + // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为规范 Codex 身份。 // 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。 if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("user-agent", codexCLIUserAgent) + req.Header.Set("user-agent", CodexCanonicalUserAgent()) } // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。 - // leftover 5 session/full 模式会用账号级恒定 session_id 覆盖上面的 - // isolate+namespace 值;那是「一号一安装」收敛,不是 leftover 11 的缺口。 - // leftover 11 在 off/device、以及不走指纹的 passthrough/WS/compat 路径生效。 - if account.Type == AccountTypeOAuth && c != nil { - if fpIDs, ok := c.Get("codex_fingerprint_ids"); ok { - if ids, ok := fpIDs.(*codexFingerprintIDs); ok && fingerprintIDsBelongToAccount(ids, account) { - applyCodexFingerprintHeaders(req.Header, ids) - } - } - } + applyStagedCodexFingerprintHeaders(c, account, req.Header) // 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。 // 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。 if account.Type == AccountTypeOAuth { - s.enforceCodexIdentityFromLoadedProfile(req.Header, account, outboundDeviceProfileFromGin(c, account)) + enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } // Ensure required headers exist @@ -1162,6 +1195,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, + // 保证不被覆盖丢失)。 + applyOpenAICodexBetaFeatures(c, account, req.Header) setOpenAICodexRoutingHintFromBody(req.Header, account, body) logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http", req.Header, body, "not_applicable") @@ -1177,25 +1213,3 @@ func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string } return account.GetOpenAIUserAgent() } - -func (s *OpenAIGatewayService) enforceCodexIdentityFromAccount(ctx context.Context, h http.Header, account *Account) { - if account == nil { - s.enforceCodexIdentityFromLoadedProfile(h, account, nil) - return - } - profile, err := LoadOutboundDeviceProfile(ctx, account) - if err != nil { - profile = nil - } - s.enforceCodexIdentityFromLoadedProfile(h, account, profile) -} - -func (s *OpenAIGatewayService) enforceCodexIdentityFromLoadedProfile(h http.Header, account *Account, profile *AccountDeviceProfile) { - fallback := s.codexIdentityOverrideUA(account) - if profile == nil { - enforceCodexIdentityHeadersWithUA(h, fallback) - return - } - identity := resolveCodexOutboundIdentityFromProfile(profile, fallback) - enforceCodexIdentityHeadersWithUA(h, identity.userAgent) -} diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index b54fb4d594..c15a78ef48 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -16,8 +16,8 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -25,6 +25,84 @@ import ( "go.uber.org/zap" ) +const openAIResponsesClientToolMappingContextKey = "openai_responses_client_tool_mapping" + +func hasOpenAIResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool { + return len(mapping.CustomTools) > 0 || mapping.ToolSearch || len(mapping.NamespaceTools) > 0 +} + +func adaptOpenAIResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) { + if !needsOpenAIResponsesClientToolAdaptation(body) { + return body, apicompat.ResponsesClientToolMapping{}, nil + } + + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var requestBody map[string]any + if err := decoder.Decode(&requestBody); err != nil { + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools: %w", err) + } + var trailingValue any + if err := decoder.Decode(&trailingValue); !errors.Is(err, io.EOF) { + if err == nil { + err = errors.New("multiple JSON values") + } + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools trailing data: %w", err) + } + mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody) + if err != nil || !changed { + return body, mapping, err + } + rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) + if err != nil { + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("encode OpenAI Responses client tools: %w", err) + } + return rebuilt, mapping, nil +} + +func needsOpenAIResponsesClientToolAdaptation(body []byte) bool { + needsAdaptation := false + var visit func(gjson.Result) bool + visit = func(value gjson.Result) bool { + if value.IsObject() { + switch strings.TrimSpace(value.Get("type").String()) { + case "custom", "custom_tool_call", "custom_tool_call_output", + "tool_search", "tool_search_call", "tool_search_output": + needsAdaptation = true + return false + } + } + if value.IsObject() || value.IsArray() { + value.ForEach(func(_, child gjson.Result) bool { + return visit(child) + }) + } + return !needsAdaptation + } + visit(gjson.ParseBytes(body)) + return needsAdaptation +} + +func openAIResponsesClientToolMapping(c *gin.Context) (apicompat.ResponsesClientToolMapping, bool) { + if c == nil { + return apicompat.ResponsesClientToolMapping{}, false + } + value, ok := c.Get(openAIResponsesClientToolMappingContextKey) + mapping, typed := value.(apicompat.ResponsesClientToolMapping) + return mapping, ok && typed && hasOpenAIResponsesClientToolMapping(mapping) +} + +// clearOpenAIResponsesClientToolMapping removes mapping state from the prior +// forwarding attempt. Forward retries accounts on the same Gin context. +func clearOpenAIResponsesClientToolMapping(c *gin.Context) { + if c == nil { + return + } + if _, exists := c.Get(openAIResponsesClientToolMappingContextKey); exists { + c.Set(openAIResponsesClientToolMappingContextKey, apicompat.ResponsesClientToolMapping{}) + } +} + func (s *OpenAIGatewayService) forwardOpenAIPassthrough( ctx context.Context, c *gin.Context, @@ -80,6 +158,39 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( body = normalizedBody } reqStream = gjson.GetBytes(body, "stream").Bool() + + stageCodexFingerprintIDs(c, nil) + // 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。 + // 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科 + // 手术,透传热路径禁全量 Unmarshal),出站头改写由请求构造器读取 + // context 中的同一份 IDs 完成(turn_id 等随机字段两侧必须一致)。 + if !isOpenAIResponsesCompactPath(c) { + var clientHeaders http.Header + if c != nil && c.Request != nil { + clientHeaders = c.Request.Header + } + fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + if fpIDs != nil { + fpBody, fpChanged, fpErr := applyCodexFingerprintClientMetadataRaw(body, fpIDs) + if fpErr != nil { + return nil, fpErr + } + if fpChanged { + body = fpBody + } + } + stageCodexFingerprintIDs(c, fpIDs) + } + } + + if account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && + !isOpenAIResponsesCompactPath(c) && needsOpenAIResponsesClientToolAdaptation(body) { + adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body) + if adaptErr != nil { + return nil, adaptErr + } + body = adaptedBody + c.Set(openAIResponsesClientToolMappingContextKey, mapping) } sanitizedBody, sanitized, err := sanitizeEmptyBase64InputImagesInOpenAIBody(body) @@ -202,7 +313,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } upstreamStart := time.Now() - resp, err = s.doAccountHTTP(ctx, c, account, upstreamReq, proxyURL, "responses") + resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { // Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to @@ -236,9 +347,23 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( return nil, s.handleErrorResponsePassthrough(ctx, resp, c, account, body, probeBody) } defer func() { _ = resp.Body.Close() }() + if mapping, ok := openAIResponsesClientToolMapping(c); ok && isEventStreamResponse(resp.Header) { + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + resp.Body = newGrokResponsesClientToolStreamBody(resp.Body, mapping, maxLineSize) + } serviceTier := extractOpenAIServiceTierFromBody(body) + // x-codex-turn-state 溯源:下游回传由 writeOpenAIPassthroughResponseHeaders + // 在各 handler 的写头点强制放行,铸造账号在此统一记录,供出站守卫剥离 + // failover 换号后的跨账号回带(openai_codex_turn_state.go)。 + if extractOpenAICodexTurnState(resp.Header) != "" { + s.noteOpenAICodexTurnStateProvenance(c, account) + } + var usage *OpenAIUsage var firstTokenMs *int responseID := "" @@ -351,11 +476,14 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if err != nil { return nil, err } - targetURL = buildOpenAIResponsesURL(validatedURL) + targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) } } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) + // DeepSeek 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。 + body = normalizeDeepSeekResponsesRequestBody(account, body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { return nil, err @@ -376,6 +504,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( } } + // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号), + // 剥离后再出站(openai_codex_turn_state.go)。 + s.guardOpenAICodexTurnStateEcho(c, account, req.Header) + // 覆盖入站鉴权残留,并注入上游认证 req.Header.Del("authorization") req.Header.Del("x-api-key") @@ -408,7 +540,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if isOpenAIResponsesCompactPath(c) { req.Header.Set("accept", "application/json") if req.Header.Get("version") == "" { - req.Header.Set("version", codexCLIVersion) + req.Header.Set("version", CodexCanonicalClientVersion()) } if clientSessionID == "" { clientSessionID = resolveOpenAICompactSessionID(c) @@ -417,7 +549,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("accept", "text/event-stream") } if req.Header.Get("originator") == "" { - req.Header.Set("originator", openai.CodexDefaultOriginator) + req.Header.Set("originator", resolveCodexOutboundIdentity("").originator) } // 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。 if clientSessionID == "" { @@ -426,14 +558,11 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if clientConversationID == "" { clientConversationID = promptCacheKey } - if clientSessionID != "" || clientConversationID != "" { - sessionID, conversationID := openaiOutboundSessionPair(ctx, account, apiKeyID, clientSessionID, clientConversationID) - if clientSessionID != "" { - req.Header.Set("session_id", sessionID) - } - if clientConversationID != "" { - req.Header.Set("conversation_id", conversationID) - } + if clientSessionID != "" { + req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, clientSessionID)) + } + if clientConversationID != "" { + req.Header.Set("conversation_id", isolateOpenAISessionID(apiKeyID, clientConversationID)) } } else if isOpenAIResponsesCompactPath(c) { // 透传白名单会放行客户端的 Accept: text/event-stream;compact 上游是 @@ -448,12 +577,16 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("user-agent", customUA) } if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("user-agent", codexCLIUserAgent) + req.Header.Set("user-agent", CodexCanonicalUserAgent()) } + // 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头, + // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置: + // 会话隔离之后、终态身份收口之前)。 + applyStagedCodexFingerprintHeaders(c, account, req.Header) // 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份 // (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。 if account.Type == AccountTypeOAuth { - s.enforceCodexIdentityFromAccount(ctx, req.Header, account) + enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } if req.Header.Get("content-type") == "" { @@ -462,6 +595,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, + // 保证不被覆盖丢失)。 + applyOpenAICodexBetaFeatures(c, account, req.Header) setOpenAICodexRoutingHintFromBody(req.Header, account, body) logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http_passthrough", req.Header, body, "not_applicable") @@ -638,19 +774,13 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough( Detail: upstreamDetail, UpstreamResponseBody: upstreamDetail, }) - failoverErr := newOpenAIUpstreamFailoverError( + return newOpenAIUpstreamFailoverError( resp.StatusCode, resp.Header, body, upstreamMsg, - s.shouldRetryOpenAIOAuth429OnSameAccount(account, resp.StatusCode, shouldDisable), + !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), ) - if failoverErr.RetryableOnSameAccount { - failoverErr.SameAccountRetryDelay = s.openAIOAuth429SameAccountRetryDelay(resp.StatusCode, account) - failoverErr.SameAccountRetryDeadline = s.openAIOAuth429RetryDeadline(account) - failoverErr.SameAccountRetryMax = s.openAIOAuth429SameAccountRetryMax() - } - return failoverErr } func (s *OpenAIGatewayService) handleErrorResponsePassthrough( @@ -778,6 +908,19 @@ type openaiNonStreamingResultPassthrough struct { imageOutputSizes []string } +const openAIStreamKeepaliveBytesKey = "openai_stream_keepalive_bytes" + +func recordOpenAIStreamKeepaliveBytes(c *gin.Context, written int) { + if c == nil || written <= 0 { + return + } + current := 0 + if value, ok := c.Get(openAIStreamKeepaliveBytesKey); ok { + current, _ = value.(int) + } + c.Set(openAIStreamKeepaliveBytesKey, current+written) +} + func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool { if localStarted { return true @@ -800,6 +943,85 @@ func openAIStreamEventIsPreamble(eventType string) bool { } } +func openAIStreamAddedEventStartsClientOutput(payload []byte, eventType string) bool { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return true + } + + switch strings.TrimSpace(eventType) { + case "response.output_item.added": + item := gjson.GetBytes(payload, "item") + if !item.Exists() || !item.IsObject() { + return true + } + switch strings.TrimSpace(item.Get("type").String()) { + case "reasoning": + if item.Get("encrypted_content").String() != "" { + return true + } + summary := item.Get("summary") + if !summary.IsArray() { + return false + } + for _, part := range summary.Array() { + if strings.TrimSpace(part.Get("type").String()) != "summary_text" || part.Get("text").String() != "" { + return true + } + } + return false + case "message": + content := item.Get("content") + if !content.IsArray() { + return false + } + for _, part := range content.Array() { + switch strings.TrimSpace(part.Get("type").String()) { + case "output_text": + if part.Get("text").String() != "" { + return true + } + case "refusal": + if part.Get("refusal").String() != "" { + return true + } + default: + return true + } + } + return false + case "function_call": + return item.Get("arguments").String() != "" + case "custom_tool_call": + return item.Get("input").String() != "" + case "compaction": + return item.Get("encrypted_content").String() != "" + default: + return true + } + case "response.content_part.added": + part := gjson.GetBytes(payload, "part") + if !part.Exists() || !part.IsObject() { + return true + } + switch strings.TrimSpace(part.Get("type").String()) { + case "output_text": + return part.Get("text").String() != "" + case "refusal": + return part.Get("refusal").String() != "" + default: + return true + } + case "response.reasoning_summary_part.added": + part := gjson.GetBytes(payload, "part") + if !part.Exists() || !part.IsObject() || strings.TrimSpace(part.Get("type").String()) != "summary_text" { + return true + } + return part.Get("text").String() != "" + default: + return true + } +} + func openAIStreamDataStartsClientOutput(data, eventType string) bool { trimmed := strings.TrimSpace(data) if trimmed == "" { @@ -816,6 +1038,8 @@ func openAIStreamDataStartsClientOutput(data, eventType string) bool { // (content_policy / invalid_request 等)维持原样转发,保留上游错误细节。 payload := []byte(trimmed) return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload)) + case "response.output_item.added", "response.content_part.added", "response.reasoning_summary_part.added": + return openAIStreamAddedEventStartsClientOutput([]byte(trimmed), eventType) } return !openAIStreamEventIsPreamble(eventType) } @@ -894,9 +1118,34 @@ func isOpenAIUpstreamCapacityShedEvent(payload []byte) bool { switch openAIStreamFailedEventErrorCode(payload) { case "server_is_overloaded", "slow_down": return true - default: - return false } + for _, path := range []string{"response.error.message", "error.message", "message"} { + if isOpenAICapacityShedMessage(gjson.GetBytes(payload, path).String()) { + return true + } + } + return false +} + +func logOpenAICapacityFailoverSuppressed( + ctx context.Context, + account *Account, + path string, + upstreamRequestID string, + eventType string, +) { + fields := []zap.Field{ + zap.String("path", path), + zap.String("event_type", strings.TrimSpace(eventType)), + zap.String("upstream_request_id", strings.TrimSpace(upstreamRequestID)), + } + if account != nil { + fields = append(fields, + zap.Int64("account_id", account.ID), + zap.String("platform", account.Platform), + ) + } + logger.FromContext(ctx).Warn("gateway.failover_suppressed_after_semantic_output", fields...) } // openAICapacityShedRetryableClientCode 是把上游容量降载错误转发给客户端时改写 @@ -919,9 +1168,12 @@ func sanitizeOpenAICapacityShedErrorCodeForClient(payload []byte) ([]byte, bool) updated := payload changed := false for _, path := range []string{"response.error.code", "error.code"} { - switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(updated, path).String())) { - case "server_is_overloaded", "slow_down": - default: + parent := strings.TrimSuffix(path, ".code") + if !gjson.GetBytes(updated, parent).Exists() { + continue + } + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(updated, path).String())) + if code != "" && code != "server_is_overloaded" && code != "slow_down" { continue } next, err := sjson.SetBytes(updated, path, openAICapacityShedRetryableClientCode) @@ -954,7 +1206,7 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int { return http.StatusUnauthorized case strings.Contains(combined, "permission") || strings.Contains(combined, "forbidden") || strings.Contains(combined, "access denied"): return http.StatusForbidden - case code == "server_is_overloaded" || code == "slow_down": + case isOpenAIUpstreamCapacityShedEvent(payload): return http.StatusServiceUnavailable default: return http.StatusBadGateway @@ -1088,6 +1340,16 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool return true } +func openAIStreamErrorEventShouldFailover(payload []byte, message string) bool { + if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) != "error" { + return false + } + if isOpenAIContextWindowError(message, payload) { + return false + } + return isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) +} + func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool { if account == nil { return false @@ -1230,6 +1492,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( sawTerminalEvent := false sawFailedEvent := false semanticOutputSeen := false + capacityFailoverSuppressedLogged := false failedMessage := "" clientOutputStarted := false upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) @@ -1316,6 +1579,32 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( } } eventType := strings.TrimSpace(gjson.Get(trimmedData, "type").String()) + if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI && + (eventType == "error" || eventType == "response.failed") && + openAIStreamClientOutputStarted(c, clientOutputStarted) && + isOpenAIUpstreamCapacityShedEvent(dataBytes) { + logOpenAICapacityFailoverSuppressed(ctx, account, "passthrough_sse", upstreamRequestID, eventType) + capacityFailoverSuppressedLogged = true + } + if eventType == "error" && !openAIStreamClientOutputStarted(c, clientOutputStarted) { + errorMessage := extractOpenAISSEErrorMessage(dataBytes) + if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, errorMessage); matched { + s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, errorMessage) + MarkResponseCommitted(c) + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + c.JSON(status, gin.H{ + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + return resultWithUsage(), fmt.Errorf("upstream error event: passthrough rule matched message=%s", errMsg) + } + if openAIStreamErrorEventShouldFailover(dataBytes, errorMessage) { + return resultWithUsage(), + s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, errorMessage, resp.Header) + } + } if eventType == "response.failed" { failedMessage = extractOpenAISSEErrorMessage(dataBytes) // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析 @@ -1529,6 +1818,12 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( if err != nil { return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", err) } + if mapping, ok := openAIResponsesClientToolMapping(c); ok && json.Valid(body) { + body, _, err = apicompat.RestoreResponsesClientToolPayload(body, mapping) + if err != nil { + return nil, fmt.Errorf("restore OpenAI Responses client tools: %w", err) + } + } if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) { c.Data(resp.StatusCode, contentType, body) } @@ -1659,4 +1954,13 @@ func writeOpenAIPassthroughResponseHeaders(dst http.Header, src http.Header, fil dst.Add(key, v) } } + + // x-codex-turn-state:Codex 回合状态头,客户端会在同回合后续请求回带。 + // 与上面的用量头不同,这里在上游缺失时也主动清除——failover 换号后残留 + // 上一账号的 blob 会构成跨账号矛盾(openai_codex_turn_state.go)。 + turnStateKey := http.CanonicalHeaderKey(openAICodexTurnStateHeader) + dst.Del(turnStateKey) + for _, v := range getCaseInsensitiveValues(src, openAICodexTurnStateHeader) { + dst.Add(turnStateKey, v) + } } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index dee8a6dbf2..16082a16a4 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -61,7 +61,7 @@ const ( // 陈旧版本会被优先丢弃(HTTP 200 + 流内 server_is_overloaded);非官方客户端配不出 // 官方身份时整体回退到本常量,因此它必须跟随官方 CLI 的当前发布版本, // 落后多个版本会让这些请求稳定落在被优先丢弃的一侧。 - codexCLIVersion = "0.147.0" + codexCLIVersion = "0.146.0" // Codex 限额快照仅用于后台展示/诊断,不需要每个成功请求都立即落库。 openAICodexSnapshotPersistMinInterval = 30 * time.Second // 配额自动暂停时,超过该时长仍未刷新的 used% 快照视为陈旧,不再据此暂停账号。 @@ -284,8 +284,9 @@ type OpenAIForwardResult struct { // AudioUsage carries Voice billing units when present. AudioUsage *AudioUsage - wsReplayInput []json.RawMessage - wsReplayInputExists bool + wsReplayInput []json.RawMessage + wsReplayInputExists bool + wsAccountFailoverReplayInput []json.RawMessage } // SucceededForScheduling reports whether this result is an upstream success @@ -398,8 +399,8 @@ func (t *accountWriteThrottle) Allow(id int64, now time.Time) bool { var defaultOpenAICodexSnapshotPersistThrottle = newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval) -// ErrNoAvailableCompactAccounts indicates the request needs /responses/compact -// support but no compatible account is available. +// ErrNoAvailableCompactAccounts indicates a legacy /responses/compact request +// needs compact support but no compatible account is available. var ErrNoAvailableCompactAccounts = errors.New("no available accounts support /responses/compact") // OpenAIGatewayService handles OpenAI API gateway operations @@ -428,8 +429,6 @@ type OpenAIGatewayService struct { channelService *ChannelService balanceNotifyService *BalanceNotifyService settingService *SettingService - tlsFPProfileService *TLSFingerprintProfileService - tlsFPRouterService *TLSFingerprintRouterService userPlatformQuotaRepo UserPlatformQuotaRepository liveAttestation liveattestation.Provider liveAttestationCipher SecretEncryptor @@ -455,10 +454,6 @@ type OpenAIGatewayService struct { openaiAccountRuntimeBlockLocks sync.Map // key: int64(accountID), value: *sync.Mutex openaiAccountRuntimeBlockGeneration sync.Map // key: int64(accountID), value: uint64 openaiAccountRuntimeBlockSequence atomic.Uint64 - openaiOAuth429RetryStartedAt sync.Map // key: int64(accountID), value: time.Time - openai429StrategyMu sync.Mutex - openai429StrategyCachedAt time.Time - openai429StrategyCached RateLimit429CooldownSettings grokCredentialMutationLocks sync.Map // key: int64(accountID), value: *sync.Mutex openaiOAuth429WindowStartUnixNano atomic.Int64 openaiOAuth429WindowCount atomic.Int64 @@ -468,6 +463,11 @@ type OpenAIGatewayService struct { codexModelsManifestCache codexModelsManifestCache openaiCompatSessionResponses sync.Map openaiCompatAnthropicDigestSessions sync.Map + // openaiCodexTurnStateOrigins: 下游会话 seed → openAICodexTurnStateOrigin, + // 记录最近一次向该会话下发 x-codex-turn-state 的铸造账号,供出站守卫 + // 剥离跨账号回带(openai_codex_turn_state.go)。 + openaiCodexTurnStateOrigins sync.Map + openaiCodexTurnStateWrites atomic.Uint64 } // NewOpenAIGatewayService creates a new OpenAIGatewayService @@ -548,27 +548,6 @@ func NewOpenAIGatewayService( return svc } -func (s *OpenAIGatewayService) SetTLSFingerprintServices(profile *TLSFingerprintProfileService, router *TLSFingerprintRouterService) { - if s == nil { - return - } - s.tlsFPProfileService = profile - s.tlsFPRouterService = router -} - -func (s *OpenAIGatewayService) doAccountHTTP(ctx context.Context, c *gin.Context, account *Account, req *http.Request, proxyURL, protocol string) (*http.Response, error) { - if account != nil && account.Platform == PlatformGrok { - return doLeftoverAccountHTTP(ctx, s.httpUpstream, req, proxyURL, account, s.tlsFPProfileService, s.tlsFPRouterService, inboundUserAgentFromGin(c), "http", protocol) - } - // Last mutation before send: callers may Header.Set/Get after buildUpstreamRequest - // (images Content-Type, messages identity + turn-state). leftover 5 session_id - // values are unchanged; originator stays lowercase. - if account != nil && account.Type == AccountTypeOAuth && req != nil { - applyCodexHeaderWireCasing(req.Header) - } - return doAccountHTTPUpstreamFromGin(ctx, c, s.httpUpstream, req, proxyURL, account, s.tlsFPProfileService, s.tlsFPRouterService, "http", protocol) -} - // ResolveChannelMapping 解析渠道级模型映射(代理到 ChannelService) func (s *OpenAIGatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult { if s.channelService == nil { @@ -625,6 +604,10 @@ func (s *OpenAIGatewayService) isUpstreamModelRestrictedByChannel(ctx context.Co if s.channelService == nil { return false } + if compactForwardModel, ok := openAIForwardModelFromContext(ctx); ok { + requestedModel = compactForwardModel.model + requireCompact = compactForwardModel.useCompactModelMapping + } upstreamModel := resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact) if upstreamModel == "" { return false @@ -1073,9 +1056,6 @@ func getAPIKeyIDFromContext(c *gin.Context) int64 { // isolateOpenAISessionID 将 apiKeyID 混入 session 标识符, // 确保不同 API Key 的用户即使使用相同的原始 session_id/conversation_id, // 到达上游的标识符也不同,防止跨用户会话碰撞。 -// -// Outbound session/conversation headers should use openaiOutboundSessionID -// or openaiOutboundSessionUUID so a valid device-profile namespace is folded in. func isolateOpenAISessionID(apiKeyID int64, raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -1087,60 +1067,6 @@ func isolateOpenAISessionID(apiKeyID int64, raw string) string { return fmt.Sprintf("%016x", h.Sum64()) } -func loadOpenAIOutboundSessionProfile(ctx context.Context, account *Account) *AccountDeviceProfile { - if account == nil || !account.IsOpenAIOAuth() { - return nil - } - return loadOutboundCodexProfile(ctx, account) -} - -func deriveOpenAIOutboundSessionIDFromProfile(profile *AccountDeviceProfile, isolated string) string { - if profile == nil || isolated == "" { - return "" - } - sessionID, _, _, err := DeriveSessionIDs(profile.SessionNamespace, isolated) - if err != nil || sessionID == "" { - return "" - } - return sessionID -} - -func deriveOpenAIOutboundSessionID(ctx context.Context, account *Account, apiKeyID int64, raw string) string { - isolated := isolateOpenAISessionID(apiKeyID, raw) - if isolated == "" { - return "" - } - return deriveOpenAIOutboundSessionIDFromProfile(loadOpenAIOutboundSessionProfile(ctx, account), isolated) -} - -func openaiOutboundSessionIDFromProfile(profile *AccountDeviceProfile, apiKeyID int64, raw string) string { - isolated := isolateOpenAISessionID(apiKeyID, raw) - if derived := deriveOpenAIOutboundSessionIDFromProfile(profile, isolated); derived != "" { - return derived - } - return isolated -} - -func openaiOutboundSessionID(ctx context.Context, account *Account, apiKeyID int64, raw string) string { - if derived := deriveOpenAIOutboundSessionID(ctx, account, apiKeyID, raw); derived != "" { - return derived - } - return isolateOpenAISessionID(apiKeyID, raw) -} - -func openaiOutboundSessionUUID(ctx context.Context, account *Account, apiKeyID int64, raw string) string { - if derived := deriveOpenAIOutboundSessionID(ctx, account, apiKeyID, raw); derived != "" { - return derived - } - return generateSessionUUID(isolateOpenAISessionID(apiKeyID, raw)) -} - -func openaiOutboundSessionPair(ctx context.Context, account *Account, apiKeyID int64, sessionRaw, conversationRaw string) (sessionID, conversationID string) { - profile := loadOpenAIOutboundSessionProfile(ctx, account) - return openaiOutboundSessionIDFromProfile(profile, apiKeyID, sessionRaw), - openaiOutboundSessionIDFromProfile(profile, apiKeyID, conversationRaw) -} - func logCodexCLIOnlyDetection(ctx context.Context, c *gin.Context, account *Account, apiKeyID int64, result CodexClientRestrictionDetectionResult, body []byte) { if !result.Enabled { return @@ -1280,7 +1206,7 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco } return apiKey, "apikey", nil } - apiKey := account.GetOpenAIApiKey() + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if apiKey == "" { return "", "", errors.New("api_key not found in credentials") } diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 69b23a8fe1..648fa1dc21 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -739,27 +739,6 @@ func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (* if settings.CooldownSeconds > 7200 { settings.CooldownSeconds = 7200 } - if settings.Strategy != "same_account_retry" { - settings.Strategy = "cooldown" - } - if settings.RetryIntervalMs < 100 { - settings.RetryIntervalMs = 500 - } - if settings.RetryIntervalMs > 60000 { - settings.RetryIntervalMs = 60000 - } - if settings.RetryMaxDurationSeconds < 1 { - settings.RetryMaxDurationSeconds = 120 - } - if settings.RetryMaxDurationSeconds > 600 { - settings.RetryMaxDurationSeconds = 600 - } - if settings.MaxAccountSwitches < 0 { - settings.MaxAccountSwitches = 0 - } - if settings.MaxAccountSwitches > 10 { - settings.MaxAccountSwitches = 10 - } return &settings, nil } @@ -769,10 +748,6 @@ func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, se if settings == nil { return fmt.Errorf("settings cannot be nil") } - if settings.Strategy == "" { settings.Strategy = "cooldown" } - if settings.RetryIntervalMs == 0 { settings.RetryIntervalMs = 500 } - if settings.RetryMaxDurationSeconds == 0 { settings.RetryMaxDurationSeconds = 120 } - if settings.MaxAccountSwitches < 0 { settings.MaxAccountSwitches = 0 } if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 { if settings.Enabled { @@ -780,18 +755,6 @@ func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, se } settings.CooldownSeconds = 5 } - if settings.Strategy != "cooldown" && settings.Strategy != "same_account_retry" { - return fmt.Errorf("strategy must be cooldown or same_account_retry") - } - if settings.RetryIntervalMs < 100 || settings.RetryIntervalMs > 60000 { - return fmt.Errorf("retry_interval_ms must be between 100-60000") - } - if settings.RetryMaxDurationSeconds < 1 || settings.RetryMaxDurationSeconds > 600 { - return fmt.Errorf("retry_max_duration_seconds must be between 1-600") - } - if settings.MaxAccountSwitches < 0 || settings.MaxAccountSwitches > 10 { - return fmt.Errorf("max_account_switches must be between 0-10") - } data, err := json.Marshal(settings) if err != nil { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 7f7cbbc67f..a939000947 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -154,8 +154,6 @@ type SystemSettings struct { SiteSubtitle string APIBaseURL string ContactInfo string - SupportQRCodes string - DownloadToolsURL string DocURL string HomeContent string CompactHomeEnabled bool @@ -167,36 +165,19 @@ type SystemSettings struct { CustomMenuItems string // JSON array of custom menu items CustomEndpoints string // JSON array of custom endpoints - DefaultConcurrency int - DefaultBalance float64 - RiskControlEnabled bool - CyberSessionBlockEnabled bool - CyberSessionBlockTTLSeconds int - AffiliateEnabled bool - AffiliateRebateRate float64 - AffiliateRebateFreezeHours int - AffiliateRebateDurationDays int - AffiliateRebatePerInviteeCap float64 - AffiliateRebateCap float64 - AffiliateRebateInviteeLimit int - AffiliateSignupBonus float64 - AdminRechargeRebateEnabled bool - TicketEnabled bool - KiroDefaultVersion string - KiroDefaultCommit string - KiroDefaultSystemVersion string - KiroDefaultNodeVersion string - KiroCacheHitRateScale int - KiroCacheMinBlockTokens int - KiroCacheIndependentTTLSeconds int - KiroCachePrefixTTLSeconds int - KiroCodeExecutionSandboxCommand string - IPMultiAccountBanEnabled bool - IPMultiAccountBanWindowMinutes int - IPMultiAccountBanThreshold int - IPMultiAccountBanLearningUntil string - DefaultUserRPMLimit int - DefaultSubscriptions []DefaultSubscriptionSetting + DefaultConcurrency int + DefaultBalance float64 + RiskControlEnabled bool + CyberSessionBlockEnabled bool + CyberSessionBlockTTLSeconds int + AffiliateEnabled bool + AffiliateRebateRate float64 + AffiliateRebateFreezeHours int + AffiliateRebateDurationDays int + AffiliateRebatePerInviteeCap float64 + AdminRechargeRebateEnabled bool + DefaultUserRPMLimit int + DefaultSubscriptions []DefaultSubscriptionSetting // Model fallback configuration EnableModelFallback bool `json:"enable_model_fallback"` @@ -220,6 +201,7 @@ type SystemSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings; empty mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -331,18 +313,6 @@ type DefaultSubscriptionSetting struct { ValidityDays int `json:"validity_days"` } -type DefaultAccountModelConfig struct { - ModelWhitelist []string `json:"model_whitelist,omitempty"` - ModelMapping map[string]string `json:"model_mapping,omitempty"` - CompactModelMapping map[string]string `json:"compact_model_mapping,omitempty"` - KiroSubscriptionTypeModelMap map[string]DefaultAccountModelConfig `json:"kiro_subscription_type_model_config,omitempty"` - - TempUnschedulableEnabled bool `json:"temp_unschedulable_enabled,omitempty"` - TempUnschedulableRules []TempUnschedulableRule `json:"temp_unschedulable_rules,omitempty"` - CustomErrorCodesEnabled bool `json:"custom_error_codes_enabled,omitempty"` - CustomErrorCodes []int `json:"custom_error_codes,omitempty"` -} - type PublicSettings struct { RegistrationEnabled bool EmailVerifyEnabled bool @@ -373,8 +343,6 @@ type PublicSettings struct { SiteSubtitle string APIBaseURL string ContactInfo string - SupportQRCodes string - DownloadToolsURL string DocURL string HomeContent string CompactHomeEnabled bool @@ -411,6 +379,7 @@ type PublicSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -427,9 +396,6 @@ type PublicSettings struct { // Affiliate (邀请返利) feature toggle AffiliateEnabled bool `json:"affiliate_enabled"` - // Ticket feature toggle (default enabled) - TicketEnabled bool `json:"ticket_enabled"` - // 风控中心功能开关 RiskControlEnabled bool `json:"risk_control_enabled"` @@ -594,11 +560,7 @@ type RateLimit429CooldownSettings struct { // Enabled 是否在无法解析上游重置时间时应用默认429回避 Enabled bool `json:"enabled"` // CooldownSeconds 默认回避时长(秒) - CooldownSeconds int `json:"cooldown_seconds"` - Strategy string `json:"strategy"` - RetryIntervalMs int `json:"retry_interval_ms"` - RetryMaxDurationSeconds int `json:"retry_max_duration_seconds"` - MaxAccountSwitches int `json:"max_account_switches"` + CooldownSeconds int `json:"cooldown_seconds"` } // DefaultOverloadCooldownSettings 返回默认的过载冷却配置(启用,10分钟) @@ -612,12 +574,8 @@ func DefaultOverloadCooldownSettings() *OverloadCooldownSettings { // DefaultRateLimit429CooldownSettings 返回默认的429回避配置(启用,5秒) func DefaultRateLimit429CooldownSettings() *RateLimit429CooldownSettings { return &RateLimit429CooldownSettings{ - Enabled: true, - CooldownSeconds: 5, - Strategy: "cooldown", - RetryIntervalMs: 500, - RetryMaxDurationSeconds: 120, - MaxAccountSwitches: 2, + Enabled: true, + CooldownSeconds: 5, } } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index a956a150a5..f5f19918a8 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -1289,10 +1289,6 @@ export async function updateOverloadCooldownSettings( export interface RateLimit429CooldownSettings { enabled: boolean; cooldown_seconds: number; - strategy: "cooldown" | "same_account_retry"; - retry_interval_ms: number; - retry_max_duration_seconds: number; - max_account_switches: number; } export async function getRateLimit429CooldownSettings(): Promise { diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index cc63ae24de..80af0b1940 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -340,16 +340,8 @@
-
- - -
-
@@ -379,12 +371,6 @@
-
- - - -
-
@@ -8951,10 +8937,6 @@ const rateLimit429CooldownSaving = ref(false); const rateLimit429CooldownForm = reactive({ enabled: true, cooldown_seconds: 5, - strategy: "cooldown" as "cooldown" | "same_account_retry", - retry_interval_ms: 500, - retry_max_duration_seconds: 120, - max_account_switches: 2, }); // Panel API Rate Limit 状态 @@ -11855,10 +11837,6 @@ async function saveRateLimit429CooldownSettings() { const updated = await adminAPI.settings.updateRateLimit429CooldownSettings({ enabled: rateLimit429CooldownForm.enabled, cooldown_seconds: rateLimit429CooldownForm.cooldown_seconds, - strategy: rateLimit429CooldownForm.strategy, - retry_interval_ms: rateLimit429CooldownForm.retry_interval_ms, - retry_max_duration_seconds: rateLimit429CooldownForm.retry_max_duration_seconds, - max_account_switches: rateLimit429CooldownForm.max_account_switches, }); Object.assign(rateLimit429CooldownForm, updated); appStore.showSuccess(t("admin.settings.rateLimit429Cooldown.saved")); From 8db8791a73aebc2afa60e6c61534894028d8b219 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:53:55 -0700 Subject: [PATCH 15/28] =?UTF-8?q?=E4=B8=BA=20Grok=20=E6=99=AE=E9=80=9A=204?= =?UTF-8?q?29=20=E5=A2=9E=E5=8A=A0=E6=9C=89=E9=99=90=E5=90=8C=E5=8F=B7?= =?UTF-8?q?=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/grok_upstream_failure.go | 9 ++++++++- backend/internal/service/grok_upstream_failure_test.go | 8 ++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/grok_upstream_failure.go b/backend/internal/service/grok_upstream_failure.go index 3d6aa05c6e..51edbc3332 100644 --- a/backend/internal/service/grok_upstream_failure.go +++ b/backend/internal/service/grok_upstream_failure.go @@ -402,6 +402,13 @@ func grokRetryableOnSameAccount(account *Account, statusCode int, responseBody [ if statusCode == http.StatusTooManyRequests { return true } + case GrokFailureRateLimit: + // A transient 429 does not identify a bad credential. Give every Grok + // account a bounded same-account retry window before failover; the + // failover loop still caps attempts and the client receives 429 after it. + if statusCode == http.StatusTooManyRequests { + return true + } } return account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode) } @@ -411,7 +418,7 @@ func grokSameAccountRetryMetadata(account *Account, statusCode int, responseBody return false, 0, time.Time{} } decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") - if decision.Class != GrokFailureModelCapacity { + if decision.Class != GrokFailureModelCapacity && decision.Class != GrokFailureRateLimit { return true, 0, time.Time{} } return true, 500 * time.Millisecond, time.Now().Add(30 * time.Second) diff --git a/backend/internal/service/grok_upstream_failure_test.go b/backend/internal/service/grok_upstream_failure_test.go index d6e6ec7ace..0cc8cd1efa 100644 --- a/backend/internal/service/grok_upstream_failure_test.go +++ b/backend/internal/service/grok_upstream_failure_test.go @@ -81,7 +81,7 @@ func TestGrokRetryableOnSameAccount_CapacityAndRateLimit(t *testing.T) { account := &Account{ID: 9105, Platform: PlatformGrok, Type: AccountTypeOAuth} require.True(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests, []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`))) - require.False(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests, + require.True(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests, []byte(`{"error":{"message":"rate limit exceeded"}}`))) require.False(t, grokRetryableOnSameAccount(account, http.StatusPaymentRequired, []byte(`{"error":{"message":"You have run out of credits or need a Grok subscription"}}`))) @@ -118,9 +118,9 @@ func TestGrokSameAccountRetryMetadata_CapacityDeadline(t *testing.T) { retryable, delay, deadline = grokSameAccountRetryMetadata(account, http.StatusTooManyRequests, []byte(`{"error":{"message":"rate limit exceeded"}}`)) - require.False(t, retryable) - require.Zero(t, delay) - require.True(t, deadline.IsZero()) + require.True(t, retryable) + require.Equal(t, 500*time.Millisecond, delay) + require.WithinDuration(t, time.Now().Add(30*time.Second), deadline, 2*time.Second) } func TestClassifyGrokUpstreamFailure_ValidationNoCool(t *testing.T) { From ad87ddee125cf7d5aebac417acd5119a21e6c644 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:08:09 -0700 Subject: [PATCH 16/28] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Grok=20CC=20?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E4=B8=8E=20compaction=20=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/openai_gateway_chat_completions_raw.go | 12 ++++++++---- backend/internal/service/openai_gateway_grok.go | 14 ++++++++------ .../internal/service/openai_gateway_grok_test.go | 10 ++++++++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 3c46194188..00a7ece52c 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -200,11 +200,15 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( }) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { + retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + StatusCode: resp.StatusCode, + ResponseBody: respBody, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, + RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, } } return s.handleChatCompletionsErrorResponse(resp, c, account, billingModel) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 1933cd2550..132f7b3159 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -258,7 +258,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( } func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool { - if statusCode != http.StatusBadRequest { + if statusCode != http.StatusBadRequest && statusCode != http.StatusUnprocessableEntity { return false } @@ -284,7 +284,7 @@ func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool { return false } - if strings.EqualFold(code, "invalid_encrypted_content") { + if strings.EqualFold(code, "invalid_encrypted_content") || strings.EqualFold(code, "invalid_compaction") || strings.EqualFold(code, "compaction_decode_error") { return true } // Keep the official xAI flat-code gate so unrelated 400s are not retried. @@ -292,12 +292,13 @@ func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool { return false } // Nested OpenAI-style envelopes may omit top-level code; require decrypt text. - if code == "" && !strings.Contains(normalizedMessage, "decrypt") { + if code == "" && !strings.Contains(normalizedMessage, "decrypt") && !strings.Contains(normalizedMessage, "decode the compaction blob") { return false } - return strings.Contains(normalizedMessage, "encrypted_content") && + return (strings.Contains(normalizedMessage, "encrypted_content") && (strings.Contains(normalizedMessage, "decrypt") || - strings.Contains(normalizedMessage, "unmodified")) + strings.Contains(normalizedMessage, "unmodified"))) || + strings.Contains(normalizedMessage, "decode the compaction blob") } // requestHasGrokEncryptedReasoning reports whether the outbound Responses body @@ -392,7 +393,8 @@ func trimGrokInvalidEncryptedContentRetryBody(body []byte) ([]byte, bool, error) hasEncryptedReasoning := false for _, item := range items { - if strings.TrimSpace(item.Get("type").String()) == "reasoning" && item.Get("encrypted_content").Exists() { + if (strings.TrimSpace(item.Get("type").String()) == "reasoning" && item.Get("encrypted_content").Exists()) || + (isOpenAICompactionType(strings.TrimSpace(item.Get("type").String())) && item.Get("encrypted_content").Exists()) { hasEncryptedReasoning = true break } diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 541e3a3216..b89c9eef83 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -2053,6 +2053,16 @@ func TestForwardGrokResponsesInvalidEncryptedContentRecoveryDoesNotOvermatch(t * } } +func TestGrokCompactionBlobRecoveryStripsCompactionItem(t *testing.T) { + body := []byte(`{"model":"grok","input":[{"type":"compaction","id":"cmp_1","encrypted_content":"blob"},{"type":"message","role":"user","content":"hi"}]}`) + require.True(t, isGrokInvalidEncryptedContentResponse(http.StatusUnprocessableEntity, []byte(`{"code":"invalid_compaction","error":"could not decode the compaction blob"}`))) + retry, changed, err := trimGrokInvalidEncryptedContentRetryBody(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(retry, "input.0.type").String()) + require.False(t, gjson.GetBytes(retry, "input.#(type==\"compaction\")").Exists()) +} + func TestForwardGrokResponsesInvalidEncryptedContentRecoveryNestedErrorShape(t *testing.T) { gin.SetMode(gin.TestMode) From 17c0ee385985073fb4a35a10ad1fb39eadb08959 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:08:34 -0700 Subject: [PATCH 17/28] =?UTF-8?q?=E6=94=AF=E6=8C=81=20Grok=20compaction=20?= =?UTF-8?q?422=20=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/openai_gateway_grok.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 132f7b3159..6ad03cc253 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -119,7 +119,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( // xAI can reject encrypted reasoning copied from a response produced under // another account or cache identity. Retry once with the same routing and // credential after removing only the rejected encrypted reasoning payload. - if attempt > 0 || resp.StatusCode != http.StatusBadRequest { + if attempt > 0 || (resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnprocessableEntity) { break } respBody := s.readUpstreamErrorBody(resp) From 5ae254f771588d2aef990cb1205024d30aab6d81 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:25:57 -0700 Subject: [PATCH 18/28] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Grok=20CC=20bridge?= =?UTF-8?q?=20=E5=90=8C=E5=8F=B7=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/openai_gateway_grok_chat_bridge.go | 6 +++++- .../service/openai_gateway_grok_chat_bridge_test.go | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index e2b8f9a477..1c9f6c4b7d 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -633,11 +633,15 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( }) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { + retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, ResponseBody: respBody, ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + RetryableOnSameAccount: retryable, + RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, } } return s.handleChatCompletionsErrorResponse(resp, c, account, billingModel) diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go index 5904c8ce3c..c854b462b7 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go @@ -614,6 +614,9 @@ func TestForwardGrokChatViaResponses429UsesGrokRateLimitPolicy(t *testing.T) { var failoverErr *UpstreamFailoverError require.True(t, errors.As(err, &failoverErr)) require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) + require.True(t, failoverErr.RetryableOnSameAccount) + require.Equal(t, 500*time.Millisecond, failoverErr.SameAccountRetryDelay) + require.False(t, failoverErr.SameAccountRetryDeadline.IsZero()) require.Equal(t, "45", failoverErr.ResponseHeaders.Get("Retry-After")) require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, grokChatResponsesEndpoint, GetActualOpenAIUpstreamEndpoint(c)) From ab9cb69e7e5c8bf623ea6025bc6cc432c2401a3c Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:36:42 -0700 Subject: [PATCH 19/28] =?UTF-8?q?Revert=20"=E4=BF=AE=E5=A4=8D=20Grok=20Web?= =?UTF-8?q?Search=20SSE=20action=20=E5=85=BC=E5=AE=B9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 726de30101a75145e6b5a4bfc605e5a9d79007b7. --- .../openai_gateway_response_handling.go | 60 +------------------ .../service/openai_gateway_service_test.go | 11 ---- 2 files changed, 1 insertion(+), 70 deletions(-) diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 5bc2d895e7..45a8e06de6 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -439,9 +439,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. sendErrorEvent("stream_read_error") return resultWithUsage(), fmt.Errorf("stream read error: %w", scanErr), true } - var pendingGrokWebSearchCompleted string - var processSSELine func(line string, queueDrained bool) - processSSELine = func(line string, queueDrained bool) { + processSSELine := func(line string, queueDrained bool) { if streamEarlyErr != nil { return } @@ -450,26 +448,6 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. dataBytes := []byte(data) eventTypeRaw := gjson.GetBytes(dataBytes, "type").String() eventType := strings.TrimSpace(eventTypeRaw) - // Grok Build's xAI decoder requires action on the completed search - // event, while OpenAI-compatible upstreams often provide it only on - // the following output_item.done event. Hold that one event until the - // matching item arrives, then replay it with action injected. - if account != nil && account.Platform == PlatformGrok && account.Type == AccountTypeAPIKey && - eventType == "response.web_search_call.completed" && - !gjson.GetBytes(dataBytes, "action").Exists() { - pendingGrokWebSearchCompleted = line - return - } - if pendingGrokWebSearchCompleted != "" && eventType == "response.output_item.done" { - if adapted, adaptedOK := adaptGrokWebSearchCompletedAction( - []byte(strings.TrimSpace(strings.TrimPrefix(pendingGrokWebSearchCompleted, "data:"))), dataBytes, - ); adaptedOK { - pendingGrokWebSearchCompleted = "data: " + string(adapted) - } - pending := pendingGrokWebSearchCompleted - pendingGrokWebSearchCompleted = "" - processSSELine(pending, queueDrained) - } observer.ObserveOpenAI(dataBytes, eventTypeRaw) // 初始上游 data 的 type 只解析一次:原始值保持终止事件的精确匹配,规范化值供后续分支复用。 if openAIStreamEventIsTerminalWithType(data, eventTypeRaw) { @@ -662,11 +640,6 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. // A blank line dispatches a guarded event from the attempt-local stage. if stageFirstOutput && line == "" { - if pendingGrokWebSearchCompleted != "" { - pending := pendingGrokWebSearchCompleted - pendingGrokWebSearchCompleted = "" - processSSELine(pending, queueDrained) - } if !clientDisconnected { if _, err := writePendingString("\n"); err != nil { handlePendingWriteError(err) @@ -681,11 +654,6 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. // or queue-drain flush must never split an open SSE event. shouldFlush := false if line == "" { - if pendingGrokWebSearchCompleted != "" { - pending := pendingGrokWebSearchCompleted - pendingGrokWebSearchCompleted = "" - processSSELine(pending, queueDrained) - } shouldFlush = eventShouldFlush || (queueDrained && clientOutputStarted) eventShouldFlush = false } @@ -1205,32 +1173,6 @@ func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *g logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) } -// adaptGrokWebSearchCompletedAction fills the xAI-specific action field that -// Grok Build expects on response.web_search_call.completed. OpenAI-compatible -// upstreams commonly emit the action only on the later output_item.done event. -func adaptGrokWebSearchCompletedAction(completed, outputItem []byte) ([]byte, bool) { - if strings.TrimSpace(gjson.GetBytes(completed, "type").String()) != "response.web_search_call.completed" { - return completed, false - } - itemID := strings.TrimSpace(gjson.GetBytes(completed, "item_id").String()) - item := gjson.GetBytes(outputItem, "item") - if itemID == "" || !item.Exists() || strings.TrimSpace(item.Get("type").String()) != "web_search_call" { - return completed, false - } - if itemID != strings.TrimSpace(item.Get("id").String()) { - return completed, false - } - action := item.Get("action") - if !action.Exists() || strings.TrimSpace(action.Raw) == "" || action.Raw == "null" { - return completed, false - } - updated, err := sjson.SetRawBytes(completed, "action", []byte(action.Raw)) - if err != nil { - return completed, false - } - return updated, true -} - func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { if !value.Exists() || !value.IsObject() { return OpenAIUsage{}, false diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 2b0344c309..998ab6a154 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -3463,17 +3463,6 @@ func TestExtractOpenAIUsageFromJSONBytes_AcceptsResponseAndChatUsageShapes(t *te require.Equal(t, 500, usage.OutputTokens) } -func TestAdaptGrokWebSearchCompletedAction(t *testing.T) { - completed := []byte(`{"type":"response.web_search_call.completed","item_id":"call_1"}`) - done := []byte(`{"type":"response.output_item.done","item":{"type":"web_search_call","id":"call_1","action":{"type":"search","query":"latest news"}}}`) - got, ok := adaptGrokWebSearchCompletedAction(completed, done) - require.True(t, ok) - require.Equal(t, "search", gjson.GetBytes(got, "action.type").String()) - require.Equal(t, "latest news", gjson.GetBytes(got, "action.query").String()) - _, ok = adaptGrokWebSearchCompletedAction(completed, []byte(`{"type":"response.output_item.done","item":{"type":"web_search_call","id":"other"}}`)) - require.False(t, ok) -} - func TestExtractOpenAIUsageFromJSONBytes_IncludesGrokReasoningTokens(t *testing.T) { usage, ok := extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"prompt_tokens":32,"completion_tokens":9,"total_tokens":135,"completion_tokens_details":{"reasoning_tokens":94}}}`)) require.True(t, ok) From 2ab24a1e77189138297605342586157bc66517b8 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:43:31 -0700 Subject: [PATCH 20/28] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20Grok=20429=20?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E4=B8=8E=20stream=20idle=20=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/failover_loop.go | 3 +++ backend/internal/service/gateway_service.go | 1 + backend/internal/service/grok_stream_idle.go | 1 + backend/internal/service/grok_stream_idle_test.go | 1 + backend/internal/service/grok_upstream_failure.go | 9 +-------- backend/internal/service/grok_upstream_failure_test.go | 8 ++++---- backend/internal/service/openai_gateway_grok_test.go | 8 ++++---- 7 files changed, 15 insertions(+), 16 deletions(-) diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go index d349980fc6..c51779c047 100644 --- a/backend/internal/handler/failover_loop.go +++ b/backend/internal/handler/failover_loop.go @@ -171,6 +171,9 @@ func (s *FailoverState) HandleFailoverError( // 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。 retryCount := s.SameAccountRetryCount[accountID] + if failoverErr.SameAccountRetryMax > 0 && (retryLimit <= 0 || failoverErr.SameAccountRetryMax < retryLimit) { + retryLimit = failoverErr.SameAccountRetryMax + } sameAccountRetryAllowed := failoverErr.RetryableOnSameAccount && retryLimit > 0 && retryCount < retryLimit if sameAccountRetryAllowed && !failoverErr.SameAccountRetryDeadline.IsZero() { sameAccountRetryAllowed = time.Now().Before(failoverErr.SameAccountRetryDeadline) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 6e1ebecbbb..0269dc55c6 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -677,6 +677,7 @@ type UpstreamFailoverError struct { RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换 SameAccountRetryDelay time.Duration // 同账号重试的最小间隔;零值使用 handler 默认值 SameAccountRetryDeadline time.Time // 同账号重试截止时间;零值表示仅受 retryLimit 限制 + SameAccountRetryMax int // 可选的错误级同账号重试上限,低于 handler 默认预算时优先采用 RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁 SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号 Stage GatewayFailureStage diff --git a/backend/internal/service/grok_stream_idle.go b/backend/internal/service/grok_stream_idle.go index cbe1fdc767..ddfa9f80bd 100644 --- a/backend/internal/service/grok_stream_idle.go +++ b/backend/internal/service/grok_stream_idle.go @@ -38,6 +38,7 @@ func grokStreamIdleFailoverError(account *Account, idle time.Duration) *Upstream // the request's retry limit. RetryableOnSameAccount: account != nil && account.Platform == PlatformGrok, RequestScopedTransient: true, + SameAccountRetryMax: 1, // Permit at most one same-account replay after the idle failure. The // deadline is anchored at failure time, so a hung stream cannot consume // the normal three-attempt budget before failover. diff --git a/backend/internal/service/grok_stream_idle_test.go b/backend/internal/service/grok_stream_idle_test.go index 69fdcc1da9..7110dac9f2 100644 --- a/backend/internal/service/grok_stream_idle_test.go +++ b/backend/internal/service/grok_stream_idle_test.go @@ -23,6 +23,7 @@ func TestGrokStreamIdleFailoverError(t *testing.T) { require.True(t, err.SafeToFailoverAfterWrite) require.True(t, err.RetryableOnSameAccount) require.True(t, err.RequestScopedTransient) + require.Equal(t, 1, err.SameAccountRetryMax) require.Contains(t, string(err.ResponseBody), "empty_upstream") require.WithinDuration(t, time.Now().Add(180*time.Second), err.SameAccountRetryDeadline, 2*time.Second) } diff --git a/backend/internal/service/grok_upstream_failure.go b/backend/internal/service/grok_upstream_failure.go index 51edbc3332..3d6aa05c6e 100644 --- a/backend/internal/service/grok_upstream_failure.go +++ b/backend/internal/service/grok_upstream_failure.go @@ -402,13 +402,6 @@ func grokRetryableOnSameAccount(account *Account, statusCode int, responseBody [ if statusCode == http.StatusTooManyRequests { return true } - case GrokFailureRateLimit: - // A transient 429 does not identify a bad credential. Give every Grok - // account a bounded same-account retry window before failover; the - // failover loop still caps attempts and the client receives 429 after it. - if statusCode == http.StatusTooManyRequests { - return true - } } return account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode) } @@ -418,7 +411,7 @@ func grokSameAccountRetryMetadata(account *Account, statusCode int, responseBody return false, 0, time.Time{} } decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") - if decision.Class != GrokFailureModelCapacity && decision.Class != GrokFailureRateLimit { + if decision.Class != GrokFailureModelCapacity { return true, 0, time.Time{} } return true, 500 * time.Millisecond, time.Now().Add(30 * time.Second) diff --git a/backend/internal/service/grok_upstream_failure_test.go b/backend/internal/service/grok_upstream_failure_test.go index 0cc8cd1efa..d6e6ec7ace 100644 --- a/backend/internal/service/grok_upstream_failure_test.go +++ b/backend/internal/service/grok_upstream_failure_test.go @@ -81,7 +81,7 @@ func TestGrokRetryableOnSameAccount_CapacityAndRateLimit(t *testing.T) { account := &Account{ID: 9105, Platform: PlatformGrok, Type: AccountTypeOAuth} require.True(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests, []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`))) - require.True(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests, + require.False(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests, []byte(`{"error":{"message":"rate limit exceeded"}}`))) require.False(t, grokRetryableOnSameAccount(account, http.StatusPaymentRequired, []byte(`{"error":{"message":"You have run out of credits or need a Grok subscription"}}`))) @@ -118,9 +118,9 @@ func TestGrokSameAccountRetryMetadata_CapacityDeadline(t *testing.T) { retryable, delay, deadline = grokSameAccountRetryMetadata(account, http.StatusTooManyRequests, []byte(`{"error":{"message":"rate limit exceeded"}}`)) - require.True(t, retryable) - require.Equal(t, 500*time.Millisecond, delay) - require.WithinDuration(t, time.Now().Add(30*time.Second), deadline, 2*time.Second) + require.False(t, retryable) + require.Zero(t, delay) + require.True(t, deadline.IsZero()) } func TestClassifyGrokUpstreamFailure_ValidationNoCool(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index b89c9eef83..27419e82c3 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -1794,7 +1794,7 @@ func TestForwardAsChatCompletionsForGrokStopFallsBackToXAIChatCompletions(t *tes require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists()) require.Equal(t, "grok", result.Model) - require.Equal(t, "grok-4.5", result.UpstreamModel) + require.Equal(t, "grok-4.6", result.UpstreamModel) require.Equal(t, 1, result.Usage.InputTokens) require.Equal(t, 2, result.Usage.OutputTokens) require.Equal(t, 1, result.Usage.CacheReadInputTokens) @@ -1848,7 +1848,7 @@ func TestForwardGrokResponsesStreamingDefaultsEmptyModelTo45AndSnapshots(t *test require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) - require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) require.Equal(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String(), upstream.lastReq.Header.Get(grokConversationIDHeader)) require.Equal(t, "web_search", gjson.GetBytes(upstream.lastBody, "tools.0.type").String()) @@ -2367,7 +2367,7 @@ func TestForwardAsChatCompletionsForGrokStreamingUsesRawXAIChatCompletions(t *te require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept")) require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool()) require.True(t, result.Stream) require.Equal(t, 6, result.Usage.InputTokens) @@ -2648,7 +2648,7 @@ func TestForwardAsAnthropicForGrokUsesXAIResponses(t *testing.T) { require.Equal(t, "grok-experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) require.Empty(t, upstream.lastReq.Header.Get("originator")) require.Empty(t, upstream.lastReq.Header.Get("version")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) require.Equal(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String(), upstream.lastReq.Header.Get(grokConversationIDHeader)) require.Equal(t, "web_search", gjson.GetBytes(upstream.lastBody, "tools.0.type").String()) From 0b1f79c83ce6ec088e266fba910db93f70fe9cbb Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:43:54 -0700 Subject: [PATCH 21/28] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20Grok=20429=20bridge?= =?UTF-8?q?=20=E5=9B=9E=E5=BD=92=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/openai_gateway_grok_chat_bridge_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go index c854b462b7..5904c8ce3c 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go @@ -614,9 +614,6 @@ func TestForwardGrokChatViaResponses429UsesGrokRateLimitPolicy(t *testing.T) { var failoverErr *UpstreamFailoverError require.True(t, errors.As(err, &failoverErr)) require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) - require.True(t, failoverErr.RetryableOnSameAccount) - require.Equal(t, 500*time.Millisecond, failoverErr.SameAccountRetryDelay) - require.False(t, failoverErr.SameAccountRetryDeadline.IsZero()) require.Equal(t, "45", failoverErr.ResponseHeaders.Get("Retry-After")) require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, grokChatResponsesEndpoint, GetActualOpenAIUpstreamEndpoint(c)) From f7bc1970efbda1a847b2465d233811ce6cd89ea9 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:00:42 -0700 Subject: [PATCH 22/28] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20Grok=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=A8=A1=E5=9E=8B=E5=9B=9E=E5=BD=92=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/openai_gateway_grok_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 27419e82c3..3d2fd953fa 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -1848,7 +1848,7 @@ func TestForwardGrokResponsesStreamingDefaultsEmptyModelTo45AndSnapshots(t *test require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) require.Equal(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String(), upstream.lastReq.Header.Get(grokConversationIDHeader)) require.Equal(t, "web_search", gjson.GetBytes(upstream.lastBody, "tools.0.type").String()) @@ -2543,7 +2543,7 @@ func TestForwardAsChatCompletionsForGrokStreamingStopFallsBackToRawXAIChatComple require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version")) require.NotEmpty(t, upstream.lastReq.Header.Get(grokConversationIDHeader)) require.NotEqual(t, "native-client-conversation", upstream.lastReq.Header.Get(grokConversationIDHeader)) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool()) require.True(t, result.Stream) require.Equal(t, 6, result.Usage.InputTokens) @@ -2658,7 +2658,7 @@ func TestForwardAsAnthropicForGrokUsesXAIResponses(t *testing.T) { require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) require.NotContains(t, string(upstream.lastBody), "chatgpt.com") require.Equal(t, "grok", result.Model) - require.Equal(t, "grok-4.5", result.UpstreamModel) + require.Equal(t, "grok-4.6", result.UpstreamModel) require.Equal(t, 5, result.Usage.InputTokens) require.Equal(t, 2, result.Usage.OutputTokens) require.Equal(t, 3, result.Usage.CacheReadInputTokens) From c628b3eea70d3a8813074fdacc252d8c3af16bba Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:07:55 -0700 Subject: [PATCH 23/28] =?UTF-8?q?=E8=AE=A9=20Grok=20stream=20idle=20?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E4=B8=8A=E9=99=90=E4=BD=9C=E7=94=A8=E4=BA=8E?= =?UTF-8?q?=E4=B8=BB=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/failover_loop.go | 15 ++++++++++++++- backend/internal/handler/failover_loop_test.go | 7 +++++++ backend/internal/handler/grok_media.go | 2 +- .../internal/handler/openai_chat_completions.go | 2 +- .../internal/handler/openai_gateway_handler.go | 4 ++-- backend/internal/handler/openai_images.go | 2 +- .../service/openai_gateway_grok_chat_bridge.go | 8 ++++---- 7 files changed, 30 insertions(+), 10 deletions(-) diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go index c51779c047..e626d66e05 100644 --- a/backend/internal/handler/failover_loop.go +++ b/backend/internal/handler/failover_loop.go @@ -82,6 +82,19 @@ func sameAccountRetryDeadlineAllows(failoverErr *service.UpstreamFailoverError) return failoverErr == nil || failoverErr.SameAccountRetryDeadline.IsZero() || time.Now().Before(failoverErr.SameAccountRetryDeadline) } +// effectiveSameAccountRetryLimit applies an error-specific cap without +// overriding an explicit account setting of zero (which disables retries). +func effectiveSameAccountRetryLimit(failoverErr *service.UpstreamFailoverError, account *service.Account) int { + if account == nil { + return 0 + } + limit := account.GetPoolModeRetryCount() + if limit > 0 && failoverErr != nil && failoverErr.SameAccountRetryMax > 0 && failoverErr.SameAccountRetryMax < limit { + return failoverErr.SameAccountRetryMax + } + return limit +} + // FailoverState 跨循环迭代共享的 failover 状态 type FailoverState struct { SwitchCount int @@ -171,7 +184,7 @@ func (s *FailoverState) HandleFailoverError( // 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。 retryCount := s.SameAccountRetryCount[accountID] - if failoverErr.SameAccountRetryMax > 0 && (retryLimit <= 0 || failoverErr.SameAccountRetryMax < retryLimit) { + if failoverErr.SameAccountRetryMax > 0 && failoverErr.SameAccountRetryMax < retryLimit { retryLimit = failoverErr.SameAccountRetryMax } sameAccountRetryAllowed := failoverErr.RetryableOnSameAccount && retryLimit > 0 && retryCount < retryLimit diff --git a/backend/internal/handler/failover_loop_test.go b/backend/internal/handler/failover_loop_test.go index 40602f9df9..bcf8110850 100644 --- a/backend/internal/handler/failover_loop_test.go +++ b/backend/internal/handler/failover_loop_test.go @@ -70,6 +70,13 @@ func TestSameAccountRetryDeadlineAllows(t *testing.T) { })) } +func TestEffectiveSameAccountRetryLimitHonorsErrorCapAndDisabledAccount(t *testing.T) { + account := &service.Account{Type: service.AccountTypeAPIKey, Credentials: map[string]any{"pool_mode": true, "pool_mode_retry_count": float64(3)}} + require.Equal(t, 1, effectiveSameAccountRetryLimit(&service.UpstreamFailoverError{SameAccountRetryMax: 1}, account)) + account.Credentials["pool_mode_retry_count"] = float64(0) + require.Equal(t, 0, effectiveSameAccountRetryLimit(&service.UpstreamFailoverError{SameAccountRetryMax: 1}, account)) +} + // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index 6e5619b6e5..94f267a970 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -361,7 +361,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. return } if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() + retryLimit := effectiveSameAccountRetryLimit(failoverErr, account) if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 1dd8f63ceb..78dd7eb098 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -327,7 +327,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { } // Pool mode: retry on the same account if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() + retryLimit := effectiveSameAccountRetryLimit(failoverErr, account) if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 1f7c849a6a..bbece97730 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -717,7 +717,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } // 池模式:同账号重试 if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() + retryLimit := effectiveSameAccountRetryLimit(failoverErr, account) if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) @@ -1266,7 +1266,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { } // 池模式:同账号重试 if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() + retryLimit := effectiveSameAccountRetryLimit(failoverErr, account) if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index a82c7b7193..6ff30eccfe 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -304,7 +304,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { return } if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() + retryLimit := effectiveSameAccountRetryLimit(failoverErr, account) if sameAccountRetryCount[account.ID] < retryLimit && sameAccountRetryDeadlineAllows(failoverErr) { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index 1c9f6c4b7d..84e2c95e72 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -635,10 +635,10 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: retryable, + StatusCode: resp.StatusCode, + ResponseBody: respBody, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, SameAccountRetryDelay: retryDelay, SameAccountRetryDeadline: retryDeadline, From cca235365b35c1a54fcdb1b1800807e9ee510bcd Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:19:51 -0700 Subject: [PATCH 24/28] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20Grok=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=A8=A1=E5=9E=8B=E6=B5=8B=E8=AF=95=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/openai_gateway_grok_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 3d2fd953fa..92f9901142 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -1848,7 +1848,7 @@ func TestForwardGrokResponsesStreamingDefaultsEmptyModelTo45AndSnapshots(t *test require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) - require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) require.Equal(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String(), upstream.lastReq.Header.Get(grokConversationIDHeader)) require.Equal(t, "web_search", gjson.GetBytes(upstream.lastBody, "tools.0.type").String()) From d78e366db552a6dd361701981af2dc3065d2734a Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:31:19 -0700 Subject: [PATCH 25/28] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Grok=20Realtime=20?= =?UTF-8?q?=E6=8F=A1=E6=89=8B=E5=A4=B1=E8=B4=A5=E8=B4=A6=E5=8F=B7=E5=86=B7?= =?UTF-8?q?=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/grok_audio.go | 6 ++++++ backend/internal/service/grok_audio.go | 23 +++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/backend/internal/handler/grok_audio.go b/backend/internal/handler/grok_audio.go index 08a8ff3c6c..c95b5ce559 100644 --- a/backend/internal/handler/grok_audio.go +++ b/backend/internal/handler/grok_audio.go @@ -98,6 +98,12 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { cancelProbe() if openErr != nil { reqLog.Warn("grok_realtime.pre_accept_failed", zap.Int64("account_id", account.ID), zap.Error(openErr)) + statusCode := http.StatusBadGateway + var dialErr *service.GrokRealtimeDialError + if errors.As(openErr, &dialErr) && dialErr.StatusCode > 0 { + statusCode = dialErr.StatusCode + } + h.gatewayService.HandleGrokRealtimeUpstreamError(c.Request.Context(), account, statusCode, []byte(openErr.Error())) release() release = nil failed[account.ID] = struct{}{} diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index a2fb33f12d..771cae09f4 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -139,6 +139,16 @@ func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Con type GrokRealtimeUpstream struct{ conn openAIWSClientConn } +// GrokRealtimeDialError preserves an HTTP status returned before WebSocket +// upgrade so handlers can apply the normal Grok account policy. +type GrokRealtimeDialError struct { + StatusCode int + Err error +} + +func (e *GrokRealtimeDialError) Error() string { return e.Err.Error() } +func (e *GrokRealtimeDialError) Unwrap() error { return e.Err } + func (u *GrokRealtimeUpstream) Close() error { if u == nil || u.conn == nil { return nil @@ -171,13 +181,22 @@ func (s *OpenAIGatewayService) OpenGrokRealtime(ctx context.Context, account *Ac if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - conn, _, _, err := s.getOpenAIWSPassthroughDialer().Dial(ctx, u.String(), headers, proxyURL) + conn, status, _, err := s.getOpenAIWSPassthroughDialer().Dial(ctx, u.String(), headers, proxyURL) if err != nil { - return nil, err + return nil, &GrokRealtimeDialError{StatusCode: status, Err: err} } return &GrokRealtimeUpstream{conn: conn}, nil } +// HandleGrokRealtimeUpstreamError applies the shared Grok account policy to a +// failed pre-accept WebSocket handshake. +func (s *OpenAIGatewayService) HandleGrokRealtimeUpstreamError(ctx context.Context, account *Account, statusCode int, body []byte) { + if statusCode <= 0 { + statusCode = http.StatusBadGateway + } + s.handleGrokAccountUpstreamError(ctx, account, statusCode, nil, body) +} + func (s *OpenAIGatewayService) ProxyGrokRealtimeConn(ctx context.Context, c *gin.Context, client *coderws.Conn, upstream *GrokRealtimeUpstream) (bool, error) { if s == nil || client == nil || upstream == nil || upstream.conn == nil { return false, fmt.Errorf("realtime connection is required") From 39aaf2fea32fc73097aa60197e963383672e0267 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:07:04 -0700 Subject: [PATCH 26/28] =?UTF-8?q?=E6=94=B6=E7=B4=A7=20Grok=20=E5=AE=B9?= =?UTF-8?q?=E9=87=8F=E9=87=8D=E8=AF=95=E4=B8=8E=E5=85=BC=E5=AE=B9=E6=80=A7?= =?UTF-8?q?=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/grok_media.go | 3 ++- .../internal/service/grok_upstream_failure.go | 19 +++++++++++++------ .../service/grok_upstream_failure_test.go | 15 +++++++++++++-- .../openai_gateway_chat_completions_raw.go | 3 ++- .../internal/service/openai_gateway_grok.go | 6 ++++-- .../openai_gateway_grok_chat_bridge.go | 3 ++- 6 files changed, 36 insertions(+), 13 deletions(-) diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 7c4a12cd0b..3d7ad1fe31 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -1288,7 +1288,7 @@ func (s *OpenAIGatewayService) handleGrokMediaErrorResponse( Detail: upstreamDetail, }) if kind == "failover" { - retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, body) + retryable, retryDelay, retryDeadline, retryMax := grokSameAccountRetryMetadata(account, resp.StatusCode, body) return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, ResponseBody: body, @@ -1297,6 +1297,7 @@ func (s *OpenAIGatewayService) handleGrokMediaErrorResponse( RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, SameAccountRetryDelay: retryDelay, SameAccountRetryDeadline: retryDeadline, + SameAccountRetryMax: retryMax, } } diff --git a/backend/internal/service/grok_upstream_failure.go b/backend/internal/service/grok_upstream_failure.go index 3d6aa05c6e..53d9d5500a 100644 --- a/backend/internal/service/grok_upstream_failure.go +++ b/backend/internal/service/grok_upstream_failure.go @@ -119,7 +119,7 @@ func classifyGrokUpstreamFailure(statusCode int, responseBody []byte, requestedM // deployment while the same request is valid on another account. Treat // these precise decoder/content-shape failures as account compatibility // errors, rather than durable account health failures. - if isGrokCompatibilityError(low, code) { + if isGrokCompatibilityError(statusCode, low, code) { return GrokUpstreamFailureDecision{ Class: GrokFailureCompatibility, Model: model, @@ -406,15 +406,19 @@ func grokRetryableOnSameAccount(account *Account, statusCode int, responseBody [ return account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode) } -func grokSameAccountRetryMetadata(account *Account, statusCode int, responseBody []byte) (bool, time.Duration, time.Time) { +func grokSameAccountRetryMetadata(account *Account, statusCode int, responseBody []byte) (bool, time.Duration, time.Time, int) { if !grokRetryableOnSameAccount(account, statusCode, responseBody) { - return false, 0, time.Time{} + return false, 0, time.Time{}, 0 } decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") if decision.Class != GrokFailureModelCapacity { - return true, 0, time.Time{} + return true, 0, time.Time{}, 0 } - return true, 500 * time.Millisecond, time.Now().Add(30 * time.Second) + // The error is reconstructed after every upstream attempt, so a deadline + // stored on the error cannot provide a request-wide window. Cap capacity + // retries explicitly to one replay; this remains effective even when the + // first attempt itself takes longer than the nominal 30-second window. + return true, 500 * time.Millisecond, time.Now().Add(30 * time.Second), 1 } // shouldMarkGrokTeamModelRateLimit controls the process-local sibling-account @@ -430,7 +434,10 @@ func shouldMarkGrokTeamModelRateLimit(statusCode int, responseBody []byte) bool return statusCode == http.StatusTooManyRequests || decision.Class == GrokFailureFreeUsage } -func isGrokCompatibilityError(low, code string) bool { +func isGrokCompatibilityError(statusCode int, low, code string) bool { + if statusCode != http.StatusBadRequest && statusCode != http.StatusUnprocessableEntity { + return false + } combined := strings.ToLower(strings.TrimSpace(low + " " + code)) // Compaction blobs are account/session-bound and frequently fail with 400 // or 422 after a reconnect. Also cover xAI's JSON decoder shape errors. diff --git a/backend/internal/service/grok_upstream_failure_test.go b/backend/internal/service/grok_upstream_failure_test.go index d6e6ec7ace..4f6514cde1 100644 --- a/backend/internal/service/grok_upstream_failure_test.go +++ b/backend/internal/service/grok_upstream_failure_test.go @@ -110,17 +110,19 @@ func TestShouldMarkGrokTeamModelRateLimit_ExcludesCapacity(t *testing.T) { func TestGrokSameAccountRetryMetadata_CapacityDeadline(t *testing.T) { account := &Account{ID: 9107, Platform: PlatformGrok, Type: AccountTypeOAuth} - retryable, delay, deadline := grokSameAccountRetryMetadata(account, http.StatusTooManyRequests, + retryable, delay, deadline, retryMax := grokSameAccountRetryMetadata(account, http.StatusTooManyRequests, []byte(`{"error":{"message":"model capacity exceeded"}}`)) require.True(t, retryable) require.Equal(t, 500*time.Millisecond, delay) require.WithinDuration(t, time.Now().Add(30*time.Second), deadline, 2*time.Second) + require.Equal(t, 1, retryMax) - retryable, delay, deadline = grokSameAccountRetryMetadata(account, http.StatusTooManyRequests, + retryable, delay, deadline, retryMax = grokSameAccountRetryMetadata(account, http.StatusTooManyRequests, []byte(`{"error":{"message":"rate limit exceeded"}}`)) require.False(t, retryable) require.Zero(t, delay) require.True(t, deadline.IsZero()) + require.Zero(t, retryMax) } func TestClassifyGrokUpstreamFailure_ValidationNoCool(t *testing.T) { @@ -151,6 +153,15 @@ func TestClassifyGrokUpstreamFailure_CompatibilityDoesNotCooldown(t *testing.T) } } +func TestClassifyGrokUpstreamFailure_CompatibilityRequiresClientError(t *testing.T) { + body := []byte(`{"error":{"message":"upstream failed while handling the compaction blob"}}`) + for _, status := range []int{http.StatusBadGateway, http.StatusInternalServerError} { + d := classifyGrokUpstreamFailure(status, body, "grok-4.6") + require.NotEqual(t, GrokFailureCompatibility, d.Class) + require.True(t, d.ShouldCooldown) + } +} + func TestClassifyGrokUpstreamFailure_GenericShapeErrorDoesNotFailover(t *testing.T) { d := classifyGrokUpstreamFailure(http.StatusBadRequest, []byte(`{"error":{"message":"data did not match any variant of the untagged enum content"}}`), "grok-4.6") diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 00a7ece52c..1ef181b21b 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -200,7 +200,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( }) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { - retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) + retryable, retryDelay, retryDeadline, retryMax := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, ResponseBody: respBody, @@ -209,6 +209,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, SameAccountRetryDelay: retryDelay, SameAccountRetryDeadline: retryDeadline, + SameAccountRetryMax: retryMax, } } return s.handleChatCompletionsErrorResponse(resp, c, account, billingModel) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 6ad03cc253..9a16c6c7a7 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -173,7 +173,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( markGrokTeamModelRateLimit(account, upstreamModel, resolveGrokTeamRateLimitUntil(time.Now().Add(grokTeamRateLimitDefaultTTL), time.Now())) } if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { - retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) + retryable, retryDelay, retryDeadline, retryMax := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, ResponseBody: respBody, @@ -182,6 +182,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, SameAccountRetryDelay: retryDelay, SameAccountRetryDeadline: retryDeadline, + SameAccountRetryMax: retryMax, } } return s.handleErrorResponse(ctx, resp, c, account, patchedBody, upstreamModel) @@ -1186,7 +1187,7 @@ func (s *OpenAIGatewayService) describeGrokComposerImage( }) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, grokComposerImageBridgeVisionModel), account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { - retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) + retryable, retryDelay, retryDeadline, retryMax := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return "", OpenAIUsage{}, &UpstreamFailoverError{ StatusCode: resp.StatusCode, ResponseBody: respBody, @@ -1195,6 +1196,7 @@ func (s *OpenAIGatewayService) describeGrokComposerImage( RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, SameAccountRetryDelay: retryDelay, SameAccountRetryDeadline: retryDeadline, + SameAccountRetryMax: retryMax, } } return "", OpenAIUsage{}, fmt.Errorf("grok composer image bridge upstream error: %s", upstreamMsg) diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index 84e2c95e72..f04ab5ae43 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -633,7 +633,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( }) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { - retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) + retryable, retryDelay, retryDeadline, retryMax := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, ResponseBody: respBody, @@ -642,6 +642,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, SameAccountRetryDelay: retryDelay, SameAccountRetryDeadline: retryDeadline, + SameAccountRetryMax: retryMax, } } return s.handleChatCompletionsErrorResponse(resp, c, account, billingModel) From 2e68b10aadf13b94453bcca8c57605d754d4789a Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:12:52 -0700 Subject: [PATCH 27/28] =?UTF-8?q?=E5=AE=8C=E5=96=84=20Grok=20=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=8B=92=E7=BB=9D=E8=AE=A1=E8=B4=B9=E4=B8=8E=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/pkg/xai/usage.go | 25 +++ backend/internal/pkg/xai/usage_test.go | 30 ++++ backend/internal/service/grok_media.go | 22 ++- .../service/grok_media_image_geometry.go | 149 ++++++++++++++++++ .../service/grok_media_image_geometry_test.go | 60 +++++++ .../internal/service/grok_upstream_errors.go | 6 +- .../service/grok_upstream_errors_test.go | 33 ++++ .../internal/service/openai_gateway_grok.go | 97 ++++++++++-- .../service/openai_gateway_grok_test.go | 94 ++++++++++- .../openai_gateway_response_handling.go | 13 +- .../service/openai_ws_v2/passthrough_relay.go | 11 ++ .../passthrough_relay_internal_test.go | 23 +++ 12 files changed, 530 insertions(+), 33 deletions(-) create mode 100644 backend/internal/pkg/xai/usage.go create mode 100644 backend/internal/pkg/xai/usage_test.go create mode 100644 backend/internal/service/grok_media_image_geometry.go create mode 100644 backend/internal/service/grok_media_image_geometry_test.go diff --git a/backend/internal/pkg/xai/usage.go b/backend/internal/pkg/xai/usage.go new file mode 100644 index 0000000000..77aa382900 --- /dev/null +++ b/backend/internal/pkg/xai/usage.go @@ -0,0 +1,25 @@ +package xai + +// IncludeIndependentReasoningTokens adds reasoning tokens to billed output +// only when total_tokens proves they are not already folded into output. +// +// Official xAI Chat Completions example: prompt=32, completion=9, +// reasoning=94, total=135. Responses example: input=32, output=9, +// reasoning=110, total=151. OpenAI's canonical completion_tokens already +// includes reasoning and total equals input+output. +func IncludeIndependentReasoningTokens(input, output, total, reasoning int64) int64 { + if input < 0 || output < 0 || reasoning <= 0 || total <= 0 { + return output + } + if total == input+output { + return output + } + gap := total - input - output + if gap <= 0 { + return output + } + if reasoning < gap { + gap = reasoning + } + return output + gap +} diff --git a/backend/internal/pkg/xai/usage_test.go b/backend/internal/pkg/xai/usage_test.go new file mode 100644 index 0000000000..79b8f8639c --- /dev/null +++ b/backend/internal/pkg/xai/usage_test.go @@ -0,0 +1,30 @@ +package xai + +import "testing" + +func TestIncludeIndependentReasoningTokens(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input, output, total, reason int64 + want int64 + }{ + {name: "xAI chat example", input: 32, output: 9, total: 135, reason: 94, want: 103}, + {name: "xAI responses example", input: 32, output: 9, total: 151, reason: 110, want: 119}, + {name: "OpenAI inclusive output", input: 32, output: 103, total: 135, reason: 94, want: 103}, + {name: "gap smaller than reasoning", input: 10, output: 20, total: 33, reason: 5, want: 23}, + {name: "absent total", input: 10, output: 20, total: 0, reason: 5, want: 20}, + {name: "total already complete", input: 10, output: 20, total: 30, reason: 5, want: 20}, + {name: "inconsistent undershoot", input: 10, output: 20, total: 25, reason: 5, want: 20}, + {name: "zero reasoning", input: 10, output: 20, total: 35, reason: 0, want: 20}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := IncludeIndependentReasoningTokens(tt.input, tt.output, tt.total, tt.reason) + if got != tt.want { + t.Fatalf("got %d, want %d", got, tt.want) + } + }) + } +} diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 3d7ad1fe31..85dd1b14ec 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -59,6 +59,8 @@ type GrokMediaRequestInfo struct { N int Size string SizeTier string + AspectRatio string + ImageResolution string Resolution string DurationSeconds int InputImageURLs []string @@ -127,6 +129,8 @@ func ParseGrokMediaRequest(contentType string, body []byte) GrokMediaRequestInfo info.Prompt = strings.TrimSpace(info.Prompt) info.Size = strings.TrimSpace(info.Size) info.SizeTier = NormalizeImageBillingTierOrDefault(info.Size) + info.AspectRatio = strings.TrimSpace(info.AspectRatio) + info.ImageResolution = grokImagineImageResolution(info.ImageResolution) info.Resolution = NormalizeVideoBillingResolutionOrDefault(info.Resolution) info.DurationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(info.DurationSeconds) if info.N <= 0 { @@ -142,7 +146,8 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { info.Model = strings.TrimSpace(gjson.GetBytes(body, "model").String()) info.Prompt = strings.TrimSpace(gjson.GetBytes(body, "prompt").String()) info.Size = strings.TrimSpace(gjson.GetBytes(body, "size").String()) - info.Resolution = strings.TrimSpace(gjson.GetBytes(body, "resolution").String()) + info.AspectRatio = strings.TrimSpace(gjson.GetBytes(body, "aspect_ratio").String()) + assignGrokMediaResolution(strings.TrimSpace(gjson.GetBytes(body, "resolution").String()), info) if duration := gjson.GetBytes(body, "duration"); duration.Exists() && duration.Type == gjson.Number { info.DurationSeconds = int(duration.Int()) } @@ -255,8 +260,10 @@ func parseGrokMediaMultipartRequest(contentType string, body []byte, info *GrokM info.Prompt = value case "size": info.Size = value + case "aspect_ratio": + info.AspectRatio = value case "resolution": - info.Resolution = value + assignGrokMediaResolution(value, info) case "duration": if duration, err := strconv.Atoi(value); err == nil { info.DurationSeconds = duration @@ -934,6 +941,12 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten if info.Size != "" { payload["size"] = info.Size } + if info.ImageResolution != "" { + payload["resolution"] = info.ImageResolution + } + if info.AspectRatio != "" { + payload["aspect_ratio"] = info.AspectRatio + } images := make([]map[string]string, 0, len(info.InputImageURLs)+len(info.Uploads)) for _, imageURL := range info.InputImageURLs { @@ -1106,10 +1119,7 @@ func sanitizeGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conte } switch endpoint { case GrokMediaEndpointImagesGenerations, GrokMediaEndpointImagesEdits: - if !gjson.GetBytes(body, "size").Exists() { - return body, contentType, nil - } - out, err := sjson.DeleteBytes(body, "size") + out, err := applyGrokImagineImageGeometry(body) if err != nil { return nil, "", fmt.Errorf("sanitize grok media size: %w", err) } diff --git a/backend/internal/service/grok_media_image_geometry.go b/backend/internal/service/grok_media_image_geometry.go new file mode 100644 index 0000000000..51e2a6fd69 --- /dev/null +++ b/backend/internal/service/grok_media_image_geometry.go @@ -0,0 +1,149 @@ +package service + +import ( + "math" + "strconv" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Official Imagine image geometry: https://docs.x.ai/developers/model-capabilities/images/generation +var grokImagineAspectRatioValues = []struct { + label string + ratio float64 +}{ + {"1:1", 1}, + {"16:9", 16.0 / 9.0}, + {"9:16", 9.0 / 16.0}, + {"4:3", 4.0 / 3.0}, + {"3:4", 3.0 / 4.0}, + {"3:2", 1.5}, + {"2:3", 2.0 / 3.0}, + {"2:1", 2}, + {"1:2", 0.5}, + {"19.5:9", 19.5 / 9.0}, + {"9:19.5", 9.0 / 19.5}, + {"20:9", 20.0 / 9.0}, + {"9:20", 9.0 / 20.0}, +} + +func applyGrokImagineImageGeometry(body []byte) ([]byte, error) { + size := strings.TrimSpace(gjson.GetBytes(body, "size").String()) + resolution := grokImagineImageResolution(gjson.GetBytes(body, "resolution").String()) + aspect := strings.TrimSpace(gjson.GetBytes(body, "aspect_ratio").String()) + out := append([]byte(nil), body...) + + if resolution == "" { + if derived := grokImagineImageResolutionFromSize(size); derived != "" { + next, err := sjson.SetBytes(out, "resolution", derived) + if err != nil { + return nil, err + } + out = next + } + } else if gjson.GetBytes(body, "resolution").String() != resolution { + next, err := sjson.SetBytes(out, "resolution", resolution) + if err != nil { + return nil, err + } + out = next + } + + if aspect == "" { + if derived := grokImagineAspectRatioFromSize(size); derived != "" { + next, err := sjson.SetBytes(out, "aspect_ratio", derived) + if err != nil { + return nil, err + } + out = next + } + } + + if !gjson.GetBytes(out, "size").Exists() { + return out, nil + } + return sjson.DeleteBytes(out, "size") +} + +func assignGrokMediaResolution(value string, info *GrokMediaRequestInfo) { + if info == nil { + return + } + value = strings.TrimSpace(value) + if value == "" { + return + } + if img := grokImagineImageResolution(value); img != "" { + info.ImageResolution = img + return + } + info.Resolution = value +} + +func grokImagineImageResolution(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1k": + return "1k" + case "2k": + return "2k" + default: + return "" + } +} + +func grokImagineImageResolutionFromSize(size string) string { + if explicit := grokImagineImageResolution(size); explicit != "" { + return explicit + } + tier, ok := ClassifyImageBillingTier(size) + if !ok { + return "" + } + if tier == ImageBillingSize1K { + return "1k" + } + return "2k" +} + +func grokImagineAspectRatioFromSize(size string) string { + width, height, ok := parseImageBillingDimensions(strings.TrimSpace(size)) + if !ok || width <= 0 || height <= 0 { + return "" + } + div := grokImagineGCD(width, height) + exact := strconv.Itoa(width/div) + ":" + strconv.Itoa(height/div) + for _, candidate := range grokImagineAspectRatioValues { + if candidate.label == exact { + return exact + } + } + ratio := float64(width) / float64(height) + bestLabel := "" + bestDelta := math.MaxFloat64 + for _, candidate := range grokImagineAspectRatioValues { + delta := math.Abs(ratio - candidate.ratio) + if delta < bestDelta { + bestDelta = delta + bestLabel = candidate.label + } + } + return bestLabel +} + +func grokImagineGCD(a, b int) int { + if a < 0 { + a = -a + } + if b < 0 { + b = -b + } + for b != 0 { + a, b = b, a%b + } + if a == 0 { + return 1 + } + return a +} diff --git a/backend/internal/service/grok_media_image_geometry_test.go b/backend/internal/service/grok_media_image_geometry_test.go new file mode 100644 index 0000000000..cac26c3bd1 --- /dev/null +++ b/backend/internal/service/grok_media_image_geometry_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestApplyGrokImagineImageGeometryMapsOpenAISize(t *testing.T) { + t.Parallel() + + out, err := applyGrokImagineImageGeometry([]byte(`{"model":"grok-imagine-image-2.0","prompt":"hi","size":"1152x1536"}`)) + require.NoError(t, err) + require.False(t, gjson.GetBytes(out, "size").Exists()) + require.Equal(t, "2k", gjson.GetBytes(out, "resolution").String()) + require.Equal(t, "3:4", gjson.GetBytes(out, "aspect_ratio").String()) +} + +func TestApplyGrokImagineImageGeometryKeepsClientGeometry(t *testing.T) { + t.Parallel() + + out, err := applyGrokImagineImageGeometry([]byte(`{"size":"1024x1024","resolution":"2K","aspect_ratio":"16:9"}`)) + require.NoError(t, err) + require.False(t, gjson.GetBytes(out, "size").Exists()) + require.Equal(t, "2k", gjson.GetBytes(out, "resolution").String()) + require.Equal(t, "16:9", gjson.GetBytes(out, "aspect_ratio").String()) +} + +func TestSanitizeGrokMediaForwardBodyConvertsImageSize(t *testing.T) { + t.Parallel() + + out, contentType, err := sanitizeGrokMediaForwardBody( + GrokMediaEndpointImagesGenerations, + []byte(`{"model":"grok-imagine-image","prompt":"hi","size":"1024x1024"}`), + "application/json", + ) + require.NoError(t, err) + require.Equal(t, "application/json", contentType) + require.False(t, gjson.GetBytes(out, "size").Exists()) + require.Equal(t, "1k", gjson.GetBytes(out, "resolution").String()) + require.Equal(t, "1:1", gjson.GetBytes(out, "aspect_ratio").String()) +} + +func TestParseGrokMediaRequestKeepsImageResolutionOutOfVideoNormalize(t *testing.T) { + t.Parallel() + + info := ParseGrokMediaRequest("application/json", []byte(`{"model":"grok-imagine-image-2.0","resolution":"2K","aspect_ratio":"16:9"}`)) + require.Equal(t, "2k", info.ImageResolution) + require.Equal(t, "16:9", info.AspectRatio) + require.Equal(t, VideoBillingResolution480P, info.Resolution) +} + +func TestGrokImagineAspectRatioFromSize(t *testing.T) { + t.Parallel() + require.Equal(t, "1:1", grokImagineAspectRatioFromSize("1024x1024")) + require.Equal(t, "3:4", grokImagineAspectRatioFromSize("1152x1536")) + require.Equal(t, "4:3", grokImagineAspectRatioFromSize("1536x1152")) + require.Equal(t, "16:9", grokImagineAspectRatioFromSize("1792x1024")) +} diff --git a/backend/internal/service/grok_upstream_errors.go b/backend/internal/service/grok_upstream_errors.go index 1d1bddc6d6..db204fc70b 100644 --- a/backend/internal/service/grok_upstream_errors.go +++ b/backend/internal/service/grok_upstream_errors.go @@ -114,8 +114,9 @@ func isGrokAccountAccessCode(value string) bool { "subscription_required", "entitlement_required", "not_entitled", - "plan_required", - "permission_denied": + "plan_required": + // permission-denied is omitted: xAI reuses it for both entitlement + // refusals and request-scoped safety blocks, so the message decides. return true default: return false @@ -171,6 +172,7 @@ func grokContentPolicyMessage(value string) bool { "prompt violates policy", "input violates content policy", "input violates policy", + "violates usage guidelines", } { if strings.Contains(lower, phrase) { return true diff --git a/backend/internal/service/grok_upstream_errors_test.go b/backend/internal/service/grok_upstream_errors_test.go index 0dee756b72..3bd5ad3e73 100644 --- a/backend/internal/service/grok_upstream_errors_test.go +++ b/backend/internal/service/grok_upstream_errors_test.go @@ -83,6 +83,24 @@ func TestIsGrokContentPolicyRejection(t *testing.T) { body: `{"error":{"code":"policy_violation","message":"request blocked by policy"}}`, want: true, }, + { + name: "permission-denied usage guidelines is request scoped", + status: http.StatusForbidden, + body: `{"code":"permission-denied","error":"Content violates usage guidelines. "}`, + want: true, + }, + { + name: "permission-denied entitlement stays on the account path", + status: http.StatusForbidden, + body: `{"code":"permission-denied","error":"Access to the chat endpoint is denied"}`, + want: false, + }, + { + name: "structured account code overrides usage guidelines phrase", + status: http.StatusForbidden, + body: `{"error":{"code":"account_suspended","message":"Content violates usage guidelines."}}`, + want: false, + }, { name: "wrong status", status: http.StatusBadRequest, @@ -279,6 +297,21 @@ func TestGrokContentPolicySSEErrorDoesNotMutateOrFailover(t *testing.T) { require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) } +func TestGrokPermissionDeniedContentRefusalDoesNotMutateOrFailover(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 4785, Platform: PlatformGrok, Type: AccountTypeOAuth} + body := []byte(`{"code":"permission-denied","error":"Content violates usage guidelines. "}`) + + svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusForbidden, nil, body) + + require.Zero(t, repo.tempUnschedCalls) + require.Zero(t, repo.rateLimitedCalls) + require.Zero(t, repo.updateCalls) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.False(t, svc.shouldFailoverGrokUpstreamError(http.StatusForbidden, body)) +} + func TestHandleGrokAccountUpstreamErrorEntitlement403KeepsDefaultCooldown(t *testing.T) { repo := &grokQuotaAccountRepo{} svc := &OpenAIGatewayService{accountRepo: repo} diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 9a16c6c7a7..cca3b28ab3 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -856,33 +856,98 @@ func grokResponsesToolDedupKey(tool gjson.Result) string { return "json:" + normalizeCompatSeedJSON(json.RawMessage(tool.Raw)) } -// sanitizeGrokReasoningNullContent 删除 reasoning 项中的 "content": null。 -// xAI 的 untagged enum 反序列化器拒收该字段,返回 422。 +// sanitizeGrokReasoningNullContent drops explicit JSON nulls from Responses +// input items. xAI's untagged ModelInput decoder 422s on those fields. +// Compaction items stay unmodified per the compact contract. func sanitizeGrokReasoningNullContent(body []byte) ([]byte, error) { input := gjson.GetBytes(body, "input") - if !input.Exists() || !input.IsArray() { + if !input.Exists() || (!input.IsArray() && !input.IsObject()) { return body, nil } - items := input.Array() + var decoded map[string]any + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return body, nil + } + rawInput, ok := decoded["input"] + if !ok { + return body, nil + } + cleaned, changed := stripExplicitNullsFromGrokInput(rawInput) + if !changed { + return body, nil + } + decoded["input"] = cleaned + out, err := marshalOpenAIUpstreamJSON(decoded) + if err != nil { + return nil, err + } + return out, nil +} + +func stripExplicitNullsFromGrokInput(value any) (any, bool) { + switch node := value.(type) { + case []any: + changed := false + for i, item := range node { + itemMap, ok := item.(map[string]any) + if !ok { + next, childChanged := stripExplicitNullsFromGrokInput(item) + if childChanged { + node[i] = next + changed = true + } + continue + } + if isOpenAICompactionType(stringValue(itemMap["type"])) { + continue + } + next, childChanged := stripExplicitNullsFromJSONObject(itemMap) + if childChanged { + node[i] = next + changed = true + } + } + return node, changed + case map[string]any: + if isOpenAICompactionType(stringValue(node["type"])) { + return node, false + } + return stripExplicitNullsFromJSONObject(node) + default: + return value, false + } +} + +func stripExplicitNullsFromJSONObject(node map[string]any) (map[string]any, bool) { + if node == nil { + return node, false + } changed := false - for i := len(items) - 1; i >= 0; i-- { - item := items[i] - if strings.TrimSpace(item.Get("type").String()) != "reasoning" { + for key, child := range node { + if child == nil { + delete(node, key) + changed = true continue } - contentResult := item.Get("content") - if contentResult.Exists() && contentResult.Type == gjson.Null { - var err error - body, err = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.content", i)) - if err != nil { - return nil, err + switch typed := child.(type) { + case map[string]any: + next, childChanged := stripExplicitNullsFromJSONObject(typed) + if childChanged { + node[key] = next + changed = true + } + case []any: + next, childChanged := stripExplicitNullsFromGrokInput(typed) + if childChanged { + node[key] = next + changed = true } - changed = true } } - _ = changed - return body, nil + return node, changed } var grokResponsesSupportedToolTypes = map[string]struct{}{ diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 92f9901142..146b310176 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -1177,7 +1177,7 @@ func TestForwardGrokMediaAppliesAccountModelMappingAfterEndpointNormalization(t modelMapping: map[string]any{"grok-imagine-image-quality": "vendor-image-model"}, wantRequestModel: "grok-imagine-image-quality", wantUpstream: "vendor-image-model", - wantBody: `{"model":"vendor-image-model","prompt":"draw"}`, + wantBody: `{"model":"vendor-image-model","prompt":"draw","resolution":"1k","aspect_ratio":"1:1"}`, responseBody: `{"data":[{"url":"https://images.test/mapped.png"}]}`, }, { @@ -1300,7 +1300,8 @@ func TestForwardGrokMediaImagesGenerationStripsUnsupportedSize(t *testing.T) { result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointImagesGenerations, "", body, "application/json") require.NoError(t, err) - require.JSONEq(t, `{"model":"grok-imagine-image","prompt":"draw a cat"}`, string(upstream.lastBody)) + require.JSONEq(t, `{"model":"grok-imagine-image","prompt":"draw a cat","resolution":"1k","aspect_ratio":"1:1"}`, string(upstream.lastBody)) + require.False(t, gjson.GetBytes(upstream.lastBody, "size").Exists()) require.Equal(t, ImageBillingSize1K, result.ImageSize) require.Equal(t, "1024x1024", result.ImageInputSize) } @@ -1361,6 +1362,58 @@ func TestForwardGrokMediaImagesEditMultipartConvertsToJSON(t *testing.T) { require.Equal(t, "vendor-image-edit", result.UpstreamModel) } +func TestForwardGrokMediaImagesEditMultipartPreservesExplicitGeometry(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + gin.SetMode(gin.TestMode) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.WriteField("model", "grok-imagine-edit")) + require.NoError(t, writer.WriteField("prompt", "edit this private image")) + require.NoError(t, writer.WriteField("size", "1024x1024")) + require.NoError(t, writer.WriteField("resolution", "2k")) + require.NoError(t, writer.WriteField("aspect_ratio", "16:9")) + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Disposition", `form-data; name="image"; filename="input.png"`) + partHeader.Set("Content-Type", "image/png") + part, err := writer.CreatePart(partHeader) + require.NoError(t, err) + _, err = part.Write([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(buf.Bytes())) + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + + account := &Account{ + ID: 67, + Name: "grok", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "api-key", + "base_url": "https://xai.test/v1", + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[{"url":"https://images.test/edited.png"}]}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointImagesEdits, "", buf.Bytes(), writer.FormDataContentType()) + require.NoError(t, err) + require.False(t, gjson.GetBytes(upstream.lastBody, "size").Exists()) + require.Equal(t, "2k", gjson.GetBytes(upstream.lastBody, "resolution").String()) + require.Equal(t, "16:9", gjson.GetBytes(upstream.lastBody, "aspect_ratio").String()) + require.Equal(t, ImageBillingSize1K, result.ImageSize) + require.Equal(t, "1024x1024", result.ImageInputSize) +} + func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) { t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") gin.SetMode(gin.TestMode) @@ -3446,6 +3499,43 @@ func TestPatchGrokResponsesBody_StripsReasoningContentNull(t *testing.T) { require.False(t, reasoning.Get("content").Exists(), "content: null should be stripped") } +func TestPatchGrokResponsesBody_StripsNestedInputNulls(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model": "grok-4.6", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hi","logprobs":null}]} + ] + }`) + patched, err := patchGrokResponsesBody(body, "grok-4.6") + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "input.0.content.0.logprobs").Exists()) + require.Equal(t, "hi", gjson.GetBytes(patched, "input.0.content.0.text").String()) +} + +func TestStripExplicitNullsFromGrokInputSkipsCompaction(t *testing.T) { + t.Parallel() + + input := []any{ + map[string]any{"type": "message", "role": "user", "content": []any{map[string]any{"type": "input_text", "text": "hi", "logprobs": nil}}}, + map[string]any{"type": "compaction", "id": "cmp_1", "encrypted_content": "blob", "status": nil}, + } + out, changed := stripExplicitNullsFromGrokInput(input) + require.True(t, changed) + items, ok := out.([]any) + require.True(t, ok) + require.Len(t, items, 2) + msg := items[0].(map[string]any) + content := msg["content"].([]any)[0].(map[string]any) + _, hasLogprobs := content["logprobs"] + require.False(t, hasLogprobs) + compaction := items[1].(map[string]any) + require.Equal(t, "blob", compaction["encrypted_content"]) + _, hasStatus := compaction["status"] + require.True(t, hasStatus, "compaction items must stay unmodified") +} + func TestPatchGrokResponsesBody_KeepsReasoningContentNonNull(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 45a8e06de6..3c5a64e5fe 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -15,6 +15,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -1185,18 +1186,16 @@ func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { if outputTokens == 0 { outputTokens = value.Get("completion_tokens").Int() } - // xAI may report reasoning_tokens separately from visible output tokens. - // Only add it when total_tokens proves it is an independent component; - // OpenAI's canonical completion_tokens already includes reasoning details. + // xAI reports visible output separately from reasoning_tokens; OpenAI + // folds reasoning into completion/output. Use total_tokens to tell them apart. reasoningTokens := max(int(firstPositiveGJSONInt( value.Get("completion_tokens_details.reasoning_tokens"), value.Get("output_tokens_details.reasoning_tokens"), )), 0) if reasoningTokens > 0 { - totalTokens := value.Get("total_tokens").Int() - if totalTokens > 0 && totalTokens == inputTokens+outputTokens+int64(reasoningTokens) { - outputTokens += int64(reasoningTokens) - } + outputTokens = xai.IncludeIndependentReasoningTokens( + inputTokens, outputTokens, value.Get("total_tokens").Int(), int64(reasoningTokens), + ) } cacheReadTokens := openAICacheReadTokensFromUsage(value) cacheCreationTokens := openAICacheCreationTokensFromUsage(value) diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index abc94e8f1c..2cad27d88f 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -13,6 +13,8 @@ import ( coderws "github.com/coder/websocket" "github.com/tidwall/gjson" + + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) type FrameConn interface { @@ -948,6 +950,15 @@ func parseUsageAndAccumulate( // 解析失败时不做部分字段累加,避免计费 usage 出现“半有效”状态。 return Usage{} } + reasoningTokens := usageResult.Get("output_tokens_details.reasoning_tokens").Int() + if reasoningTokens == 0 { + reasoningTokens = usageResult.Get("completion_tokens_details.reasoning_tokens").Int() + } + if reasoningTokens > 0 { + outputTokens = int(xai.IncludeIndependentReasoningTokens( + int64(inputTokens), int64(outputTokens), usageResult.Get("total_tokens").Int(), reasoningTokens, + )) + } parsedUsage := Usage{ InputTokens: inputTokens, OutputTokens: outputTokens, diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go index ebb2394a12..51f1afb6ee 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go @@ -324,6 +324,29 @@ func TestParseUsageAndEnrichCoverage(t *testing.T) { enrichResult(nil, state, 0) } +func TestParseUsageAndAccumulateIncludesIndependentReasoningTokens(t *testing.T) { + t.Parallel() + + state := &relayState{} + got := parseUsageAndAccumulate( + state, + []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":32,"output_tokens":9,"total_tokens":151,"output_tokens_details":{"reasoning_tokens":110}}}}`), + "response.completed", + nil, + ) + require.Equal(t, 32, got.InputTokens) + require.Equal(t, 119, got.OutputTokens) + + state = &relayState{} + got = parseUsageAndAccumulate( + state, + []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":32,"output_tokens":119,"total_tokens":151,"output_tokens_details":{"reasoning_tokens":110}}}}`), + "response.completed", + nil, + ) + require.Equal(t, 119, got.OutputTokens, "inclusive Responses output must not double-count reasoning") +} + func TestParseUsageAndAccumulateAcceptsChatUsageAliases(t *testing.T) { t.Parallel() From 1bff06ea502290ac1b8f6702d1ea5a85cc90ca5f Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:50:23 -0700 Subject: [PATCH 28/28] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Grok=20Realtime=20?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E6=A3=80=E6=9F=A5=E4=B8=8E=20rollup=20?= =?UTF-8?q?=E6=97=B6=E5=8C=BA=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit golangci 要求检查 Realtime 上游 Close 返回值,并删除已无调用的 heavy-model 冷却函数。分组日汇总集成测试在 UTC 晚上跨上海零点时会把水位写成 UTC 日期,将会话时区钉到 Asia/Shanghai。 --- backend/internal/handler/grok_audio.go | 2 +- .../group_usage_rollup_trigger_integration_test.go | 7 +++++++ backend/internal/service/grok_audio.go | 2 +- backend/internal/service/openai_gateway_grok.go | 5 ----- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/internal/handler/grok_audio.go b/backend/internal/handler/grok_audio.go index c95b5ce559..28ae40a39b 100644 --- a/backend/internal/handler/grok_audio.go +++ b/backend/internal/handler/grok_audio.go @@ -121,7 +121,7 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { return } defer release() - defer upstream.Close() + defer func() { _ = upstream.Close() }() conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) if err != nil { diff --git a/backend/internal/repository/group_usage_rollup_trigger_integration_test.go b/backend/internal/repository/group_usage_rollup_trigger_integration_test.go index db182be495..32016e3c0a 100644 --- a/backend/internal/repository/group_usage_rollup_trigger_integration_test.go +++ b/backend/internal/repository/group_usage_rollup_trigger_integration_test.go @@ -151,6 +151,7 @@ func TestGroupUsageRollupTriggerSerializesInsertTransactionAcrossMidnight(t *tes insertTx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) defer func() { _ = insertTx.Rollback() }() + require.NoError(t, setGroupUsageRollupTriggerTimeZone(ctx, insertTx, "Asia/Shanghai")) var insertBackendPID int require.NoError(t, insertTx.QueryRowContext(ctx, "SELECT pg_backend_pid()").Scan(&insertBackendPID)) @@ -206,6 +207,7 @@ func TestGroupUsageRollupTriggerKeepsWatermarkForTodayInsert(t *testing.T) { tx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) defer func() { _ = tx.Rollback() }() + require.NoError(t, setGroupUsageRollupTriggerTimeZone(ctx, tx, "Asia/Shanghai")) _, err := tx.ExecContext(ctx, ` INSERT INTO groups (id) VALUES (10); INSERT INTO users (id) VALUES (1); @@ -476,6 +478,11 @@ func setGroupUsageRollupTriggerSearchPath(ctx context.Context, tx *sql.Tx, quote return err } +func setGroupUsageRollupTriggerTimeZone(ctx context.Context, tx *sql.Tx, name string) error { + _, err := tx.ExecContext(ctx, "SET LOCAL TIME ZONE "+pq.QuoteLiteral(name)) + return err +} + func waitForGroupUsageRollupStateLock( ctx context.Context, backendPID int, diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index 771cae09f4..0cd138cad5 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -133,7 +133,7 @@ func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Con if err != nil { return false, err } - defer upstream.Close() + defer func() { _ = upstream.Close() }() return s.ProxyGrokRealtimeConn(ctx, c, client, upstream) } diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index cca3b28ab3..7fed94eb13 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -1809,11 +1809,6 @@ func grokRequestedModelFromCtx(ctx context.Context) string { return strings.TrimSpace(model) } -func isGrokHeavyTransientModel(requestedModel string) bool { - model := strings.ToLower(strings.TrimSpace(xai.ResolveGrokTextResponsesModelID(requestedModel))) - return strings.Contains(model, "multi-agent") -} - func persistGrokTransientModelCooldown(account *Account, decision GrokUpstreamFailureDecision) bool { if account == nil { return false