From 4934920f06afe75ed481ea2ee1a7ac809161d6e4 Mon Sep 17 00:00:00 2001 From: TimothyVang <121889316+TimothyVang@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:25:57 -0500 Subject: [PATCH] fix(rate-limits): stop reporting Grok usage as 0% when the API omits the percent (#17936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mapWeeklyCredits treated an absent creditUsagePercent as a confirmed protobuf zero whenever the weekly period matched billing bounds, so unified-billing accounts whose credits view never reports the percent showed a confident 0% and short-circuited the monthly fallback (#15740). Those payloads emit onDemandUsed/prepaidBalance zeros, which disproves the "encoder drops zeros" premise. Resolution order is now: reported percent → monthly used/monthlyLimit pair as a monthly window → synthetic 0 only when the payload emits no usage scalars at all and the weekly period is confirmed → unavailable with an explicit reason the Accounts pane surfaces. Rebased onto current main from nwparker/grok-usage-percent-fallback (#15878). Fixes #15740 Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- src/main/rate-limits/grok-fetcher.test.ts | 135 ++++++++++++++++++ src/main/rate-limits/grok-fetcher.ts | 94 ++++++++++-- .../settings/GrokAccountsSection.test.tsx | 26 +++- .../settings/GrokAccountsSection.tsx | 17 +++ src/renderer/src/i18n/locales/en.json | 4 +- 5 files changed, 258 insertions(+), 18 deletions(-) diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index 63d7bc5cfcd..e5037f68dd4 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -100,6 +100,9 @@ describe('fetchGrokRateLimits', () => { ) }) + // Why: this payload emits NO usage scalars, so the omitted percent really is + // the dropped protobuf zero (#9214/#9219). #15740's payload does emit them — + // keep the two shapes apart. it('maps an omitted protobuf percentage as zero for a weekly credits period', async () => { authState.file = freshAuthJson() netFetchMock.mockResolvedValueOnce( @@ -125,6 +128,138 @@ describe('fetchGrokRateLimits', () => { expect(netFetchMock).toHaveBeenCalledTimes(1) }) + // Why: #15740 — an absent creditUsagePercent alongside explicitly-emitted zero + // credit fields means "not reported", never 0%. + it('reports usage as unavailable when the credits view omits the percent but emits explicit zero credit fields', async () => { + authState.file = freshAuthJson() + netFetchMock + .mockResolvedValueOnce( + jsonResponse({ + config: { + currentPeriod: { + type: 'USAGE_PERIOD_TYPE_WEEKLY', + start: '2026-08-16T12:54:39.515635+00:00', + end: '2026-08-23T12:54:39.515635+00:00' + }, + onDemandCap: { val: 100 }, + onDemandUsed: { val: 0 }, + isUnifiedBillingUser: true, + prepaidBalance: { val: 0 }, + topUpMethod: 'TOP_UP_METHOD_SAVED_PAYMENT_METHOD', + billingPeriodStart: '2026-08-16T12:54:39.515635+00:00', + billingPeriodEnd: '2026-08-23T12:54:39.515635+00:00' + } + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + config: { + monthlyLimit: { val: 0 }, + used: { val: 37.5 }, + billingPeriodStart: '2026-08-16T12:54:39.515635+00:00', + billingPeriodEnd: '2026-08-23T12:54:39.515635+00:00' + } + }) + ) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('unavailable') + expect(result.weekly).toBeNull() + expect(result.monthly).toBeUndefined() + expect(result.error).toMatch(/did not report a usage percentage/i) + expect(netFetchMock).toHaveBeenCalledTimes(2) + }) + + // Why: the monthly budget pair is a monthly window wherever it arrives — the + // credits view must not relabel it 'Weekly credits'. + it('publishes a credits-view monthly budget pair as a monthly window without a second request', async () => { + authState.file = freshAuthJson() + netFetchMock.mockResolvedValueOnce( + jsonResponse({ + config: { + currentPeriod: { + type: 'USAGE_PERIOD_TYPE_WEEKLY', + start: '2026-08-16T12:54:39.515635+00:00', + end: '2026-08-23T12:54:39.515635+00:00' + }, + billingPeriodStart: '2026-08-16T12:54:39.515635+00:00', + billingPeriodEnd: '2026-08-23T12:54:39.515635+00:00', + monthlyLimit: { val: 100 }, + used: { val: 25 } + } + }) + ) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('ok') + expect(result.weekly).toBeNull() + expect(result.monthly?.usedPercent).toBe(25) + expect(result.monthly?.windowMinutes).toBe(43_200) + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) + + // Why: #9214/#9219 — non-zero money fields never prove the encoder emits + // default zeros, so the omitted percent still reads as the dropped zero. + it('still reads an omitted percentage as zero when the payload carries only non-zero money fields', async () => { + authState.file = freshAuthJson() + netFetchMock.mockResolvedValueOnce( + jsonResponse({ + config: { + currentPeriod: { + type: 'USAGE_PERIOD_TYPE_WEEKLY', + start: '2026-07-17T19:38:56.948570+00:00', + end: '2026-07-24T19:38:56.948570+00:00' + }, + billingPeriodStart: '2026-07-17T19:38:56.948570+00:00', + billingPeriodEnd: '2026-07-24T19:38:56.948570+00:00', + onDemandCap: { val: 100 }, + prepaidBalance: { val: 25 }, + isUnifiedBillingUser: true + } + }) + ) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('ok') + expect(result.weekly?.usedPercent).toBe(0) + expect(result.weekly?.windowMinutes).toBe(10_080) + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) + + it.each([{ val: 0 }, { val: '0' }])( + 'does not divide by a zero monthly limit (%o)', + async (monthlyLimit) => { + authState.file = freshAuthJson() + netFetchMock + .mockResolvedValueOnce(jsonResponse({ config: { isUnifiedBillingUser: true } })) + .mockResolvedValueOnce(jsonResponse({ config: { monthlyLimit, used: { val: 12 } } })) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('unavailable') + expect(result.weekly).toBeNull() + expect(result.monthly).toBeUndefined() + expect(result.error).toMatch(/did not report a usage percentage/i) + } + ) + + it('reads a flat billing payload that carries usage fields but no percent', async () => { + authState.file = freshAuthJson() + netFetchMock.mockResolvedValueOnce( + jsonResponse({ + monthlyLimit: { val: 200 }, + used: { val: 50 }, + billingPeriodEnd: '2026-09-01T00:00:00+00:00' + }) + ) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('ok') + expect(result.weekly).toBeNull() + expect(result.monthly?.usedPercent).toBe(25) + expect(result.monthly?.windowMinutes).toBe(43_200) + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) + it('returns unavailable when not signed in even if a token-less auth file exists', async () => { authState.file = JSON.stringify({}) const result = await fetchGrokRateLimits() diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index 366c75c3835..33c4fac9aec 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -89,8 +89,9 @@ function timestampsMatch(left: string | undefined, right: string | undefined): b function hasConfirmedWeeklyPeriod(config: GrokBillingConfig): boolean { const period = config.currentPeriod - // Why: monthly unified-billing responses can also carry a weekly currentPeriod; - // matching billing bounds identify Grok's omitted protobuf zero unambiguously. + // Why: matching billing bounds only prove the current period IS the billing + // period; they say nothing about consumption (#15740), so resolveWeeklyPercent + // rules out the other consumption evidence before trusting this. return ( period?.type === 'USAGE_PERIOD_TYPE_WEEKLY' && timestampsMatch(period.start, config.billingPeriodStart) && @@ -98,12 +99,49 @@ function hasConfirmedWeeklyPeriod(config: GrokBillingConfig): boolean { ) } +function usageScalars(config: GrokBillingConfig): (GrokMoneyVal | undefined)[] { + return [ + config.onDemandCap, + config.onDemandUsed, + config.prepaidBalance, + config.monthlyLimit, + config.used + ] +} + +// Why: proto3 JSON drops default zeros, so an omitted percent can mean zero — +// but only an explicitly-emitted zero proves this encoder keeps them. #15740 +// ships `onDemandUsed: {val: 0}`, so there the omission means "not reported" +// and must never render as 0%. Non-zero money fields prove nothing either way, +// so #9214/#9219 accounts that carry only those keep their genuine 0%. +function emitsExplicitZeroScalar(config: GrokBillingConfig): boolean { + return usageScalars(config).some((value) => parseMoneyVal(value) === 0) +} + +function reportsAnyUsageScalar(config: GrokBillingConfig): boolean { + return usageScalars(config).some((value) => parseMoneyVal(value) !== null) +} + +function resolveWeeklyPercent(config: GrokBillingConfig): number | null { + const reported = config.creditUsagePercent + if (typeof reported === 'number' && Number.isFinite(reported)) { + return reported + } + if (reported !== undefined) { + return null + } + // Why: infer the dropped zero only when nothing else in the payload speaks + // for consumption — an explicit zero proves the encoder keeps defaults, and a + // computable budget pair is a real monthly number this must not shadow. + if (emitsExplicitZeroScalar(config) || mapMonthlyUsage(config) !== null) { + return null + } + return hasConfirmedWeeklyPeriod(config) ? 0 : null +} + function mapWeeklyCredits(config: GrokBillingConfig): RateLimitWindow | null { - const usedPercent = - config.creditUsagePercent === undefined && hasConfirmedWeeklyPeriod(config) - ? 0 - : config.creditUsagePercent - if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) { + const usedPercent = resolveWeeklyPercent(config) + if (usedPercent === null) { return null } const periodEnd = config.currentPeriod?.end ?? config.billingPeriodEnd @@ -125,13 +163,16 @@ function parseMoneyVal(value: GrokMoneyVal | undefined): number | null { function mapMonthlyUsage(config: GrokBillingConfig): RateLimitWindow | null { const limit = parseMoneyVal(config.monthlyLimit) const used = parseMoneyVal(config.used) + // Why: a zero, missing or unparseable denominator yields no window rather + // than NaN/Infinity or a fabricated 0%. if (limit === null || used === null || limit <= 0) { return null } + const usedPercent = Math.min(100, Math.max(0, (used / limit) * 100)) 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)), + usedPercent, windowMinutes: MONTHLY_WINDOW_MINUTES, resetsAt: resetsAt !== null && Number.isFinite(resetsAt) ? resetsAt : null, resetDescription: parseResetDescription(periodEnd) @@ -150,14 +191,26 @@ function grokRequestHeaders(session: GrokAuthSession): Record { return headers } +// Why: a flat response can carry monthly/on-demand fields and no percent at all +// (#15740); keying only on creditUsagePercent misreported those as "no config". +const FLAT_BILLING_FIELDS: readonly (keyof GrokBillingConfig)[] = [ + 'creditUsagePercent', + 'currentPeriod', + 'billingPeriodStart', + 'billingPeriodEnd', + 'subscriptionTier', + 'monthlyLimit', + 'used', + 'onDemandCap', + 'onDemandUsed', + 'prepaidBalance' +] + function resolveBillingConfig(data: GrokBillingResponse): GrokBillingConfig | null { if (data.config) { return data.config } - if (typeof data.creditUsagePercent === 'number') { - return data - } - return null + return FLAT_BILLING_FIELDS.some((field) => data[field] !== undefined) ? data : null } function billingUsageResult( @@ -219,7 +272,7 @@ async function fetchBillingData( } type GrokMonthlyFallbackOutcome = - | { kind: 'window'; window: RateLimitWindow | null } + | { kind: 'window'; window: RateLimitWindow | null; config: GrokBillingConfig } | { kind: 'result'; result: ProviderRateLimits } // Why: request failures propagate as 'error' (thrown errors reach the caller's @@ -235,7 +288,7 @@ async function fetchMonthlyUsageFallback( return outcome } const config = outcome.data.config ?? outcome.data - return { kind: 'window', window: mapMonthlyUsage(config) } + return { kind: 'window', window: mapMonthlyUsage(config), config } } // Why: Orca never runs grok login; it only reads the session file the CLI updates. @@ -277,6 +330,13 @@ export async function fetchGrokRateLimits( if (weekly) { return billingUsageResult({ weekly }, config, session) } + // Why: the credits view can already carry the monthly budget pair; that pair + // is a monthly window, so publish it as one rather than mislabelling it + // weekly — and skip the redundant second request. + const creditsMonthly = mapMonthlyUsage(config) + if (creditsMonthly) { + return billingUsageResult({ monthly: creditsMonthly }, config, session) + } // Why: some unified-billing accounts expose only a monthly included budget; // their credits view omits creditUsagePercent, so read the default view. const fallback = await fetchMonthlyUsageFallback(session, options.signal) @@ -286,7 +346,11 @@ export async function fetchGrokRateLimits( if (fallback.window) { return billingUsageResult({ monthly: fallback.window }, config, session) } - return result('unavailable', 'Grok billing response did not include credit usage') + // Why: an account that reports spend fields but no computable percentage is + // not a quota-less plan — say the usage is unknown instead of implying zero. + return reportsAnyUsageScalar(config) || reportsAnyUsageScalar(fallback.config) + ? result('unavailable', 'Grok did not report a usage percentage for this account') + : 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.test.tsx b/src/renderer/src/components/settings/GrokAccountsSection.test.tsx index cd418597719..09e9372283b 100644 --- a/src/renderer/src/components/settings/GrokAccountsSection.test.tsx +++ b/src/renderer/src/components/settings/GrokAccountsSection.test.tsx @@ -8,7 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ getStatus: vi.fn(), - refreshGrokRateLimits: vi.fn() + refreshGrokRateLimits: vi.fn(), + grokUsage: vi.fn<() => unknown>(() => null) })) vi.mock('@/lib/agent-catalog', () => ({ @@ -29,7 +30,8 @@ vi.mock('../../store', () => ({ useAppStore: (selector: (state: Record) => unknown) => selector({ refreshGrokRateLimits: mocks.refreshGrokRateLimits, - rateLimits: { grok: null } + settingsSearchQuery: '', + rateLimits: { grok: mocks.grokUsage() } }) })) @@ -45,6 +47,7 @@ describe('GrokAccountsSection', () => { error: null }) mocks.refreshGrokRateLimits.mockResolvedValue(undefined) + mocks.grokUsage.mockReturnValue(null) Object.defineProperty(window, 'api', { configurable: true, value: { grokAccounts: { getStatus: mocks.getStatus } } @@ -66,4 +69,23 @@ describe('GrokAccountsSection', () => { ).toBeInTheDocument() expect(screen.queryByText(/grok login/i)).not.toBeInTheDocument() }) + + // Why: #15740 — an unreported percentage must be stated, never shown as 0%. + it('shows why usage is unknown instead of hiding the row', async () => { + mocks.grokUsage.mockReturnValue({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Grok did not report a usage percentage for this account', + status: 'unavailable' + }) + + render() + + expect( + await screen.findByText('Grok did not report a usage percentage for this account') + ).toBeInTheDocument() + expect(screen.queryByText('0%')).not.toBeInTheDocument() + }) }) diff --git a/src/renderer/src/components/settings/GrokAccountsSection.tsx b/src/renderer/src/components/settings/GrokAccountsSection.tsx index 8df194dbd2d..b4f6a64157f 100644 --- a/src/renderer/src/components/settings/GrokAccountsSection.tsx +++ b/src/renderer/src/components/settings/GrokAccountsSection.tsx @@ -56,6 +56,12 @@ export function GrokAccountsSection(): React.JSX.Element { // monthly included usage instead of hiding the usage row entirely. const usageIsWeekly = Boolean(grokUsage?.weekly) const usageWindow = grokUsage?.weekly ?? grokUsage?.monthly ?? null + // Why: hiding the row entirely left signed-in users with no explanation when + // Grok reports no percentage — never let unknown usage read as healthy (#15740). + const unavailableReason = + signedIn && !usageWindow && grokUsage?.status === 'unavailable' + ? (grokUsage.error ?? null) + : null return (
@@ -198,6 +204,17 @@ export function GrokAccountsSection(): React.JSX.Element { ) : null} + ) : unavailableReason ? ( + +

{unavailableReason}

+
) : null}
) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0877f3eb585..1f65bd559a6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11051,7 +11051,9 @@ "e6dadc1e2b": "Monthly usage", "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", - "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.", + "0bb18642b7": "Usage", + "a8f4139350": "Grok reported no usage percentage for this account." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Used",