diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index d49c8697b..adb5f8fbc 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -1306,6 +1306,9 @@ func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, acco stateCtx, cancel = openAIAccountStateContext(ctx) defer cancel() } + // Account pointers on the request path are per-request copies (Redis/DB decode), + // not a shared in-process cache. Mutating Extra here matches token refresh / + // rate-limit writers; do not reuse the same *Account across goroutines. if account.Extra == nil { account.Extra = map[string]any{} } diff --git a/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts b/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts index aef73b0fa..f9559697e 100644 --- a/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts +++ b/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildOpenAIUsageRefreshKey } from '../accountUsageRefresh' +import { buildGrokUsageRefreshKey, buildOpenAIUsageRefreshKey } from '../accountUsageRefresh' describe('buildOpenAIUsageRefreshKey', () => { it('会在 codex 快照变化时生成不同 key', () => { @@ -61,3 +61,101 @@ describe('buildOpenAIUsageRefreshKey', () => { } as any)).toBe('') }) }) + +describe('buildGrokUsageRefreshKey', () => { + it('changes when a canonical Grok billing or usage snapshot changes', () => { + const base = { + platform: 'grok', + extra: { + grok_billing_snapshot: { plan: 'Free', usage_percent: 0 }, + grok_usage_snapshot: { subscription_tier: 'Free', status_code: 200 } + } + } as any + + expect(buildGrokUsageRefreshKey(base)).not.toBe(buildGrokUsageRefreshKey({ + ...base, + extra: { + ...base.extra, + grok_billing_snapshot: { plan: 'SuperGrok', usage_percent: 0 } + } + })) + expect(buildGrokUsageRefreshKey(base)).not.toBe(buildGrokUsageRefreshKey({ + ...base, + extra: { + ...base.extra, + grok_usage_snapshot: { subscription_tier: 'SuperGrok', status_code: 200 } + } + })) + }) + + it('ignores object key order and a legacy alias shadowed by canonical usage', () => { + const first = { + platform: 'grok', + extra: { + grok_billing_snapshot: { + plan: 'SuperGrok', + limits: { monthly: 100, weekly: 25 } + }, + grok_usage_snapshot: { status_code: 200, subscription_tier: 'SuperGrok' }, + grok_quota_snapshot: { subscription_tier: 'Free' } + } + } as any + const reordered = { + platform: 'grok', + extra: { + grok_quota_snapshot: { subscription_tier: 'SuperGrok Heavy' }, + grok_usage_snapshot: { subscription_tier: 'SuperGrok', status_code: 200 }, + grok_billing_snapshot: { + limits: { weekly: 25, monthly: 100 }, + plan: 'SuperGrok' + } + } + } as any + + expect(buildGrokUsageRefreshKey(first)).toBe(buildGrokUsageRefreshKey(reordered)) + }) + + it('uses the legacy quota alias only when the canonical snapshot is absent', () => { + const base = { + platform: 'grok', + extra: { grok_quota_snapshot: { subscription_tier: 'Free' } } + } as any + const next = { + platform: 'grok', + extra: { grok_quota_snapshot: { subscription_tier: 'SuperGrok' } } + } as any + + expect(buildGrokUsageRefreshKey(base)).not.toBe(buildGrokUsageRefreshKey(next)) + }) + + it('tracks the legacy tier when the canonical snapshot has no usable tier', () => { + for (const canonicalSnapshot of [ + { status_code: 200 }, + { status_code: 200, subscription_tier: ' ' }, + ]) { + const base = { + platform: 'grok', + extra: { + grok_usage_snapshot: canonicalSnapshot, + grok_quota_snapshot: { subscription_tier: 'Free' }, + }, + } as any + const next = { + ...base, + extra: { + ...base.extra, + grok_quota_snapshot: { subscription_tier: 'SuperGrok' }, + }, + } + + expect(buildGrokUsageRefreshKey(base)).not.toBe(buildGrokUsageRefreshKey(next)) + } + }) + + it('returns an empty key for non-Grok accounts', () => { + expect(buildGrokUsageRefreshKey({ + platform: 'openai', + extra: { grok_usage_snapshot: { subscription_tier: 'SuperGrok' } } + } as any)).toBe('') + }) +}) diff --git a/frontend/src/utils/accountUsageRefresh.ts b/frontend/src/utils/accountUsageRefresh.ts index 3406c7a50..121dae253 100644 --- a/frontend/src/utils/accountUsageRefresh.ts +++ b/frontend/src/utils/accountUsageRefresh.ts @@ -5,6 +5,30 @@ const normalizeUsageRefreshValue = (value: unknown): string => { return String(value) } +const normalizeSnapshotRefreshValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(normalizeSnapshotRefreshValue) + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, normalizeSnapshotRefreshValue(entry)]) + ) + } + return value +} + +const serializeSnapshotRefreshValue = (value: unknown): string => { + if (value == null) return '' + return JSON.stringify(normalizeSnapshotRefreshValue(value)) ?? '' +} + +const isNonBlankString = (value: unknown): value is string => ( + typeof value === 'string' && value.trim().length > 0 +) + export const buildOpenAIUsageRefreshKey = (account: Pick): string => { if (account.platform !== 'openai' || account.type !== 'oauth') { return '' @@ -27,3 +51,21 @@ export const buildOpenAIUsageRefreshKey = (account: Pick): string => { + if (account.platform !== 'grok') { + return '' + } + + const extra = account.extra ?? {} + const usageSnapshot = extra.grok_usage_snapshot + const canonicalTier = (usageSnapshot as Record | null | undefined)?.subscription_tier + const legacyQuotaFallback = isNonBlankString(canonicalTier) + ? undefined + : extra.grok_quota_snapshot + return [ + serializeSnapshotRefreshValue(extra.grok_billing_snapshot), + serializeSnapshotRefreshValue(usageSnapshot), + serializeSnapshotRefreshValue(legacyQuotaFallback) + ].join('|') +} diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index c33dcad0d..3bfc9357e 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -525,7 +525,7 @@ import Icon from '@/components/icons/Icon.vue' import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue' import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfilesModal.vue' import { fetchAllAccountIds } from '@/utils/accountSelection' -import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' +import { buildGrokUsageRefreshKey, buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' import { formatDateTime, formatRelativeTime } from '@/utils/format' import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry' import { extractApiErrorMessage } from '@/utils/apiError' @@ -1300,7 +1300,8 @@ const shouldReplaceAutoRefreshRow = (current: Account, next: Account) => { current.rate_limit_reset_at !== next.rate_limit_reset_at || current.overload_until !== next.overload_until || current.temp_unschedulable_until !== next.temp_unschedulable_until || - buildOpenAIUsageRefreshKey(current) !== buildOpenAIUsageRefreshKey(next) + buildOpenAIUsageRefreshKey(current) !== buildOpenAIUsageRefreshKey(next) || + buildGrokUsageRefreshKey(current) !== buildGrokUsageRefreshKey(next) ) } @@ -1484,13 +1485,32 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn( const GROK_QUOTA_SIGNAL_MAX_AGE_MS = 24 * 60 * 60 * 1000 const GROK_QUOTA_SIGNAL_MAX_FUTURE_SKEW_MS = 5 * 60 * 1000 +function firstNonBlankString(...values: unknown[]): string | undefined { + return values.find((value): value is string => ( + typeof value === 'string' && value.trim().length > 0 + )) +} + function normalizeGrokPlanKey(value: unknown): string { - return String(value ?? '') + if (typeof value !== 'string') return '' + return value .trim() .toLowerCase() .replace(/[\s_-]+/g, '') } +function grokPersistedQuotaSnapshot(extra: Record): Record | undefined { + const usage = extra.grok_usage_snapshot + if (usage && typeof usage === 'object' && !Array.isArray(usage)) { + return usage as Record + } + const legacy = extra.grok_quota_snapshot + if (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) { + return legacy as Record + } + return undefined +} + function isGrokQuotaTimestampFresh(raw: unknown): boolean { const value = String(raw || '').trim() if (!value) return false @@ -1536,8 +1556,10 @@ function getAccountPlanType(row: any): string | undefined { if (row.platform === 'grok') { const extra = (row.extra || {}) as Record const billing = extra.grok_billing_snapshot as Record | undefined - const quota = (extra.grok_quota_snapshot || extra.grok_usage_snapshot) as Record | undefined - const cred = row.credentials?.subscription_tier + const usage = extra.grok_usage_snapshot as Record | undefined + const legacyQuota = extra.grok_quota_snapshot as Record | undefined + const quota = grokPersistedQuotaSnapshot(extra) + const cred = firstNonBlankString(row.credentials?.subscription_tier) const credKey = normalizeGrokPlanKey(cred) if (credKey && credKey !== 'supergrokpro') { return cred @@ -1551,18 +1573,18 @@ function getAccountPlanType(row: any): string | undefined { return 'SuperGrok Heavy' } if (credKey === 'supergrokpro') { - return billing?.plan || 'SuperGrok' + return firstNonBlankString(billing?.plan) || 'SuperGrok' } - return ( - billing?.plan || - quota?.subscription_tier || - extra.subscription_tier || - row.credentials?.plan_type || - row.parent_plan_type || - undefined + return firstNonBlankString( + billing?.plan, + usage?.subscription_tier, + legacyQuota?.subscription_tier, + extra.subscription_tier, + row.credentials?.plan_type, + row.parent_plan_type ) } - return row.credentials?.plan_type || row.parent_plan_type || undefined + return firstNonBlankString(row.credentials?.plan_type, row.parent_plan_type) } function getOpenAIAuthMode(row: any): string | undefined { @@ -2418,6 +2440,7 @@ const handleClickOutside = (event: MouseEvent) => { onMounted(async () => { if (typeof window !== 'undefined') { + loadSavedAutoRefresh() desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery) isDesktopViewport.value = desktopViewportMediaQuery.matches desktopViewportListener = (event: MediaQueryListEvent) => { diff --git a/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts index 54a37ed84..08191b511 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts @@ -270,6 +270,8 @@ describe('admin AccountsView — 账号行展示', () => { }) afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -404,12 +406,18 @@ describe('admin AccountsView — 账号行展示', () => { credentials: { subscription_tier: 'SuperGrokPro' }, extra: { grok_billing_snapshot: { plan: 'SuperGrok' }, - grok_quota_snapshot: { + grok_usage_snapshot: { model: 'grok-4.5', last_headers_seen_at: new Date().toISOString(), requests: { limit: 8300 }, tokens: { limit: 53_000_000 }, }, + grok_quota_snapshot: { + model: 'grok-4.6', + last_headers_seen_at: new Date().toISOString(), + requests: { limit: 8300 }, + tokens: { limit: 53_000_000 }, + }, }, }, { @@ -420,7 +428,7 @@ describe('admin AccountsView — 账号行展示', () => { credentials: { subscription_tier: 'SuperGrokPro' }, extra: { grok_billing_snapshot: { plan: 'SuperGrok' }, - grok_quota_snapshot: { + grok_usage_snapshot: { model: 'grok-4.6', last_headers_seen_at: new Date().toISOString(), requests: { limit: 8300 }, @@ -428,6 +436,25 @@ describe('admin AccountsView — 账号行展示', () => { }, }, }, + { + id: 208, + name: 'usage-over-legacy-quota', + platform: 'grok', + type: 'oauth', + credentials: {}, + extra: { + grok_usage_snapshot: { subscription_tier: 'SuperGrok' }, + grok_quota_snapshot: { subscription_tier: 'Free' }, + }, + }, + { + id: 209, + name: 'legacy-quota-alias', + platform: 'grok', + type: 'oauth', + credentials: {}, + extra: { grok_quota_snapshot: { subscription_tier: 'SuperGrok' } }, + }, ] listAccounts.mockResolvedValue({ @@ -450,8 +477,106 @@ describe('admin AccountsView — 账号行展示', () => { 'SuperGrok', 'SuperGrok Heavy', 'SuperGrok', + 'SuperGrok', + 'SuperGrok', ]) wrapper.unmount() }) + + it('skips malformed Grok plan fields and safely uses the next valid fallback', async () => { + const grokAccounts = [ + { + id: 210, + name: 'legacy-fallback', + platform: 'grok', + type: 'oauth', + credentials: {}, + extra: { + grok_usage_snapshot: { subscription_tier: { name: 'SuperGrok Heavy' } }, + grok_quota_snapshot: { subscription_tier: 'SuperGrok' }, + }, + }, + { + id: 211, + name: 'credential-plan-fallback', + platform: 'grok', + type: 'oauth', + credentials: { subscription_tier: 0, plan_type: 'SuperGrok Heavy' }, + extra: { + grok_billing_snapshot: { plan: {} }, + grok_usage_snapshot: { subscription_tier: 1 }, + grok_quota_snapshot: { subscription_tier: [] }, + subscription_tier: ' ', + }, + }, + { + id: 212, + name: 'no-valid-plan', + platform: 'grok', + type: 'oauth', + credentials: { subscription_tier: {}, plan_type: 2 }, + parent_plan_type: [], + extra: { + grok_billing_snapshot: { plan: [] }, + grok_usage_snapshot: { subscription_tier: 1 }, + grok_quota_snapshot: { subscription_tier: {} }, + subscription_tier: null, + }, + }, + ] + + listAccounts.mockResolvedValue({ + items: grokAccounts, + total: grokAccounts.length, + page: 1, + page_size: 20, + pages: 1, + }) + + const wrapper = mountViewWithRow() + await flushPromises() + + expect(wrapper.findAllComponents(PlatformTypeBadge).map((badge) => badge.props('planType'))).toEqual([ + 'SuperGrok', + 'SuperGrok Heavy', + undefined, + ]) + wrapper.unmount() + }) + + it('replaces a Grok row when auto refresh returns a changed canonical usage snapshot', async () => { + vi.useFakeTimers() + vi.spyOn(document, 'hidden', 'get').mockReturnValue(false) + localStorage.setItem('account-auto-refresh', JSON.stringify({ enabled: true, interval_seconds: 5 })) + + const initialAccount = { + id: 213, + name: 'refresh-tier', + platform: 'grok', + type: 'oauth', + extra: { grok_usage_snapshot: { subscription_tier: 'Free', status_code: 200 } }, + } + const refreshedAccount = { + ...initialAccount, + extra: { grok_usage_snapshot: { subscription_tier: 'SuperGrok', status_code: 200 } }, + } + listAccounts.mockResolvedValue({ items: [initialAccount], total: 1, page: 1, page_size: 20, pages: 1 }) + listWithEtag.mockResolvedValueOnce({ + notModified: false, + etag: 'grok-snapshot-2', + data: { items: [refreshedAccount], total: 1, page: 1, page_size: 20, pages: 1 }, + }) + + const wrapper = mountViewWithRow() + await flushPromises() + expect(wrapper.findComponent(PlatformTypeBadge).props('planType')).toBe('Free') + + await vi.advanceTimersByTimeAsync(6000) + await flushPromises() + + expect(listWithEtag).toHaveBeenCalledTimes(1) + expect(wrapper.findComponent(PlatformTypeBadge).props('planType')).toBe('SuperGrok') + wrapper.unmount() + }) })