From a76a0bcddc6d06ffc09db97dbb221ca6013d5d19 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:36:54 -0700 Subject: [PATCH] fix(rate-limits): read real Antigravity quota from the agy CLI, not the Gemini mirror (#24073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../rate-limits/antigravity-usage-command.ts | 59 ++++ .../antigravity-usage-fetcher.test.ts | 250 +++++++++++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 191 +++++++++++++ .../antigravity-usage-mirror.test.ts | 70 ----- .../rate-limits/antigravity-usage-mirror.ts | 29 -- .../antigravity-usage-real-cli.test.ts | 38 +++ .../antigravity-usage-response.test.ts | 247 +++++++++++++++++ .../rate-limits/antigravity-usage-response.ts | 253 ++++++++++++++++++ .../rate-limits/gemini-bucket-formatting.ts | 10 +- .../rate-limits/rate-limit-bucket-summary.ts | 19 ++ .../rate-limit-service-test-harness.ts | 5 + .../service-account-target-selection.test.ts | 4 + .../service-antigravity-usage.test.ts | 81 ++++-- .../rate-limits/service-cursor-usage.test.ts | 4 + .../service-inactive-account-previews.test.ts | 4 + .../service-live-claude-usage.test.ts | 4 + .../rate-limits/service-minimax-usage.test.ts | 4 + .../service-refresh-orchestration.test.ts | 4 + .../service-window-activation.test.ts | 4 + .../service/service-full-cycle-application.ts | 19 +- .../service/service-full-cycle-preparation.ts | 13 +- 21 files changed, 1166 insertions(+), 146 deletions(-) create mode 100644 src/main/rate-limits/antigravity-usage-command.ts create mode 100644 src/main/rate-limits/antigravity-usage-fetcher.test.ts create mode 100644 src/main/rate-limits/antigravity-usage-fetcher.ts delete mode 100644 src/main/rate-limits/antigravity-usage-mirror.test.ts delete mode 100644 src/main/rate-limits/antigravity-usage-mirror.ts create mode 100644 src/main/rate-limits/antigravity-usage-real-cli.test.ts create mode 100644 src/main/rate-limits/antigravity-usage-response.test.ts create mode 100644 src/main/rate-limits/antigravity-usage-response.ts create mode 100644 src/main/rate-limits/rate-limit-bucket-summary.ts diff --git a/src/main/rate-limits/antigravity-usage-command.ts b/src/main/rate-limits/antigravity-usage-command.ts new file mode 100644 index 00000000000..7d58f98cf57 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-command.ts @@ -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) +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts new file mode 100644 index 00000000000..f05fc7bc7a5 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -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 { + return { code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...overrides } +} + +function harness( + options: { + result?: ProcessResult + runCommand?: ReturnType + 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) + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts new file mode 100644 index 00000000000..9a1d8faf7f8 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -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 + 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 { + 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> + 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' + } + } +} diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts deleted file mode 100644 index 84c92e1ad22..00000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/main/rate-limits/antigravity-usage-mirror.ts b/src/main/rate-limits/antigravity-usage-mirror.ts deleted file mode 100644 index 3cea6decc11..00000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.ts +++ /dev/null @@ -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' - } -} diff --git a/src/main/rate-limits/antigravity-usage-real-cli.test.ts b/src/main/rate-limits/antigravity-usage-real-cli.test.ts new file mode 100644 index 00000000000..e4976b2274d --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-real-cli.test.ts @@ -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) +}) diff --git a/src/main/rate-limits/antigravity-usage-response.test.ts b/src/main/rate-limits/antigravity-usage-response.test.ts new file mode 100644 index 00000000000..be32522a319 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-response.test.ts @@ -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() + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-response.ts b/src/main/rate-limits/antigravity-usage-response.ts new file mode 100644 index 00000000000..3e4077ed35f --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-response.ts @@ -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 { + 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 +} diff --git a/src/main/rate-limits/gemini-bucket-formatting.ts b/src/main/rate-limits/gemini-bucket-formatting.ts index 3d58db808b7..fdee74e9323 100644 --- a/src/main/rate-limits/gemini-bucket-formatting.ts +++ b/src/main/rate-limits/gemini-bucket-formatting.ts @@ -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 = { '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) } diff --git a/src/main/rate-limits/rate-limit-bucket-summary.ts b/src/main/rate-limits/rate-limit-bucket-summary.ts new file mode 100644 index 00000000000..585c24a95aa --- /dev/null +++ b/src/main/rate-limits/rate-limit-bucket-summary.ts @@ -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 +} diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index f51839d108b..caf5ed780a5 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -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 = { @@ -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' }) diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index bb5de4b6485..27e93208c1a 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -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() })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 3696f434dec..1ffe7cabc34 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -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') }) }) diff --git a/src/main/rate-limits/service-cursor-usage.test.ts b/src/main/rate-limits/service-cursor-usage.test.ts index 3646f9f8586..03f5c1f4a16 100644 --- a/src/main/rate-limits/service-cursor-usage.test.ts +++ b/src/main/rate-limits/service-cursor-usage.test.ts @@ -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) })) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 7db0a60d661..cae71c569e7 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -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() })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 07f6ccf0d27..8c35ab77acc 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -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() })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 93f95de7847..8f31e9a6da7 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -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() })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 9577a87d6a3..e0a1d9c9b04 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -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() })) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 54296c396b9..8cbb2591503 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -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() })) diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 70b58933c16..b6a9b40373e 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -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) }) } } diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 96faa82eb34..5395ace5fbf 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -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 cursorResultPromise: Promise zcodeResultPromise: Promise + antigravityResultPromise: Promise } 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 } } }