diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index 07c536f32a0..e387a13e3a1 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -107,13 +107,95 @@ 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' }) + }) + ) + }) + + // Why: 'unavailable' would make applyStalePolicy discard the last good + // monthly snapshot; transient fallback failures must present like transient + // credits-view failures so stale data survives. + it('surfaces an error 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('error') + expect(result.error).toBe('Grok usage request failed (HTTP 500)') + }) + + it('surfaces an error when the monthly fallback request throws', async () => { + authState.file = freshAuthJson() + netFetchMock + .mockResolvedValueOnce(jsonResponse({ config: { isUnifiedBillingUser: true } })) + .mockRejectedValueOnce(new Error('network down')) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('error') + expect(result.error).toBe('network down') + }) + + 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..eaa140eb2be 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,61 @@ 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) : {} + } +} + +type GrokMonthlyFallbackOutcome = + | { kind: 'window'; window: RateLimitWindow | null } + | { kind: 'result'; result: ProviderRateLimits } + +// Why: request failures propagate as 'error' (thrown errors reach the caller's +// catch) so the stale policy keeps the last good monthly snapshot — the +// 'unavailable' status would discard it. Only a successful response without +// monthly fields means the account truly has no visible quota. +async function fetchMonthlyUsageFallback( + session: GrokAuthSession, + signal?: AbortSignal +): Promise { + const outcome = await fetchBillingData(BILLING_DEFAULT_URL, session, signal) + if (outcome.kind === 'result') { + return outcome + } + const config = outcome.data.config ?? outcome.data + return { kind: 'window', window: mapMonthlyUsage(config) } +} + // 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 +229,32 @@ 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 + } + 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 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 fallback = await fetchMonthlyUsageFallback(session, options.signal) + if (fallback.kind === 'result') { + return fallback.result } - if (!res.ok) { - return result('error', `Grok usage request failed (HTTP ${res.status})`) + if (fallback.window) { + return billingUsageResult({ monthly: fallback.window }, config, session) } - const data: unknown = await res.json() - return mapBillingResponse( - typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {}, - 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/renderer/src/components/settings/GrokAccountsSection.tsx b/src/renderer/src/components/settings/GrokAccountsSection.tsx index a5eb0ca8d99..474c4c1ee66 100644 --- a/src/renderer/src/components/settings/GrokAccountsSection.tsx +++ b/src/renderer/src/components/settings/GrokAccountsSection.tsx @@ -52,6 +52,10 @@ export function GrokAccountsSection(): React.JSX.Element { const signedIn = status?.signedIn === true const tokenFresh = status?.tokenFresh === true + // Why: unified-billing accounts have no weekly credits; surface their + // monthly included usage instead of hiding the usage row entirely. + const usageIsWeekly = Boolean(grokUsage?.weekly) + const usageWindow = grokUsage?.weekly ?? grokUsage?.monthly ?? null return (
@@ -148,32 +152,46 @@ export function GrokAccountsSection(): React.JSX.Element { - {grokUsage?.weekly ? ( + {usageWindow ? (
- {Math.round(grokUsage.weekly.usedPercent)}% + {Math.round(usageWindow.usedPercent)}% - {grokUsage.weekly.resetDescription ? ( + {usageWindow.resetDescription ? ( {translate( 'auto.components.settings.GrokAccountsSection.c6d1a8f4e2', 'Resets {{when}}', - { when: grokUsage.weekly.resetDescription } + { when: usageWindow.resetDescription } )} ) : null} - {grokUsage.usageMetadata?.authProvenance ? ( + {grokUsage?.usageMetadata?.authProvenance ? ( {grokUsage.usageMetadata.authProvenance} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index b3cc8a922c1..2c9a2fb979c 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1114,7 +1114,7 @@ function WindowLabel({ // the rest (Flash Lite, experimental) are secondary and would clutter the bar. const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) -function ProviderSegment({ +export function ProviderSegment({ p, compact, display @@ -1137,7 +1137,7 @@ function ProviderSegment({ } // Fetching with no prior data - if (p.status === 'fetching' && !p.session && !p.weekly && !p.fableWeekly) { + if (p.status === 'fetching' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) { return ( @@ -1156,7 +1156,7 @@ function ProviderSegment({ } // Error with no data - if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly) { + if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) { return ( @@ -1215,6 +1215,16 @@ function ProviderSegment({ window: p.fableWeekly, label: translate('auto.components.status.bar.StatusBar.a79c64f87e', 'Fable') } + : null, + // Why: monthly is chip-visible only when it's the sole window (Grok + // unified billing); providers with session/weekly data (OpenCode Go) + // keep monthly tooltip-only so the chip stays uncluttered. + p.monthly && !p.session && !p.weekly + ? { + key: 'monthly', + window: p.monthly, + label: formatWindowLabel(p.monthly.windowMinutes) + } : null ].filter((w): w is { key: string; window: RateLimitWindow; label: string } => w !== null) @@ -1783,7 +1793,7 @@ export function ProviderDetailsMenu({ {iconOnly ? ( {provider.provider === 'claude' diff --git a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx new file mode 100644 index 00000000000..866282b8680 --- /dev/null +++ b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx @@ -0,0 +1,86 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => null +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { usagePercentageDisplay: 'used' | 'remaining' }) => unknown) => + selector({ usagePercentageDisplay: 'used' }) +})) + +function windowOf(usedPercent: number, windowMinutes: number): RateLimitWindow { + return { usedPercent, windowMinutes, resetsAt: null, resetDescription: null } +} + +// Grok unified-billing accounts surface a monthly window and nothing else. +function grokMonthlyLimits(status: ProviderRateLimits['status']): ProviderRateLimits { + return { + provider: 'grok', + session: null, + weekly: null, + monthly: windowOf(25, 43200), + updatedAt: Date.now(), + error: null, + status + } +} + +describe('ProviderSegment monthly window', () => { + it('renders a monthly-only snapshot in the chip instead of a bare icon', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('25% used 30d') + }) + + it('shows monthly data while fetching instead of the loading placeholder', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('25% used 30d') + expect(markup).not.toContain('···') + }) + + // Why: providers with session/weekly windows (OpenCode Go) keep monthly + // tooltip-only so the chip stays uncluttered. + it('keeps monthly out of the chip when session and weekly windows exist', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const limits: ProviderRateLimits = { + provider: 'opencode-go', + session: windowOf(10, 300), + weekly: windowOf(20, 10080), + monthly: windowOf(30, 43200), + updatedAt: Date.now(), + error: null, + status: 'ok' + } + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('10% used 5h') + expect(markup).toContain('20% used wk') + expect(markup).not.toContain('30d') + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2e2ec2c6066..a74c874876d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9027,7 +9027,9 @@ "3325d996cb": "Refresh usage", "a8f3e2c1b4": "Weekly credits", "b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.", - "c6d1a8f4e2": "Resets {{when}}" + "c6d1a8f4e2": "Resets {{when}}", + "e6dadc1e2b": "Monthly usage", + "75e396bf42": "Included monthly usage for Grok unified-billing accounts." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Used", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4790364ca88..3b5f93cd3ea 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9027,7 +9027,9 @@ "3325d996cb": "Actualizar uso", "a8f3e2c1b4": "Créditos semanales", "b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.", - "c6d1a8f4e2": "Se restablece {{when}}" + "c6d1a8f4e2": "Se restablece {{when}}", + "e6dadc1e2b": "Monthly usage", + "75e396bf42": "Included monthly usage for Grok unified-billing accounts." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Usado", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c1845e376a7..48e6ce0e1e1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9027,7 +9027,9 @@ "3325d996cb": "使用状況を更新", "a8f3e2c1b4": "週次クレジット", "b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。", - "c6d1a8f4e2": "{{when}} にリセット" + "c6d1a8f4e2": "{{when}} にリセット", + "e6dadc1e2b": "Monthly usage", + "75e396bf42": "Included monthly usage for Grok unified-billing accounts." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "使用済み", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 48954ef5262..412ba6cba91 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9027,7 +9027,9 @@ "3325d996cb": "사용량 새로 고침", "a8f3e2c1b4": "주간 크레딧", "b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.", - "c6d1a8f4e2": "{{when}}에 재설정" + "c6d1a8f4e2": "{{when}}에 재설정", + "e6dadc1e2b": "Monthly usage", + "75e396bf42": "Included monthly usage for Grok unified-billing accounts." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "사용", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7344a7f7a5f..e83a0fefe3a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9027,7 +9027,9 @@ "3325d996cb": "刷新使用量", "a8f3e2c1b4": "每周额度", "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", - "c6d1a8f4e2": "{{when}} 重置" + "c6d1a8f4e2": "{{when}} 重置", + "e6dadc1e2b": "Monthly usage", + "75e396bf42": "Included monthly usage for Grok unified-billing accounts." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "已用", 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[]