修复 Grok 容量冷却与用量计费

This commit is contained in:
IanShaw
2026-08-20 04:26:02 -07:00
parent e85348be80
commit 0e05c61d36
7 changed files with 39 additions and 14 deletions
@@ -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),
}
}
@@ -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) {
@@ -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,
@@ -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)
@@ -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.
@@ -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)
}
}
@@ -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) {