Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 84 additions & 2 deletions src/main/rate-limits/grok-fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
143 changes: 114 additions & 29 deletions src/main/rate-limits/grok-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -31,6 +35,8 @@ type GrokBillingConfig = {
billingPeriodStart?: string
billingPeriodEnd?: string
subscriptionTier?: string
monthlyLimit?: GrokMoneyVal
used?: GrokMoneyVal
onDemandCap?: GrokMoneyVal
onDemandUsed?: GrokMoneyVal
prepaidBalance?: GrokMoneyVal
Expand Down Expand Up @@ -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<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${session.accessToken}`,
Expand All @@ -103,35 +131,84 @@ 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
}
}
}

type GrokBillingFetchOutcome =
| { kind: 'data'; data: GrokBillingResponse }
| { kind: 'result'; result: ProviderRateLimits }

async function fetchBillingData(
url: string,
session: GrokAuthSession,
signal?: AbortSignal
): Promise<GrokBillingFetchOutcome> {
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<GrokMonthlyFallbackOutcome> {
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 } = {}
Expand All @@ -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')
}
Expand Down
44 changes: 31 additions & 13 deletions src/renderer/src/components/settings/GrokAccountsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section id="accounts-grok" className="space-y-4 scroll-mt-6">
Expand Down Expand Up @@ -148,32 +152,46 @@ export function GrokAccountsSection(): React.JSX.Element {
</Button>
</div>

{grokUsage?.weekly ? (
{usageWindow ? (
<SearchableSetting
title={translate(
'auto.components.settings.GrokAccountsSection.a8f3e2c1b4',
'Weekly credits'
)}
description={translate(
'auto.components.settings.GrokAccountsSection.b7e2d9f0a3',
'Same weekly credit % as the grok /usage screen in the terminal.'
)}
title={
usageIsWeekly
? translate(
'auto.components.settings.GrokAccountsSection.a8f3e2c1b4',
'Weekly credits'
)
: translate(
'auto.components.settings.GrokAccountsSection.e6dadc1e2b',
'Monthly usage'
)
}
description={
usageIsWeekly
? translate(
'auto.components.settings.GrokAccountsSection.b7e2d9f0a3',
'Same weekly credit % as the grok /usage screen in the terminal.'
)
: translate(
'auto.components.settings.GrokAccountsSection.75e396bf42',
'Included monthly usage for Grok unified-billing accounts.'
)
}
keywords={['grok', 'xai', 'usage', 'credits', 'oauth']}
>
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="tabular-nums">
{Math.round(grokUsage.weekly.usedPercent)}%
{Math.round(usageWindow.usedPercent)}%
</Badge>
{grokUsage.weekly.resetDescription ? (
{usageWindow.resetDescription ? (
<span className="text-muted-foreground">
{translate(
'auto.components.settings.GrokAccountsSection.c6d1a8f4e2',
'Resets {{when}}',
{ when: grokUsage.weekly.resetDescription }
{ when: usageWindow.resetDescription }
)}
</span>
) : null}
{grokUsage.usageMetadata?.authProvenance ? (
{grokUsage?.usageMetadata?.authProvenance ? (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<span className="truncate text-muted-foreground">
{grokUsage.usageMetadata.authProvenance}
</span>
Expand Down
Loading
Loading