From d2753dc2e29037bf502396cf2272601c87aec209 Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Sun, 19 Jul 2026 12:17:52 +0800 Subject: [PATCH] fix(grok): serve current free quota policy --- backend/internal/pkg/xai/quota.go | 12 +++++++++ backend/internal/pkg/xai/quota_test.go | 8 ++++++ .../internal/service/account_usage_service.go | 1 + .../internal/service/grok_quota_fetcher.go | 5 ++-- .../service/grok_quota_fetcher_test.go | 1 + .../service/openai_gateway_grok_cache.go | 4 +-- .../service/openai_gateway_grok_cache_test.go | 17 ++++++++++-- .../components/account/AccountUsageCell.vue | 8 +++--- .../__tests__/AccountUsageCell.spec.ts | 27 +++++++++++-------- .../src/i18n/locales/en/admin/accounts.ts | 2 +- .../src/i18n/locales/zh/admin/accounts.ts | 2 +- frontend/src/types/index.ts | 1 + 12 files changed, 65 insertions(+), 23 deletions(-) diff --git a/backend/internal/pkg/xai/quota.go b/backend/internal/pkg/xai/quota.go index 1387c5bdad..fe269d1d84 100644 --- a/backend/internal/pkg/xai/quota.go +++ b/backend/internal/pkg/xai/quota.go @@ -7,6 +7,18 @@ import ( "time" ) +const GrokFreeRolling24hTokenLimit int64 = 1_000_000 + +var grokFreeRolling24hTokenLimits = map[int64]struct{}{ + GrokFreeRolling24hTokenLimit: {}, + 2_000_000: {}, // Legacy Free limit observed before July 2026. +} + +func IsGrokFreeRolling24hTokenLimit(limit int64) bool { + _, ok := grokFreeRolling24hTokenLimits[limit] + return ok +} + type QuotaWindow struct { Limit *int64 `json:"limit,omitempty"` Remaining *int64 `json:"remaining,omitempty"` diff --git a/backend/internal/pkg/xai/quota_test.go b/backend/internal/pkg/xai/quota_test.go index 593edc8eb9..983a87e84f 100644 --- a/backend/internal/pkg/xai/quota_test.go +++ b/backend/internal/pkg/xai/quota_test.go @@ -61,3 +61,11 @@ func TestObserveQuotaHeadersRecordsNoHeaderProbe(t *testing.T) { require.Nil(t, snapshot.Requests) require.Nil(t, snapshot.Tokens) } + +func TestIsGrokFreeRolling24hTokenLimit(t *testing.T) { + t.Parallel() + + require.True(t, IsGrokFreeRolling24hTokenLimit(GrokFreeRolling24hTokenLimit)) + require.True(t, IsGrokFreeRolling24hTokenLimit(2_000_000), "legacy snapshots remain classifiable") + require.False(t, IsGrokFreeRolling24hTokenLimit(3_000_000)) +} diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 12dc25a673..e9502ec4c6 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -207,6 +207,7 @@ type UsageInfo struct { GrokLastQuotaProbeAt string `json:"grok_last_quota_probe_at,omitempty"` GrokLastHeadersSeenAt string `json:"grok_last_headers_seen_at,omitempty"` GrokLastStatusCode int `json:"grok_last_status_code,omitempty"` + GrokFreeTokenLimit int64 `json:"grok_free_token_limit,omitempty"` GrokLocalUsage *WindowStats `json:"grok_local_usage,omitempty"` GrokLocalUsage24h *WindowStats `json:"grok_local_usage_24h,omitempty"` GrokLocalUsage7d *WindowStats `json:"grok_local_usage_7d,omitempty"` diff --git a/backend/internal/service/grok_quota_fetcher.go b/backend/internal/service/grok_quota_fetcher.go index 1f2d6374ad..545b1c9e97 100644 --- a/backend/internal/service/grok_quota_fetcher.go +++ b/backend/internal/service/grok_quota_fetcher.go @@ -21,8 +21,9 @@ func NewGrokQuotaFetcher() *GrokQuotaFetcher { func (f *GrokQuotaFetcher) BuildUsageInfo(account *Account) *UsageInfo { now := time.Now() usage := &UsageInfo{ - Source: "passive", - UpdatedAt: &now, + Source: "passive", + UpdatedAt: &now, + GrokFreeTokenLimit: xai.GrokFreeRolling24hTokenLimit, } if account == nil { usage.ErrorCode = "quota_unknown" diff --git a/backend/internal/service/grok_quota_fetcher_test.go b/backend/internal/service/grok_quota_fetcher_test.go index 6bea2814f2..a54ecc6c81 100644 --- a/backend/internal/service/grok_quota_fetcher_test.go +++ b/backend/internal/service/grok_quota_fetcher_test.go @@ -19,6 +19,7 @@ func TestGrokQuotaFetcherBuildUsageInfoUnknownUntilFirstSnapshot(t *testing.T) { usage := NewGrokQuotaFetcher().BuildUsageInfo(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth}) require.Equal(t, "passive", usage.Source) + require.Equal(t, xai.GrokFreeRolling24hTokenLimit, usage.GrokFreeTokenLimit) require.Equal(t, "quota_unknown", usage.ErrorCode) require.Contains(t, usage.Error, "unknown until billing is probed") } diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go index d9e34cbb58..6705764211 100644 --- a/backend/internal/service/openai_gateway_grok_cache.go +++ b/backend/internal/service/openai_gateway_grok_cache.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -15,7 +16,6 @@ const ( grokConversationIDHeader = "X-Grok-Conv-Id" grokFreeCacheNativeToolsJSON = `[{"type":"web_search"},{"type":"x_search"}]` grokFreeCacheDisabledToolChoice = "none" - grokFreeRolling24hTokenLimit = int64(2_000_000) ) // resolveGrokCacheIdentity derives one stable, tenant-isolated routing identity @@ -187,7 +187,7 @@ func isKnownGrokFreeAccount(account *Account) bool { } } if snapshot.Tokens != nil && snapshot.Tokens.Limit != nil && - *snapshot.Tokens.Limit == grokFreeRolling24hTokenLimit { + xai.IsGrokFreeRolling24hTokenLimit(*snapshot.Tokens.Limit) { inferredFreeSignal = true } } diff --git a/backend/internal/service/openai_gateway_grok_cache_test.go b/backend/internal/service/openai_gateway_grok_cache_test.go index 73b1ea5ec1..a90ffcdbed 100644 --- a/backend/internal/service/openai_gateway_grok_cache_test.go +++ b/backend/internal/service/openai_gateway_grok_cache_test.go @@ -8,6 +8,7 @@ import ( "sync" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -325,7 +326,19 @@ func TestGrokFreeMessagesFunctionToolCacheRouteRequiresKnownFreeTier(t *testing. a := healthyGrokOAuthGatewayTestAccount(9112, "access-token") a.Extra = map[string]any{grokQuotaSnapshotExtraKey: map[string]any{ "headers_observed": true, - "tokens": map[string]any{"limit": grokFreeRolling24hTokenLimit}, + "tokens": map[string]any{"limit": xai.GrokFreeRolling24hTokenLimit}, + }} + return a + }(), + wantMix: true, + }, + { + name: "legacy free rolling token quota", + account: func() *Account { + a := healthyGrokOAuthGatewayTestAccount(9113, "access-token") + a.Extra = map[string]any{grokQuotaSnapshotExtraKey: map[string]any{ + "headers_observed": true, + "tokens": map[string]any{"limit": int64(2_000_000)}, }} return a }(), @@ -347,7 +360,7 @@ func TestGrokFreeMessagesFunctionToolCacheRouteRequiresKnownFreeTier(t *testing. grokBillingExtraKey: map[string]any{"plan": "SuperGrok", "status_code": http.StatusOK}, grokQuotaSnapshotExtraKey: map[string]any{ "headers_observed": true, - "tokens": map[string]any{"limit": grokFreeRolling24hTokenLimit}, + "tokens": map[string]any{"limit": int64(2_000_000)}, }, } return a diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 9a3d6a86e9..9a3ac13f02 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -408,7 +408,7 @@ () const USAGE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes -// xAI Free billing exposes a window without usage_percent, so estimate it from local tokens. -const GROK_FREE_TOKEN_LIMIT = 2_000_000 const props = withDefaults( defineProps<{ @@ -1112,8 +1110,10 @@ const grokLocalUsage = computed(() => { }) const grokFreeTokenBar = computed(() => { if (!grokIsFree.value || !grokFreeQuotaUsage.value) return null + const limit = usageInfo.value?.grok_free_token_limit + if (typeof limit !== 'number' || limit <= 0) return null const used = Math.max(0, grokFreeQuotaUsage.value.tokens || 0) - return { utilization: Math.min(100, (used / GROK_FREE_TOKEN_LIMIT) * 100) } + return { utilization: Math.min(100, (used / limit) * 100), limit } }) const grokQuotaUnknown = computed(() => { if (props.account.platform !== 'grok') return false diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index 5f61494c5f..203b150183 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -705,11 +705,12 @@ describe('AccountUsageCell', () => { it.each([ { tokens: 0, expected: 0, compact: '0' }, - { tokens: 1_000_000, expected: 50, compact: '1.0M' }, - { tokens: 2_000_000, expected: 100, compact: '2.0M' }, - { tokens: 2_200_000, expected: 100, compact: '2.2M' } - ])('Grok Free derives its 2M quota from local tokens: $tokens -> $expected%', async ({ tokens, expected, compact }) => { + { tokens: 500_000, expected: 50, compact: '500.0K' }, + { tokens: 1_000_000, expected: 100, compact: '1.0M' }, + { tokens: 1_100_000, expected: 100, compact: '1.1M' } + ])('Grok Free derives its 1M quota from local tokens: $tokens -> $expected%', async ({ tokens, expected, compact }) => { getUsage.mockResolvedValue({ + grok_free_token_limit: 1_000_000, grok_billing: { period_type: 'weekly', usage_percent: null, @@ -723,7 +724,7 @@ describe('AccountUsageCell', () => { user_cost: 0 }, grok_request_quota: { limit: 100, remaining: 100 }, - grok_token_quota: { limit: 2_000_000, remaining: 2_000_000 } + grok_token_quota: { limit: 1_000_000, remaining: 1_000_000 } }) const wrapper = mount(AccountUsageCell, { @@ -753,6 +754,7 @@ describe('AccountUsageCell', () => { it('Grok Free uses rolling 24h usage instead of today-only usage', async () => { getUsage.mockResolvedValue({ + grok_free_token_limit: 1_000_000, grok_billing: { period_type: 'weekly', usage_percent: null, plan: '' }, grok_local_usage: { requests: 2, @@ -762,7 +764,7 @@ describe('AccountUsageCell', () => { }, grok_local_usage_24h: { requests: 12, - tokens: 1_500_000, + tokens: 750_000, cost: 0, standard_cost: 0 } @@ -793,7 +795,7 @@ describe('AccountUsageCell', () => { await flushPromises() expect(wrapper.text()).toContain('24h|75|admin.accounts.usageWindow.grokFreeQuota24hHint') - expect(wrapper.text()).toContain('1.5M') + expect(wrapper.text()).toContain('750.0K') expect(wrapper.text()).not.toContain('7d|') expect(wrapper.text()).not.toContain('200.0K') expect(wrapper.text()).not.toContain('250.0K') @@ -801,6 +803,7 @@ describe('AccountUsageCell', () => { it('Grok Free does not substitute today stats when rolling 24h usage is unavailable', async () => { getUsage.mockResolvedValue({ + grok_free_token_limit: 1_000_000, grok_billing: { period_type: 'weekly', usage_percent: null, plan: '' }, grok_local_usage: { requests: 1, @@ -921,8 +924,9 @@ describe('AccountUsageCell', () => { expect(wrapper.text()).not.toContain('2M|') }) - it('Grok credential Free tier keeps the 2M fallback when billing is unavailable', async () => { + it('Grok credential Free tier keeps the 1M fallback when billing is unavailable', async () => { getUsage.mockResolvedValue({ + grok_free_token_limit: 1_000_000, subscription_tier: 'FREE', grok_local_usage_24h: { requests: 3, @@ -950,7 +954,7 @@ describe('AccountUsageCell', () => { await flushPromises() - expect(wrapper.text()).toContain('24h|50') + expect(wrapper.text()).toContain('24h|100') }) it('Grok paid manual probes keep the weekly/local summary when 24h usage is returned', async () => { @@ -1165,6 +1169,7 @@ describe('AccountUsageCell', () => { it('Grok Free manual probes merge rolling 24h usage', async () => { getUsage.mockResolvedValue({ + grok_free_token_limit: 1_000_000, subscription_tier: 'FREE', grok_quota_snapshot_state: 'no_headers' }) @@ -1185,7 +1190,7 @@ describe('AccountUsageCell', () => { template: `