fix(grok): serve current free quota policy

This commit is contained in:
superman2003 committed 2026-07-19 12:17:52 +08:00
1 parent c99e479bcc
commit d2753dc2e2
12 files changed
+65 -23

No files matched your search

+12
View File
@@ -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"`
+8
View File
@@ -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))
}
@@ -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"`
@@ -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"
@@ -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")
}
@@ -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
}
}
@@ -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
@@ -408,7 +408,7 @@
<UsageProgressBar
v-if="grokFreeTokenBar"
label="24h"
:title="t('admin.accounts.usageWindow.grokFreeQuota24hHint')"
:title="t('admin.accounts.usageWindow.grokFreeQuota24hHint', { limit: formatCompactNumber(grokFreeTokenBar.limit) })"
:utilization="grokFreeTokenBar.utilization"
:show-now-when-idle="true"
color="emerald"
@@ -631,8 +631,6 @@ import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
// Module-level cache shared across all AccountUsageCell instances
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
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
@@ -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: `<button class="probe" @click="$emit('probed', {
source: 'hybrid_probe',
billing: { period_type: 'weekly', usage_percent: null, plan: '' },
local_usage_24h: { requests: 12, tokens: 1500000, cost: 0, standard_cost: 0 },
local_usage_24h: { requests: 12, tokens: 750000, cost: 0, standard_cost: 0 },
headers_observed: false,
reset_supported: false,
fetched_at: 1
@@ -1199,7 +1204,7 @@ describe('AccountUsageCell', () => {
await wrapper.get('.probe').trigger('click')
expect(wrapper.text()).toContain('24h|75')
expect(wrapper.text()).toContain('1.5M')
expect(wrapper.text()).toContain('750.0K')
expect(wrapper.text()).not.toContain('7d|')
})
@@ -1276,7 +1276,7 @@ export default {
claude: 'Claude',
grokRequests: 'Req',
grokTokens: 'Tok',
grokFreeQuota24hHint: 'Estimated from local token usage over the rolling 24-hour window (2M limit)',
grokFreeQuota24hHint: 'Estimated from local token usage over the rolling 24-hour window ({limit} limit)',
grokWeeklyUsage: 'Weekly {percent}%',
grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.',
grokRetryAfter: 'Retry after {time}',
@@ -351,7 +351,7 @@ export default {
claude: 'Claude',
grokRequests: '请求',
grokTokens: 'Token',
grokFreeQuota24hHint: '按 sub2api 近 24 小时本地 Token 用量估算(上限 2M)',
grokFreeQuota24hHint: '按 sub2api 近 24 小时本地 Token 用量估算(上限 {limit})',
grokWeeklyUsage: '周额度已用 {percent}%',
grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。',
grokRetryAfter: '{time} 后重试',
+1
View File
@@ -1120,6 +1120,7 @@ export interface AccountUsageInfo {
grok_last_quota_probe_at?: string
grok_last_headers_seen_at?: string
grok_last_status_code?: number
grok_free_token_limit?: number
grok_local_usage?: WindowStats | null
grok_local_usage_24h?: WindowStats | null
grok_local_usage_7d?: WindowStats | null