fix(rate-limits): read real Antigravity quota from the agy CLI, not the Gemini mirror (#24073)

* fix(rate-limits): read real Antigravity quota from the agy CLI

Orca published a successful Gemini `retrieveUserQuota` read under the
antigravity provider id. That reported Gemini CLI per-model buckets on a
60-minute window, so Antigravity's real pools were never shown, the weekly
window was always null, and the segment depended on an installed
@google/gemini-cli for token refresh that an Antigravity user has no reason
to have.

Read the quota from `agy -p "/usage" --output-format json` instead, which is
the only caller that can authenticate it — agy keeps its credential in the
OS keyring and mints its own token against daily-cloudcode-pa.

Fixes #9122
Fixes #22511

* test(rate-limits): stub the Antigravity CLI fetch in every service suite

Without the stub, each RateLimitService suite spawned the developer's real
`agy` and resolved a login shell, which turned service-window-activation
from 214 ms into 14 s and broke its fake-timer fetch counts.

* fix(rate-limits): never pass --disable-slash-commands to the agy quota read

The flag stops agy expanding `/usage` as a command, so the text goes to the
model as an ordinary prompt: the call starts a conversation, spends quota, and
on an account near its limit answers RESOURCE_EXHAUSTED (429) instead of a
reading. Adds an opt-in real-CLI suite that catches exactly this.

* fix(rate-limits): stop polling agy once it answers /usage as a prompt

In print mode an unrecognised slash command is not an error — agy sends the
text to the model. On a build that does not know `/usage`, polling would start
a conversation and spend the user's quota every cycle while Orca reported no
quota. The envelope distinguishes the two: a command reply has an empty
conversation_id and num_turns 0.

A successful parse is checked first, so a real reading can never trip the latch.
This commit is contained in:
Neil
2026-09-30 19:36:54 -07:00
committed by GitHub
parent 7c119465b0
commit a76a0bcddc
21 changed files with 1166 additions and 146 deletions
@@ -0,0 +1,59 @@
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
/**
* The quota read is a slash command run in print mode.
*
* Why print mode and not an HTTP call: agy keeps its Google credential in the OS keyring and mints
* its own access token against `daily-cloudcode-pa`, so nothing outside agy can authenticate the
* quota endpoint. Verified on agy 1.2.11: the call spends no quota and starts no conversation
* (`num_turns: 0`, every token counter 0, empty `conversation_id`).
*
* `/usage` over `/quota`: both resolve to the same `usage` command, and `/usage` is the spelling agy
* lists in its own help.
*/
export const ANTIGRAVITY_USAGE_ARGS: readonly string[] = [
'-p',
'/usage',
'--output-format',
'json',
// Why bound it inside agy too: the process timeout below kills a hung child, but agy's own
// deadline lets it exit cleanly and print a diagnostic instead of dying mid-write.
'--print-timeout',
'20s'
// Do NOT add --disable-slash-commands here. It stops agy expanding `/usage` as a command, so the
// text is sent to the model as an ordinary prompt: the call then starts a conversation, spends
// quota, and on an account near its limit returns RESOURCE_EXHAUSTED (429) instead of a reading.
// Verified against agy 1.2.11 — the flag turned a free metadata read into a billed model turn.
]
/**
* How long the child may run before Orca kills it.
*
* Observed cost on a warm macOS install is 2.1–2.6 s (three consecutive runs), which is the CLI
* starting its language server and refreshing the quota. The ceiling is generous because a cold
* start also pays a binary self-check, and the fetch runs on its own promise so a slow read delays
* nothing else in the cycle.
*/
export const ANTIGRAVITY_USAGE_TIMEOUT_MS = 30_000
/** Cap on captured output; the envelope is a single JSON line well under a kilobyte. */
export const ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES = 512 * 1024
/** The command name Orca already uses to detect Antigravity, so both agree on the binary. */
export function antigravityCommandName(): string {
return TUI_AGENT_CONFIG.antigravity.detectCmd
}
/**
* Why the args are never appended to a configured launch command: a user's Antigravity launch
* command may carry its own flags, a wrapper script, or a shell pipeline, and appending `-p /usage`
* to that either runs the wrong program or feeds the slash command to the wrong argv slot. The quota
* read resolves the plain executable itself instead.
*/
export function isPlainAntigravityExecutable(command: string): boolean {
const trimmed = command.trim()
if (trimmed.length === 0) {
return false
}
return !/[\s"'|&;<>$`()]/.test(trimmed)
}
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchAntigravityRateLimits,
resetAntigravityUsageSupportForTests
} from './antigravity-usage-fetcher'
import { ANTIGRAVITY_USAGE_ARGS } from './antigravity-usage-command'
import type { ProcessResult } from '../../shared/child-process/process-spec'
const USAGE_ENVELOPE = JSON.stringify({
conversation_id: '',
status: 'SUCCESS',
command: {
name: 'usage',
data: {
description: 'Within each group, models share a weekly limit.',
groups: [
{
name: 'Gemini Models',
buckets: [
{
id: 'gemini-weekly',
name: 'Weekly Limit Remaining',
window: 'weekly',
remaining_fraction: 0.4,
reset_time: '2026-10-07T08:08:35Z'
}
]
}
]
}
}
})
function processResult(overrides: Partial<ProcessResult> = {}): ProcessResult {
return { code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...overrides }
}
function harness(
options: {
result?: ProcessResult
runCommand?: ReturnType<typeof vi.fn>
program?: string | null
env?: NodeJS.ProcessEnv
} = {}
) {
const runCommand =
options.runCommand ?? vi.fn().mockResolvedValue(options.result ?? processResult())
// Why the `in` check and not `??`: an explicit `program: null` is the absent-CLI case.
const resolveCommand = vi
.fn()
.mockResolvedValue('program' in options ? options.program : '/Users/x/.local/bin/agy')
const resolveEnvironment = vi
.fn()
.mockResolvedValue(options.env ?? { PATH: '/Users/x/.local/bin:/usr/bin' })
return {
runCommand,
resolveCommand,
resolveEnvironment,
fetch: () =>
fetchAntigravityRateLimits({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock returns a ProcessResult, which is the whole contract runProcess exposes to this fetcher.
runCommand: runCommand as never,
resolveCommand,
resolveEnvironment,
platform: 'darwin',
now: () => 1_700_000_000_000
})
}
}
describe('fetchAntigravityRateLimits', () => {
beforeEach(() => {
resetAntigravityUsageSupportForTests()
})
it('publishes the CLI reading as Antigravity usage', async () => {
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
expect(result.status).toBe('ok')
expect(result.provider).toBe('antigravity')
expect(result.error).toBeNull()
expect(result.weekly).toMatchObject({ usedPercent: 60, windowMinutes: 10_080 })
expect(result.buckets).toEqual([
{
name: 'Gemini Models',
usedPercent: 60,
windowMinutes: 10_080,
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
resetDescription: null
}
])
expect(result.usageMetadata).toMatchObject({
source: 'cli',
credentialSource: 'antigravity-cli'
})
})
it('never publishes the agy bucket id to the renderer', async () => {
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
for (const bucket of result.buckets ?? []) {
expect(bucket).not.toHaveProperty('id')
}
})
it('runs the resolved absolute path with the quota arguments and the login-shell env', async () => {
const h = harness({ result: processResult({ stdout: USAGE_ENVELOPE }) })
await h.fetch()
expect(h.runCommand).toHaveBeenCalledTimes(1)
const spec = h.runCommand.mock.calls[0]![0]
expect(spec.program).toBe('/Users/x/.local/bin/agy')
expect(spec.args).toEqual(ANTIGRAVITY_USAGE_ARGS)
// Why the login-shell PATH: agy installs to ~/.local/bin, which Electron's inherited PATH omits.
expect(spec.env).toEqual({ PATH: '/Users/x/.local/bin:/usr/bin' })
expect(spec.timeoutMs).toBeGreaterThan(0)
expect(h.resolveCommand).toHaveBeenCalledWith('agy', {
platform: 'darwin',
env: { PATH: '/Users/x/.local/bin:/usr/bin' }
})
})
it('reports an absent CLI as unavailable and never spawns', async () => {
const h = harness({ program: null })
const result = await h.fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('cli-unavailable')
expect(result.error).toContain('was not found on this machine')
expect(h.runCommand).not.toHaveBeenCalled()
})
it('reports a signed-out account as unavailable, not as a failed refresh', async () => {
const result = await harness({
// agy exits 0 and prints this rather than an envelope.
result: processResult({ stderr: 'You are not logged into Antigravity.' })
}).fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
expect(result.error).toContain('Sign in with `agy`')
})
it('reports a timeout as its own failure kind', async () => {
const result = await harness({ result: processResult({ timedOut: true }) }).fetch()
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
expect(result.error).toContain('did not answer in time')
})
it('reports an unreadable payload as a parse failure carrying the exit code', async () => {
const result = await harness({
result: processResult({ code: 2, stdout: 'unknown command /usage' })
}).fetch()
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('parse')
expect(result.error).toContain('exit 2')
})
it('does not blame the exit code when agy exited cleanly with no payload', async () => {
const result = await harness({ result: processResult({ code: 0, stdout: '' }) }).fetch()
expect(result.status).toBe('error')
expect(result.error).not.toContain('exit')
})
it('reports a spawn failure instead of rejecting the cycle', async () => {
const runCommand = vi.fn().mockRejectedValue(new Error('EACCES'))
const result = await harness({ runCommand }).fetch()
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('cli-unavailable')
expect(result.error).toContain('EACCES')
})
it('never reports quota from a successful read as stale session data', async () => {
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
// Why: this tier meters no 5h pool. The Gemini mirror it replaces filled `session` from a
// 60-minute per-model window and left `weekly` null — exactly backwards (#22511).
expect(result.session).toBeNull()
expect(result.weekly).not.toBeNull()
})
})
/**
* Captured when agy treated `/usage` as a prompt instead of a command: a conversation was started,
* a turn was spent, and the account answered RESOURCE_EXHAUSTED. This is the exact shape the
* unsupported latch has to recognise.
*/
const MODEL_TURN_ENVELOPE = JSON.stringify({
conversation_id: '28a5ca91-301f-4050-8efc-9c82c4e64df3',
status: 'ERROR',
response: '',
error: 'Individual quota reached. Please upgrade your subscription to increase your limits.',
num_turns: 1
})
describe('agy versions that answer /usage as a prompt', () => {
beforeEach(() => {
resetAntigravityUsageSupportForTests()
})
it('reports the quota read as unavailable instead of as a parse failure', async () => {
const result = await harness({
result: processResult({ stdout: MODEL_TURN_ENVELOPE })
}).fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
expect(result.error).toContain('answers `/usage` as a prompt')
})
it('never spawns agy again once a turn was spent', async () => {
const h = harness({ result: processResult({ stdout: MODEL_TURN_ENVELOPE }) })
await h.fetch()
expect(h.runCommand).toHaveBeenCalledTimes(1)
// Why: the evidence costs a turn of the user's quota, so rediscovering it on a 15-minute
// cadence would keep paying for the same answer.
await h.fetch()
await h.fetch()
expect(h.runCommand).toHaveBeenCalledTimes(1)
})
it('does not latch when the usage payload parsed, whatever else the envelope says', async () => {
const h = harness({
result: processResult({ stdout: `${USAGE_ENVELOPE}\n${MODEL_TURN_ENVELOPE}` })
})
const first = await h.fetch()
const second = await h.fetch()
expect(first.status).toBe('ok')
expect(second.status).toBe('ok')
expect(h.runCommand).toHaveBeenCalledTimes(2)
})
it('does not latch on an empty or unparsable answer', async () => {
const h = harness({ result: processResult({ code: 2, stdout: 'unknown flag' }) })
const first = await h.fetch()
const second = await h.fetch()
// Why: a transient failure is not evidence that the command is unsupported.
expect(first.status).toBe('error')
expect(second.status).toBe('error')
expect(h.runCommand).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,191 @@
import { runProcess } from '../../shared/child-process/run-process'
import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver'
import { resolveLoginShellEnvironment } from '../startup/login-shell-environment'
import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared/rate-limit-types'
import {
ANTIGRAVITY_USAGE_ARGS,
ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES,
ANTIGRAVITY_USAGE_TIMEOUT_MS,
antigravityCommandName
} from './antigravity-usage-command'
import { parseAntigravityUsageStdout, stdoutShowsModelTurn } from './antigravity-usage-response'
/**
* Observed verbatim in agy's own log when the keyring holds no session. agy exits 0 and prints this
* instead of a usage envelope, so the text is the only thing that separates "signed out" from
* "answered nothing".
*/
const NOT_SIGNED_IN_MARKER = 'not logged into antigravity'
const UNSUPPORTED_USAGE_COMMAND_REASON =
'Antigravity usage is not available. This version of the Antigravity CLI answers `/usage` as a prompt instead of a command, so Orca stopped asking rather than spend quota on it. Update `agy` and restart Orca.'
/**
* Latched once agy answers the quota read with a model turn.
*
* Why latch instead of retrying: the evidence that this agy cannot answer `/usage` is the same
* event that spends a turn of the user's quota. Retrying on a cadence would keep paying for the
* same discovery, so the probe is abandoned for the rest of the process's life.
*/
let usageCommandUnsupported = false
/** Clears the unsupported latch. Tests only — a live process has no way back. */
export function resetAntigravityUsageSupportForTests(): void {
usageCommandUnsupported = false
}
export type AntigravityUsageDependencies = {
/** Injected so tests exercise the classification without spawning agy. */
runCommand?: typeof runProcess
resolveCommand?: typeof resolveCommandOnLocalPath
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
platform?: NodeJS.Platform
now?: () => number
}
export type FetchAntigravityRateLimitsOptions = AntigravityUsageDependencies & {
signal?: AbortSignal
}
function unavailable(
message: string,
failureKind: UsageRateLimitFailureKind,
now: number
): ProviderRateLimits {
return {
provider: 'antigravity',
session: null,
weekly: null,
updatedAt: now,
error: message,
// Why 'unavailable' and not 'error' for every failure: an absent CLI or a signed-out account is
// a state the user can act on, and the status bar renders it as guidance rather than as a
// refresh that keeps failing (#7809, #14227).
status: 'unavailable',
usageMetadata: { source: 'cli', attemptedSources: ['cli'], failureKind }
}
}
function failed(
message: string,
failureKind: UsageRateLimitFailureKind,
now: number
): ProviderRateLimits {
return {
provider: 'antigravity',
session: null,
weekly: null,
updatedAt: now,
error: message,
status: 'error',
usageMetadata: { source: 'cli', attemptedSources: ['cli'], failureKind }
}
}
/**
* Reads Antigravity quota from the Antigravity CLI itself.
*
* Why the CLI and not the Gemini mirror it replaces: Orca used to publish a *successful* Gemini
* `retrieveUserQuota` read under the Antigravity provider id. That reported Gemini CLI per-model
* buckets on a 60-minute window, so Antigravity's real pools ("Gemini Models" and "Claude and GPT
* models", each weekly) were never shown and the weekly limit was always null (#9122, #22511). It
* also made the segment depend on an installed `@google/gemini-cli` for token refresh, which an
* Antigravity user has no reason to have.
*
* This runs on whichever machine owns execution; the caller is responsible for not asking a local
* agy about a remote workspace's quota.
*/
export async function fetchAntigravityRateLimits(
options: FetchAntigravityRateLimitsOptions = {}
): Promise<ProviderRateLimits> {
const now = options.now ?? Date.now
if (usageCommandUnsupported) {
return unavailable(UNSUPPORTED_USAGE_COMMAND_REASON, 'usage-unavailable', now())
}
const run = options.runCommand ?? runProcess
const resolve = options.resolveCommand ?? resolveCommandOnLocalPath
const platform = options.platform ?? process.platform
const resolveEnvironment = options.resolveEnvironment ?? (() => resolveLoginShellEnvironment())
// Why the login shell's env: agy installs to ~/.local/bin, which is on the user's PATH but not on
// the PATH an Electron app inherits from the window server or a desktop launcher.
const env = await resolveEnvironment()
const command = antigravityCommandName()
const program = await resolve(command, { platform, env })
if (!program) {
return unavailable(
`Antigravity usage is not available. The Antigravity CLI (\`${command}\`) was not found on this machine.`,
'cli-unavailable',
now()
)
}
let result: Awaited<ReturnType<typeof runProcess>>
try {
result = await run({
program,
args: ANTIGRAVITY_USAGE_ARGS,
env,
timeoutMs: ANTIGRAVITY_USAGE_TIMEOUT_MS,
maxOutputBytes: ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES,
signal: options.signal
})
} catch (error) {
return failed(
`Antigravity usage is not available. The Antigravity CLI could not be started: ${error instanceof Error ? error.message : 'unknown error'}.`,
'cli-unavailable',
now()
)
}
if (result.timedOut) {
return failed(
'Antigravity usage is not available. The Antigravity CLI did not answer in time.',
'usage-unavailable',
now()
)
}
const output = `${result.stdout}\n${result.stderr}`
if (output.toLowerCase().includes(NOT_SIGNED_IN_MARKER)) {
return unavailable(
'Antigravity usage is not available. Sign in with `agy` to report this account’s quota.',
'missing-credentials',
now()
)
}
const reading = parseAntigravityUsageStdout(result.stdout)
// Why the successful read is checked first: a real reading can never be evidence of a prompt, so
// ordering it ahead of the turn check makes a false latch impossible.
if (!reading && stdoutShowsModelTurn(result.stdout)) {
usageCommandUnsupported = true
return unavailable(UNSUPPORTED_USAGE_COMMAND_REASON, 'usage-unavailable', now())
}
if (!reading) {
// Why a non-zero exit is reported only here: `runProcess` treats the exit code as data, and agy
// exits 0 for a signed-out read, so the code only adds detail once the payload is missing.
const exitDetail = result.code === 0 || result.code === null ? '' : ` (exit ${result.code})`
return failed(
`Antigravity usage is not available. The Antigravity CLI did not report a quota${exitDetail}.`,
'parse',
now()
)
}
return {
provider: 'antigravity',
session: reading.session,
weekly: reading.weekly,
buckets: reading.buckets.map(({ id: _id, ...bucket }) => bucket),
updatedAt: now(),
error: null,
status: 'ok',
usageMetadata: {
source: 'cli',
attemptedSources: ['cli'],
lastSuccessfulSource: 'cli',
credentialSource: 'antigravity-cli'
}
}
}
@@ -1,70 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ProviderRateLimits, ProviderRateLimitStatus } from '../../shared/rate-limit-types'
import { deriveAntigravityRateLimits } from './antigravity-usage-mirror'
function geminiSnapshot(
status: ProviderRateLimitStatus,
error: string | null,
usedPercent: number | null = null
): ProviderRateLimits {
return {
provider: 'gemini',
session:
usedPercent === null
? null
: { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null },
weekly: null,
updatedAt: 1_700_000_000_000,
error,
status
}
}
describe('deriveAntigravityRateLimits', () => {
it('mirrors a successful Gemini read as shared Code Assist quota', () => {
const antigravity = deriveAntigravityRateLimits(geminiSnapshot('ok', null, 42))
expect(antigravity.provider).toBe('antigravity')
expect(antigravity.status).toBe('ok')
expect(antigravity.session?.usedPercent).toBe(42)
expect(antigravity.error).toBeNull()
})
it('reports unavailable without quoting the Gemini failure', () => {
const antigravity = deriveAntigravityRateLimits(
geminiSnapshot('error', 'Gemini project ID not found')
)
expect(antigravity.provider).toBe('antigravity')
expect(antigravity.status).toBe('unavailable')
expect(antigravity.error).not.toContain('Gemini project ID not found')
expect(antigravity.error).toContain('Antigravity usage is not available')
expect(antigravity.session).toBeNull()
expect(antigravity.weekly).toBeNull()
})
it('does not blame a missing sign-in when the quota read itself failed', () => {
const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed'))
// Why: the reported symptom is a connected sign-in whose Code Assist read failed.
expect(antigravity.error).toContain('could not be read right now')
expect(antigravity.error).not.toContain('sign-in is connected')
})
it('keeps the Gemini timestamp so activation freshness checks are not forced to refetch', () => {
const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed'))
expect(antigravity.updatedAt).toBe(1_700_000_000_000)
})
it('points at the missing sign-in when the Gemini opt-in is off', () => {
const antigravity = deriveAntigravityRateLimits(
geminiSnapshot('unavailable', 'Gemini CLI OAuth is disabled in settings')
)
expect(antigravity.status).toBe('unavailable')
expect(antigravity.error).not.toContain('Gemini CLI OAuth is disabled in settings')
expect(antigravity.error).toContain('Antigravity usage is not available')
expect(antigravity.error).toContain('Gemini CLI sign-in is connected')
})
})
@@ -1,29 +0,0 @@
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
// Why: the Antigravity CLI keeps its token in the OS keyring, not in the files the Gemini
// fetcher reads, so Orca never actually queries Antigravity. Only a *successful* Gemini read
// describes shared Google Code Assist quota; republishing a Gemini failure under the
// Antigravity provider id surfaced "Refresh failed" for a request that was never attempted.
const ANTIGRAVITY_NO_SIGN_IN_REASON =
'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.'
// Why: a Gemini `error` means the sign-in exists and the quota read failed, so blaming a missing sign-in would misdirect the user.
const ANTIGRAVITY_QUOTA_UNREADABLE_REASON =
'Antigravity usage is not available. Orca reads it from the shared Google Code Assist quota, which could not be read right now.'
export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits {
if (gemini.status === 'ok') {
return { ...gemini, provider: 'antigravity' }
}
return {
provider: 'antigravity',
session: null,
weekly: null,
// Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle.
updatedAt: gemini.updatedAt,
error:
gemini.status === 'unavailable'
? ANTIGRAVITY_NO_SIGN_IN_REASON
: ANTIGRAVITY_QUOTA_UNREADABLE_REASON,
status: 'unavailable'
}
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher'
/**
* Runs the real Antigravity CLI against the developer's own signed-in account.
*
* Opt in with `ORCA_REAL_AGY_CLI_TEST=1`, the same shape as the real Claude CLI suite. It is off by
* default because it spawns `agy`, needs a live sign-in, and takes seconds — but it is the only
* check that catches agy changing the payload the parser is written against.
*
* `ORCA_REAL_AGY_CLI_TEST=1 pnpm test src/main/rate-limits/antigravity-usage-real-cli.test.ts`
*/
const enabled = process.env.ORCA_REAL_AGY_CLI_TEST === '1'
describe.skipIf(!enabled)('Antigravity usage against the real agy CLI', () => {
it('reports quota with at least one named pool', async () => {
const result = await fetchAntigravityRateLimits()
if (result.status !== 'ok') {
// A machine with no agy or no sign-in still proves the classification, not a crash.
expect(result.status).toBe('unavailable')
expect(result.error).toBeTruthy()
return
}
expect(result.provider).toBe('antigravity')
expect(result.error).toBeNull()
expect(result.buckets?.length).toBeGreaterThan(0)
expect(result.usageMetadata?.source).toBe('cli')
for (const bucket of result.buckets ?? []) {
expect(bucket.name.length).toBeGreaterThan(0)
expect(bucket.usedPercent).toBeGreaterThanOrEqual(0)
expect(bucket.usedPercent).toBeLessThanOrEqual(100)
}
// At least one window must be summarised, or the segment has nothing to draw.
expect(result.session ?? result.weekly).not.toBeNull()
}, 60_000)
})
@@ -0,0 +1,247 @@
import { describe, expect, it } from 'vitest'
import {
parseAntigravityUsageEnvelope,
parseAntigravityUsageStdout
} from './antigravity-usage-response'
/**
* Captured verbatim from `agy -p "/usage" --output-format json` on agy 1.2.11 (macOS arm64).
* A tier with no 5h bucket reports weekly alone, which is why `session` is null here.
*/
const REAL_AGY_1_2_11_STDOUT = `{"conversation_id":"","status":"SUCCESS","response":"Gemini Models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\nClaude and GPT models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\n","duration_seconds":0,"num_turns":0,"usage":{"input_tokens":0,"output_tokens":0,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":0},"command":{"name":"usage","data":{"description":"Within each group, models share a weekly limit.","groups":[{"name":"Gemini Models","description":"Models within this group: Gemini Flash, Gemini Pro","buckets":[{"id":"gemini-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]},{"name":"Claude and GPT models","description":"Models within this group: Claude Opus, Claude Sonnet, GPT-OSS","buckets":[{"id":"3p-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]}]}}}`
function envelope(groups: unknown, description = 'pool help'): unknown {
return {
status: 'SUCCESS',
command: { name: 'usage', data: { description, groups } }
}
}
describe('parseAntigravityUsageStdout', () => {
it('reads the real agy 1.2.11 payload as two weekly group pools', () => {
const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT)
expect(reading).not.toBeNull()
expect(reading?.buckets).toEqual([
{
id: 'gemini-weekly',
name: 'Gemini Models',
usedPercent: 0,
windowMinutes: 10_080,
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
resetDescription: null
},
{
id: '3p-weekly',
name: 'Claude and GPT models',
usedPercent: 0,
windowMinutes: 10_080,
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
resetDescription: null
}
])
})
it('reports the weekly window the Gemini mirror always left null', () => {
const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT)
expect(reading?.weekly).toEqual({
usedPercent: 0,
windowMinutes: 10_080,
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
resetDescription: null
})
// Why null: this tier meters no 5h pool, and inventing one would claim headroom agy never
// reported.
expect(reading?.session).toBeNull()
})
it('ignores log noise printed around the envelope', () => {
const reading = parseAntigravityUsageStdout(
`I0926 16:22:51.157090 quota_manager.go:36] doRefreshQuota\n${REAL_AGY_1_2_11_STDOUT}\nBye.`
)
expect(reading?.buckets).toHaveLength(2)
})
it('returns null for stdout with no envelope at all', () => {
expect(parseAntigravityUsageStdout('You are not logged into Antigravity.')).toBeNull()
expect(parseAntigravityUsageStdout('')).toBeNull()
expect(parseAntigravityUsageStdout('{ not json')).toBeNull()
})
})
describe('parseAntigravityUsageEnvelope', () => {
it('maps a 5h bucket onto the session window and weekly onto the weekly window', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [
{
id: 'gemini-5h',
name: '5h Limit Remaining',
window: '5h',
remaining_fraction: 0.25,
reset_time: '2026-09-30T12:00:00Z'
},
{
id: 'gemini-weekly',
name: 'Weekly Limit Remaining',
window: 'weekly',
remaining_fraction: 0.5,
reset_time: '2026-10-07T00:00:00Z'
}
]
}
])
)
expect(reading?.session).toMatchObject({ usedPercent: 75, windowMinutes: 300 })
expect(reading?.weekly).toMatchObject({ usedPercent: 50, windowMinutes: 10_080 })
})
it('names both windows of one group apart', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [
{ id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1 },
{ id: 'gemini-weekly', name: 'Weekly', window: 'weekly', remaining_fraction: 1 }
]
}
])
)
expect(reading?.buckets.map((bucket) => bucket.name)).toEqual([
'Gemini Models · 5h',
'Gemini Models · Weekly'
])
})
it('drops a disabled bucket instead of drawing it as unused', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [
// The #22511 account: the 5h pool is not metered and the weekly pool is exhausted.
{ id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1, disabled: true },
{
id: 'gemini-weekly',
name: 'Weekly',
window: 'weekly',
remaining_fraction: 0,
reset_time: '2026-10-01T00:00:00Z'
}
]
}
])
)
expect(reading?.buckets).toHaveLength(1)
expect(reading?.buckets[0]).toMatchObject({ id: 'gemini-weekly', usedPercent: 100 })
expect(reading?.session).toBeNull()
expect(reading?.weekly?.usedPercent).toBe(100)
// Why the single bucket keeps the bare group name: the disabled sibling is not a row.
expect(reading?.buckets[0]?.name).toBe('Gemini Models')
})
it('summarises each window by its most constrained group', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [{ id: 'gemini-weekly', window: 'weekly', remaining_fraction: 0.9 }]
},
{
name: 'Claude and GPT models',
buckets: [{ id: '3p-weekly', window: 'weekly', remaining_fraction: 0.1 }]
}
])
)
// Why the worst pool: the tier is out of Antigravity when either group is out.
expect(reading?.weekly?.usedPercent).toBe(90)
})
it('keeps an unrecognised window as a named bucket without claiming a duration', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [{ id: 'gemini-monthly', window: 'monthly', remaining_fraction: 0.4 }]
}
])
)
expect(reading?.buckets[0]).toMatchObject({ usedPercent: 60, windowMinutes: 0 })
expect(reading?.session).toBeNull()
expect(reading?.weekly).toBeNull()
})
it('carries agy’s own pool explanation through', () => {
const reading = parseAntigravityUsageEnvelope(
envelope(
[
{ name: 'Gemini Models', buckets: [{ id: 'g', window: 'weekly', remaining_fraction: 1 }] }
],
'Quota is consumed proportionally to the cost of the tokens.'
)
)
expect(reading?.description).toBe('Quota is consumed proportionally to the cost of the tokens.')
})
it('clamps a fraction outside 0..1', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'G',
buckets: [
{ id: 'a', window: 'weekly', remaining_fraction: 1.4 },
{ id: 'b', window: '5h', remaining_fraction: -0.2 }
]
}
])
)
expect(reading?.buckets.map((bucket) => bucket.usedPercent)).toEqual([0, 100])
})
it('reads a missing or unparsable reset time as unknown', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'G',
buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, reset_time: 'soon' }]
}
])
)
expect(reading?.buckets[0]?.resetsAt).toBeNull()
})
it.each([
['a non-SUCCESS status', { status: 'ERROR', command: { name: 'usage', data: { groups: [] } } }],
['another command’s payload', { status: 'SUCCESS', command: { name: 'models', data: {} } }],
['a missing command', { status: 'SUCCESS' }],
['no groups', envelope(undefined)],
['an empty group list', envelope([])],
['a group with no usable bucket', envelope([{ name: 'G', buckets: [{ id: 'a' }] }])],
[
'a group with every bucket disabled',
envelope([
{
name: 'G',
buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, disabled: true }]
}
])
],
['a non-object', 'nope'],
['null', null]
])('returns null for %s', (_label, value) => {
expect(parseAntigravityUsageEnvelope(value)).toBeNull()
})
})
@@ -0,0 +1,253 @@
import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types'
import { deriveMostConstrainedWindow } from './rate-limit-bucket-summary'
/**
* Parses what `agy -p "/usage" --output-format json` prints.
*
* The shape is agy's print-mode envelope with the slash command's own payload attached, verified
* against agy 1.2.11 on macOS:
*
* ```json
* { "status": "SUCCESS", "response": "Gemini Models\tWeekly Limit Remaining\t100%\t2026-10-07T08:08:35Z\n…",
* "command": { "name": "usage", "data": { "description": "…", "groups": [
* { "name": "Gemini Models", "description": "Models within this group: Gemini Flash, Gemini Pro",
* "buckets": [{ "id": "gemini-weekly", "name": "Weekly Limit Remaining", "window": "weekly",
* "remaining_fraction": 1, "reset_time": "2026-10-07T08:08:35Z" }] } ] } } }
* ```
*
* `command.data` is the contract, not the `response` text: the text is a lossy tab-joined rendering
* that rounds the fraction to a whole percent and drops both the bucket ids and `disabled`.
*/
/** 7 days. agy reports the window by name, so the minute count is Orca's mapping, not agy's. */
const WEEKLY_WINDOW_MINUTES = 10_080
/** 5 hours. Only some tiers expose a 5h bucket; a tier without one reports weekly alone. */
const SESSION_WINDOW_MINUTES = 300
export type AntigravityUsageBucket = RateLimitBucket & {
/** agy's stable bucket id (`gemini-weekly`, `gemini-5h`, `3p-weekly`, `3p-5h`). */
id: string
}
export type AntigravityUsageReading = {
session: RateLimitWindow | null
weekly: RateLimitWindow | null
buckets: AntigravityUsageBucket[]
/** agy's own explanation of how the pools work, shown as the segment's help text. */
description: string | null
}
type RawBucket = {
id?: unknown
name?: unknown
window?: unknown
remaining_fraction?: unknown
reset_time?: unknown
disabled?: unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
/**
* Maps agy's window name onto Orca's minute count.
*
* Why only these two: agy groups models into pools that share a limit, and a pool carries at most a
* rolling 5h bucket and a weekly bucket. An unrecognised name is reported as a named bucket with no
* window rather than being forced into one of the two, so a new agy window cannot silently be drawn
* as a weekly limit.
*/
function windowMinutesFor(window: string | null): number | null {
if (window === 'weekly') {
return WEEKLY_WINDOW_MINUTES
}
if (window === '5h') {
return SESSION_WINDOW_MINUTES
}
return null
}
function parseResetsAt(value: unknown): number | null {
const text = readString(value)
if (!text) {
return null
}
const parsed = new Date(text).getTime()
return Number.isFinite(parsed) ? parsed : null
}
/**
* Why a group name and not the bucket name: every bucket in the payload is called "Weekly Limit
* Remaining", so the bucket name alone renders two identical rows. The group is what distinguishes
* them ("Gemini Models" vs "Claude and GPT models"), and the agy label is only appended when one
* group reports more than one window.
*/
function formatBucketName(groupName: string, bucketName: string | null, siblings: number): string {
if (siblings <= 1 || !bucketName) {
return groupName
}
return `${groupName} · ${bucketName}`
}
function parseBucket(
raw: RawBucket,
groupName: string,
siblings: number
): AntigravityUsageBucket | null {
const id = readString(raw.id)
const fraction = raw.remaining_fraction
if (!id || typeof fraction !== 'number' || !Number.isFinite(fraction)) {
return null
}
// Why skip: a disabled bucket is one the tier does not meter at all. #22511 saw `gemini-5h`
// disabled while `gemini-weekly` was exhausted; drawing the disabled bucket as 0% used would
// report headroom the account does not have.
if (raw.disabled === true) {
return null
}
const usedPercent = Math.min(100, Math.max(0, Math.round((1 - fraction) * 100)))
return {
id,
name: formatBucketName(groupName, readString(raw.name), siblings),
usedPercent,
// Why 0 and not null: RateLimitWindow requires a number, and an unrecognised agy window still
// carries a real remaining fraction worth showing as a named bucket.
windowMinutes: windowMinutesFor(readString(raw.window)) ?? 0,
resetsAt: parseResetsAt(raw.reset_time),
resetDescription: null
}
}
function parseGroups(groups: unknown): AntigravityUsageBucket[] {
if (!Array.isArray(groups)) {
return []
}
const parsed: AntigravityUsageBucket[] = []
for (const group of groups) {
if (!isRecord(group)) {
continue
}
const groupName = readString(group.name)
const buckets = Array.isArray(group.buckets) ? group.buckets : []
if (!groupName) {
continue
}
const enabled = buckets.filter(
(bucket): bucket is RawBucket => isRecord(bucket) && bucket.disabled !== true
)
for (const bucket of enabled) {
const result = parseBucket(bucket, groupName, enabled.length)
if (result) {
parsed.push(result)
}
}
}
return parsed
}
/**
* Reads the usage payload out of an agy print-mode envelope.
*
* Returns null when the envelope is not a successful usage reply, which the caller reports as an
* unreadable quota rather than as an empty one — "no buckets" and "agy did not answer" are
* different states and only the first is safe to draw as 0% used.
*/
export function parseAntigravityUsageEnvelope(value: unknown): AntigravityUsageReading | null {
if (!isRecord(value)) {
return null
}
if (readString(value.status) !== 'SUCCESS') {
return null
}
const command = value.command
if (!isRecord(command)) {
return null
}
// Why check the command name: `/usage` and `/quota` are aliases that both answer as `usage`, so
// the name is what proves the payload is a quota reply and not some other command's data.
if (readString(command.name) !== 'usage') {
return null
}
const data = command.data
if (!isRecord(data)) {
return null
}
const buckets = parseGroups(data.groups)
if (buckets.length === 0) {
return null
}
// Why drop the id first: the summary is a RateLimitWindow, and the summariser only strips `name`,
// so an id left on the bucket would ride into the published window.
const windowsOf = (minutes: number): RateLimitBucket[] =>
buckets
.filter((bucket) => bucket.windowMinutes === minutes)
.map(({ id: _id, ...bucket }) => bucket)
return {
session: deriveMostConstrainedWindow(windowsOf(SESSION_WINDOW_MINUTES)),
weekly: deriveMostConstrainedWindow(windowsOf(WEEKLY_WINDOW_MINUTES)),
buckets,
description: readString(data.description)
}
}
/** Finds the usage envelope in agy's stdout, which may carry log noise around the JSON line. */
export function parseAntigravityUsageStdout(stdout: string): AntigravityUsageReading | null {
for (const line of stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed.startsWith('{')) {
continue
}
try {
const reading = parseAntigravityUsageEnvelope(JSON.parse(trimmed))
if (reading) {
return reading
}
} catch {
continue
}
}
return null
}
/**
* True when the envelope shows agy ran a model turn instead of answering a command.
*
* Why this matters: in print mode an *unrecognised* slash command is not an error — agy sends the
* text to the model as an ordinary prompt. On a build of agy that does not know `/usage`, polling
* would quietly start a conversation and spend the user's quota every cycle while Orca reported
* "did not report a quota". A real command reply carries an empty `conversation_id` and
* `num_turns: 0`; a prompt carries a conversation id and at least one turn.
*/
export function didRunModelTurn(value: unknown): boolean {
if (!isRecord(value)) {
return false
}
const turns = value.num_turns
if (typeof turns === 'number' && turns > 0) {
return true
}
return readString(value.conversation_id) !== null
}
/** Scans agy stdout for evidence that the quota read was answered by the model, not by a command. */
export function stdoutShowsModelTurn(stdout: string): boolean {
for (const line of stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed.startsWith('{')) {
continue
}
try {
if (didRunModelTurn(JSON.parse(trimmed))) {
return true
}
} catch {
continue
}
}
return false
}
@@ -1,4 +1,5 @@
import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types'
import { deriveMostConstrainedWindow } from './rate-limit-bucket-summary'
const MODEL_ID_TO_BUCKET_NAME: Record<string, string> = {
'gemini-3.1-pro': '3.1 Pro',
@@ -73,12 +74,5 @@ export function deduplicateBuckets(
}
export function deriveSessionSummary(buckets: RateLimitBucket[]): RateLimitWindow | null {
if (buckets.length === 0) {
return null
}
const mostConstrained = buckets.reduce((worst, bucket) => {
return bucket.usedPercent > worst.usedPercent ? bucket : worst
})
const { name: _name, ...window } = mostConstrained
return window
return deriveMostConstrainedWindow(buckets)
}
@@ -0,0 +1,19 @@
import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types'
/**
* Collapses named buckets into the one window a user is actually limited by.
*
* The most-consumed bucket is the binding constraint: a provider that reports one pool per model
* family runs out of the whole tier when any single pool does, so the summary has to follow the
* worst pool rather than an average.
*/
export function deriveMostConstrainedWindow(buckets: RateLimitBucket[]): RateLimitWindow | null {
if (buckets.length === 0) {
return null
}
const mostConstrained = buckets.reduce((worst, bucket) =>
bucket.usedPercent > worst.usedPercent ? bucket : worst
)
const { name: _name, ...window } = mostConstrained
return window
}
@@ -12,6 +12,7 @@ import { fetchCursorRateLimits } from './cursor-fetcher'
import { readCursorAuthSession } from './cursor-auth'
import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection'
import { fetchZcodeRateLimits } from './zcode-usage-fetcher'
import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher'
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
export type Deferred<T> = {
@@ -94,6 +95,9 @@ export function mockFreshBackgroundProviderFetches(): void {
vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok'))
vi.mocked(fetchCursorRateLimits).mockImplementation(async () => unavailableProvider('cursor'))
vi.mocked(fetchZcodeRateLimits).mockImplementation(async () => unavailableProvider('zcode'))
vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () =>
unavailableProvider('antigravity')
)
}
/** Shared `beforeEach` body: healthy stubs for every provider the service polls. */
@@ -113,6 +117,7 @@ export function resetRateLimitProviderMocks(): void {
})
vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor'))
vi.mocked(fetchZcodeRateLimits).mockResolvedValue(unavailableProvider('zcode'))
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity'))
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' })
vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' })
@@ -36,6 +36,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
fetchZcodeRateLimits: vi.fn()
}))
vi.mock('./antigravity-usage-fetcher', () => ({
fetchAntigravityRateLimits: vi.fn()
}))
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -3,6 +3,7 @@ import { RateLimitService } from './service'
import { fetchClaudeRateLimits } from './claude-fetcher'
import { fetchCodexRateLimits } from './codex-fetcher'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher'
import {
errorProvider,
okProvider,
@@ -41,6 +42,10 @@ vi.mock('./grok-fetcher', () => ({
vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() }))
vi.mock('./antigravity-usage-fetcher', () => ({
fetchAntigravityRateLimits: vi.fn()
}))
vi.mock('./cursor-fetcher', () => ({
fetchCursorRateLimits: vi.fn()
}))
@@ -62,27 +67,12 @@ describe('RateLimitService Antigravity usage', () => {
resetRateLimitProviderMocks()
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7))
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20))
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30))
})
it('does not republish a Gemini failure as an Antigravity refresh failure', async () => {
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(
errorProvider('gemini', 'Gemini project ID not found')
)
const service = new RateLimitService()
await service.refresh()
const state = service.getState()
expect(state.antigravity?.status).toBe('unavailable')
expect(state.antigravity?.error).not.toContain('Gemini project ID not found')
expect(state.antigravity?.session).toBeNull()
// Why: the real Gemini failure must still surface under its own provider.
expect(state.gemini?.status).toBe('error')
expect(state.gemini?.error).toBe('Gemini project ID not found')
})
it('keeps mirroring a successful Gemini read under the Antigravity provider', async () => {
it('publishes the Antigravity CLI reading, not the Gemini one', async () => {
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now()))
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30))
const service = new RateLimitService()
await service.refresh()
@@ -90,21 +80,56 @@ describe('RateLimitService Antigravity usage', () => {
const state = service.getState()
expect(state.antigravity?.status).toBe('ok')
expect(state.antigravity?.provider).toBe('antigravity')
expect(state.antigravity?.session?.usedPercent).toBe(42)
// Why both: the mirror made these two numbers the same value by construction.
expect(state.antigravity?.session?.usedPercent).toBe(30)
expect(state.gemini?.session?.usedPercent).toBe(42)
})
it('never leaves a cached Antigravity snapshot in the error retry lane', async () => {
vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now()))
const service = new RateLimitService()
await service.refresh()
it('keeps an Antigravity reading through a Gemini failure', async () => {
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(
errorProvider('gemini', 'Token refresh failed')
errorProvider('gemini', 'Gemini project ID not found')
)
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 55))
const service = new RateLimitService()
await service.refresh()
// Why: stale-retention would otherwise show Gemini numbers as "Refresh failed" Antigravity usage.
expect(service.getState().antigravity?.status).toBe('unavailable')
expect(service.getState().antigravity?.session).toBeNull()
const state = service.getState()
// Why: the two providers no longer share a credential or an endpoint, so a Gemini
// token problem is not evidence about Antigravity quota (#9122).
expect(state.antigravity?.status).toBe('ok')
expect(state.antigravity?.session?.usedPercent).toBe(55)
expect(state.gemini?.status).toBe('error')
expect(state.gemini?.error).toBe('Gemini project ID not found')
})
it('reports an Antigravity failure without touching Gemini', async () => {
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now()))
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(
errorProvider('antigravity', 'The Antigravity CLI did not report a quota.')
)
const service = new RateLimitService()
await service.refresh()
const state = service.getState()
expect(state.antigravity?.status).toBe('error')
expect(state.antigravity?.session).toBeNull()
expect(state.gemini?.status).toBe('ok')
})
it('surfaces a rejected Antigravity fetch as that provider\u2019s error', async () => {
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now()))
vi.mocked(fetchAntigravityRateLimits).mockRejectedValue(new Error('spawn agy ENOENT'))
const service = new RateLimitService()
await service.refresh()
const state = service.getState()
expect(state.antigravity?.status).toBe('error')
expect(state.antigravity?.error).toContain('spawn agy ENOENT')
// Why: a thrown Antigravity fetch must not abort the cycle for everyone else.
expect(state.claude?.status).toBe('ok')
expect(state.gemini?.status).toBe('ok')
})
})
@@ -25,6 +25,10 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() }))
vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) }))
vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() }))
vi.mock('./antigravity-usage-fetcher', () => ({
fetchAntigravityRateLimits: vi.fn()
}))
vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() }))
vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() }))
vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) }))
@@ -43,6 +43,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
fetchZcodeRateLimits: vi.fn()
}))
vi.mock('./antigravity-usage-fetcher', () => ({
fetchAntigravityRateLimits: vi.fn()
}))
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -40,6 +40,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
fetchZcodeRateLimits: vi.fn()
}))
vi.mock('./antigravity-usage-fetcher', () => ({
fetchAntigravityRateLimits: vi.fn()
}))
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -37,6 +37,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
fetchZcodeRateLimits: vi.fn()
}))
vi.mock('./antigravity-usage-fetcher', () => ({
fetchAntigravityRateLimits: vi.fn()
}))
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -45,6 +45,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
fetchZcodeRateLimits: vi.fn()
}))
vi.mock('./antigravity-usage-fetcher', () => ({
fetchAntigravityRateLimits: vi.fn()
}))
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -45,6 +45,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
fetchZcodeRateLimits: vi.fn()
}))
vi.mock('./antigravity-usage-fetcher', () => ({
fetchAntigravityRateLimits: vi.fn()
}))
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -1,5 +1,4 @@
import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation'
import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror'
import { settleSiblingProviderResult } from './service-sibling-provider-result'
import type { ProviderRateLimits } from './service-types'
@@ -37,7 +36,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
],
grokResultPromise,
cursorResultPromise,
zcodeResultPromise
zcodeResultPromise,
antigravityResultPromise
} = prepared
if (signal.aborted) {
return
@@ -82,9 +82,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
status: 'error'
} satisfies ProviderRateLimits)
// Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure.
const antigravity = deriveAntigravityRateLimits(gemini)
const opencodeGo =
opencodeGoResult.status === 'fulfilled'
? opencodeGoResult.value
@@ -159,7 +156,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
this.trackActiveFailureStreak('codex', codex)
}
this.trackActiveFailureStreak('gemini', gemini)
this.trackActiveFailureStreak('antigravity', antigravity)
if (shouldApplyOpencode) {
this.trackActiveFailureStreak('opencode-go', opencodeGo)
}
@@ -186,7 +182,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
: this.applyStalePolicy(opencodeGo, previousState.opencodeGo)
: this.state.opencodeGo,
kimi: this.applyStalePolicy(kimi, previousState.kimi),
antigravity: this.applyStalePolicy(antigravity, previousState.antigravity),
minimax: shouldApplyMiniMax
? miniMaxConfigChanged
? miniMax
@@ -194,10 +189,11 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
: this.state.minimax
})
const [grokSettled, cursorSettled, zcodeSettled] = await Promise.all([
const [grokSettled, cursorSettled, zcodeSettled, antigravitySettled] = await Promise.all([
grokResultPromise,
cursorResultPromise,
zcodeResultPromise
zcodeResultPromise,
antigravityResultPromise
])
if (signal.aborted) {
return
@@ -205,6 +201,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
const grok = settleSiblingProviderResult('grok', grokSettled)
const cursor = settleSiblingProviderResult('cursor', cursorSettled)
const zcode = settleSiblingProviderResult('zcode', zcodeSettled)
const antigravity = settleSiblingProviderResult('antigravity', antigravitySettled)
// Why: the stale policy keeps a recent snapshot through a failed refresh, but
// a snapshot belonging to a different Cursor account must not survive the
// switch — the Accounts pane would name the new account beside the old
@@ -225,6 +222,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
this.trackActiveFailureStreak('grok', grok)
this.trackActiveFailureStreak('cursor', cursor)
this.trackActiveFailureStreak('zcode', zcode)
this.trackActiveFailureStreak('antigravity', antigravity)
this.updateState({
...this.state,
grok: this.applyStalePolicy(grok, previousState.grok),
@@ -232,7 +230,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
zcode:
zcode.status === 'error' && !sameZcodeAccount
? zcode
: this.applyStalePolicy(zcode, previousState.zcode)
: this.applyStalePolicy(zcode, previousState.zcode),
antigravity: this.applyStalePolicy(antigravity, previousState.antigravity)
})
}
}
@@ -6,6 +6,7 @@ import { readGrokAuthSession } from '../grok-auth'
import { fetchCursorRateLimits } from '../cursor-fetcher'
import { readCursorAuthSession } from '../cursor-auth'
import { fetchZcodeRateLimits } from '../zcode-usage-fetcher'
import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher'
import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher'
import { createHash } from 'node:crypto'
import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection'
@@ -46,6 +47,7 @@ export type FetchAllCyclePrepared = {
grokResultPromise: Promise<SettledProviderResult>
cursorResultPromise: Promise<SettledProviderResult>
zcodeResultPromise: Promise<SettledProviderResult>
antigravityResultPromise: Promise<SettledProviderResult>
}
export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy {
@@ -153,6 +155,14 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
(reason) => ({ status: 'rejected', reason }) as const
)
// Why its own promise: the Antigravity read spawns `agy` and waits ~2.5 s for the CLI to start
// its language server and refresh the quota. Inside the awaited tuple that latency would be
// added to every other provider's cycle.
const antigravityResultPromise = fetchAntigravityRateLimits({ signal }).then(
(value) => ({ status: 'fulfilled', value }) as const,
(reason) => ({ status: 'rejected', reason }) as const
)
const missingWslCodexHome =
codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget)
const grokResultPromise = fetchGrokRateLimits({
@@ -239,7 +249,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
],
grokResultPromise,
cursorResultPromise,
zcodeResultPromise
zcodeResultPromise,
antigravityResultPromise
}
}
}