diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index 07c536f32a0..9eba9644e01 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -107,13 +107,81 @@ describe('fetchGrokRateLimits', () => { expect(netFetchMock).not.toHaveBeenCalled() }) - it('returns unavailable when billing has no credit usage', async () => { + it('returns unavailable when neither billing view has usage', async () => { authState.file = freshAuthJson() - netFetchMock.mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } })) + netFetchMock + .mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } })) + .mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } })) const result = await fetchGrokRateLimits() expect(result.status).toBe('unavailable') expect(result.weekly).toBeNull() + expect(result.monthly).toBeUndefined() + }) + + it('maps monthly included usage for unified-billing accounts without weekly credits', async () => { + authState.file = freshAuthJson() + netFetchMock + .mockResolvedValueOnce( + jsonResponse({ + config: { + currentPeriod: { + type: 'USAGE_PERIOD_TYPE_WEEKLY', + start: '2026-07-10T19:38:56.948570+00:00', + end: '2026-07-17T19:38:56.948570+00:00' + }, + isUnifiedBillingUser: true, + subscriptionTier: 'SuperGrok' + } + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + config: { + monthlyLimit: { val: 150000 }, + used: { val: 837 }, + billingPeriodStart: '2026-07-01T00:00:00+00:00', + billingPeriodEnd: '2026-08-01T00:00:00+00:00' + } + }) + ) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('ok') + expect(result.error).toBeNull() + expect(result.weekly).toBeNull() + expect(result.monthly?.usedPercent).toBeCloseTo((837 / 150000) * 100, 5) + expect(result.monthly?.windowMinutes).toBe(43_200) + expect(result.monthly?.resetsAt).toBe(Date.parse('2026-08-01T00:00:00+00:00')) + expect(result.usageMetadata?.authProvenance).toContain('SuperGrok') + + expect(netFetchMock).toHaveBeenNthCalledWith( + 2, + 'https://cli-chat-proxy.grok.com/v1/billing', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer access-token' }) + }) + ) + }) + + it('keeps the unavailable credits result when the monthly fallback request fails', async () => { + authState.file = freshAuthJson() + netFetchMock + .mockResolvedValueOnce(jsonResponse({ config: { isUnifiedBillingUser: true } })) + .mockResolvedValueOnce(jsonResponse({}, 500)) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('unavailable') + expect(result.error).toBe('Grok billing response did not include credit usage') + }) + + it('does not request the default billing view when weekly credits are present', async () => { + authState.file = freshAuthJson() + netFetchMock.mockResolvedValueOnce(jsonResponse(BILLING_RESPONSE)) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('ok') + expect(netFetchMock).toHaveBeenCalledTimes(1) }) it('returns unavailable when billing response has no config', async () => { diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index 099298a56c4..d5c4c60a523 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -12,8 +12,12 @@ const GROK_CLI_PROXY_BASE = process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim().replace(/\/$/, '') || 'https://cli-chat-proxy.grok.com/v1' const BILLING_CREDITS_URL = `${GROK_CLI_PROXY_BASE}/billing?format=credits` +// Why: unified-billing accounts have no weekly credits; their included monthly +// budget is only present in the default (format-less) billing view. +const BILLING_DEFAULT_URL = `${GROK_CLI_PROXY_BASE}/billing` const API_TIMEOUT_MS = 10_000 const WEEKLY_WINDOW_MINUTES = 10_080 +const MONTHLY_WINDOW_MINUTES = 43_200 const GROK_CLI_AUTH_HEADER = 'xai-grok-cli' @@ -31,6 +35,8 @@ type GrokBillingConfig = { billingPeriodStart?: string billingPeriodEnd?: string subscriptionTier?: string + monthlyLimit?: GrokMoneyVal + used?: GrokMoneyVal onDemandCap?: GrokMoneyVal onDemandUsed?: GrokMoneyVal prepaidBalance?: GrokMoneyVal @@ -81,6 +87,28 @@ function mapWeeklyCredits(config: GrokBillingConfig): RateLimitWindow | null { } } +function parseMoneyVal(value: GrokMoneyVal | undefined): number | null { + const raw = value?.val + const num = typeof raw === 'string' ? Number.parseFloat(raw) : raw + return typeof num === 'number' && Number.isFinite(num) ? num : null +} + +function mapMonthlyUsage(config: GrokBillingConfig): RateLimitWindow | null { + const limit = parseMoneyVal(config.monthlyLimit) + const used = parseMoneyVal(config.used) + if (limit === null || used === null || limit <= 0) { + return null + } + const periodEnd = config.currentPeriod?.end ?? config.billingPeriodEnd + const resetsAt = periodEnd ? Date.parse(periodEnd) : null + return { + usedPercent: Math.min(100, Math.max(0, (used / limit) * 100)), + windowMinutes: MONTHLY_WINDOW_MINUTES, + resetsAt: resetsAt !== null && Number.isFinite(resetsAt) ? resetsAt : null, + resetDescription: parseResetDescription(periodEnd) + } +} + function grokRequestHeaders(session: GrokAuthSession): Record { const headers: Record = { Authorization: `Bearer ${session.accessToken}`, @@ -103,28 +131,22 @@ function resolveBillingConfig(data: GrokBillingResponse): GrokBillingConfig | nu return null } -function mapBillingResponse( - data: GrokBillingResponse, +function billingUsageResult( + windows: { weekly?: RateLimitWindow | null; monthly?: RateLimitWindow | null }, + config: GrokBillingConfig, session: GrokAuthSession ): ProviderRateLimits { - const config = resolveBillingConfig(data) - // Why: a 200 without credit usage means the plan has no weekly credits — - // 'unavailable' hides the bar (like Claude on API-key billing); 'error' - // would paint a permanent alert for a signed-in account that has no quota. - if (!config) { - return result('unavailable', 'Grok billing response did not include config') - } - const weekly = mapWeeklyCredits(config) const tier = config.subscriptionTier?.trim() const authLabel = session.email?.trim() || session.userId || 'Grok account' const provenance = tier ? `${authLabel} (${tier})` : authLabel return { provider: 'grok', session: null, - weekly, + weekly: windows.weekly ?? null, + ...(windows.monthly ? { monthly: windows.monthly } : {}), updatedAt: Date.now(), - error: weekly ? null : 'Grok billing response did not include credit usage', - status: weekly ? 'ok' : 'unavailable', + error: null, + status: 'ok', usageMetadata: { source: 'oauth', authProvenance: provenance @@ -132,6 +154,59 @@ function mapBillingResponse( } } +type GrokBillingFetchOutcome = + | { kind: 'data'; data: GrokBillingResponse } + | { kind: 'result'; result: ProviderRateLimits } + +async function fetchBillingData( + url: string, + session: GrokAuthSession, + signal?: AbortSignal +): Promise { + const requestSignal = signal + ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + : AbortSignal.timeout(API_TIMEOUT_MS) + const res = await net.fetch(url, { + headers: grokRequestHeaders(session), + signal: requestSignal + }) + if (res.status === 401 || res.status === 403) { + return { + kind: 'result', + result: result('error', `Grok usage request unauthorized (HTTP ${res.status})`) + } + } + if (!res.ok) { + return { + kind: 'result', + result: result('error', `Grok usage request failed (HTTP ${res.status})`) + } + } + const data: unknown = await res.json() + return { + kind: 'data', + data: typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {} + } +} + +async function fetchMonthlyUsageFallback( + session: GrokAuthSession, + signal?: AbortSignal +): Promise { + try { + const outcome = await fetchBillingData(BILLING_DEFAULT_URL, session, signal) + if (outcome.kind === 'result') { + return null + } + const config = outcome.data.config ?? outcome.data + return mapMonthlyUsage(config) + } catch { + // Why: the fallback read must not turn a signed-in no-credits account into + // an error chip; the credits-view 'unavailable' presentation stands. + return null + } +} + // Why: Orca never runs grok login; it only reads the session file the CLI updates. export async function fetchGrokRateLimits( options: { signal?: AbortSignal; authReadResult?: GrokAuthReadResult } = {} @@ -152,24 +227,29 @@ export async function fetchGrokRateLimits( } try { - const signal = options.signal - ? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)]) - : AbortSignal.timeout(API_TIMEOUT_MS) - const res = await net.fetch(BILLING_CREDITS_URL, { - headers: grokRequestHeaders(session), - signal - }) - if (res.status === 401 || res.status === 403) { - return result('error', `Grok usage request unauthorized (HTTP ${res.status})`) + const outcome = await fetchBillingData(BILLING_CREDITS_URL, session, options.signal) + if (outcome.kind === 'result') { + return outcome.result } - if (!res.ok) { - return result('error', `Grok usage request failed (HTTP ${res.status})`) + const config = resolveBillingConfig(outcome.data) + // Why: a 200 without credit usage means the plan has no weekly credits — + // 'unavailable' hides the bar (like Claude on API-key billing); 'error' + // would paint a permanent alert for a signed-in account that has no quota. + if (!config) { + return result('unavailable', 'Grok billing response did not include config') } - const data: unknown = await res.json() - return mapBillingResponse( - typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {}, - session - ) + const weekly = mapWeeklyCredits(config) + if (weekly) { + return billingUsageResult({ weekly }, config, session) + } + // Why: unified-billing accounts report a monthly included-usage budget + // instead of weekly credits; the credits view omits creditUsagePercent + // for them, so read the default billing view before giving up. + const monthly = await fetchMonthlyUsageFallback(session, options.signal) + if (monthly) { + return billingUsageResult({ monthly }, config, session) + } + return result('unavailable', 'Grok billing response did not include credit usage') } catch (err) { return result('error', err instanceof Error ? err.message : 'Grok usage request failed') } diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 65c09aaffae..0e77decc76e 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -59,7 +59,7 @@ export type ProviderRateLimits = { weekly: RateLimitWindow | null /** Claude Fable 7-day weekly window, null if not available. */ fableWeekly?: RateLimitWindow | null - /** 30-day monthly window (OpenCode Go only), null if not available. */ + /** 30-day monthly window (OpenCode Go, Grok unified billing), null if not available. */ monthly?: RateLimitWindow | null /** Named per-model buckets (Gemini only). */ buckets?: RateLimitBucket[]