Repository navigation
fix(rate-limits): read real Antigravity quota from the agy CLI, not the Gemini mirror #24073
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
5b217b0
fix(rate-limits): read real Antigravity quota from the agy CLI
nwparker bb60e4f
test(rate-limits): stub the Antigravity CLI fetch in every service suite
nwparker 661835e
fix(rate-limits): never pass --disable-slash-commands to the agy quot…
nwparker bc30bae
fix(rate-limits): stop polling agy once it answers /usage as a prompt
nwparker File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isPlainAntigravityExecutableis exported but never called (repo-wide grep finds only this definition). Its doc comment describes a rule the fetcher does not enforce — the fetcher always resolves the plainagyexecutable itself. Either delete it or use it where a configured launch command is considered.