完善 Grok 限流冷却与用量兼容

This commit is contained in:
IanShaw
2026-08-20 02:32:43 -07:00
parent 39485f2e28
commit ad26172b83
15 changed files with 125 additions and 23 deletions
@@ -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
@@ -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
// ---------------------------------------------------------------------------
+4 -2
View File
@@ -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
}
@@ -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",
@@ -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",
+4 -2
View File
@@ -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
}
+5 -1
View File
@@ -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
}
+6 -2
View File
@@ -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,
}
}
@@ -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)
}
@@ -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
@@ -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"}}`)
@@ -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
@@ -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)
@@ -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`,
+3
View File
@@ -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.