mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
修复 Grok 容量冷却与用量计费
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user