mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(rate-limits): stop reporting Grok usage as 0% when the API omits the percent (#17936)
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>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<string, string> {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => 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(<GrokAccountsSection />)
|
||||
|
||||
expect(
|
||||
await screen.findByText('Grok did not report a usage percentage for this account')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('0%')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<section id="accounts-grok" className="space-y-4 scroll-mt-6">
|
||||
@@ -198,6 +204,17 @@ export function GrokAccountsSection(): React.JSX.Element {
|
||||
) : null}
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
) : unavailableReason ? (
|
||||
<SearchableSetting
|
||||
title={translate('auto.components.settings.GrokAccountsSection.0bb18642b7', 'Usage')}
|
||||
description={translate(
|
||||
'auto.components.settings.GrokAccountsSection.a8f4139350',
|
||||
'Grok reported no usage percentage for this account.'
|
||||
)}
|
||||
keywords={['grok', 'xai', 'usage', 'credits', 'oauth']}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{unavailableReason}</p>
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user