mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Surface Grok unified-billing monthly usage instead of a permanent warning
Unified-billing Grok accounts have no weekly credits: the /billing?format=credits view returns a config without creditUsagePercent, so the status bar was stuck on 'Grok billing response did not include credit usage' even though the account has a real quota. The default (format-less) /billing view reports it as an included monthly budget (monthlyLimit/used with the billing period). When the credits view has no weekly credit usage, read the default view and surface monthly usage as the provider's 30-day window (already supported by the tooltip and chip visibility for OpenCode Go). If the fallback read fails, the previous 'unavailable' presentation stands rather than escalating to an error chip.
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
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<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) : {}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMonthlyUsageFallback(
|
||||
session: GrokAuthSession,
|
||||
signal?: AbortSignal
|
||||
): Promise<RateLimitWindow | null> {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user