diff --git a/src/main/index.ts b/src/main/index.ts index ab0782c9a25c..49609edbae6d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -229,6 +229,7 @@ import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' import { getKimiRuntimeTarget, resolveKimiHome } from './kimi/kimi-runtime-home' +import { resolveLocalAccountRuntimeTarget } from '../shared/local-account-runtime' import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync' import { attachMainWindowServices, @@ -2612,6 +2613,10 @@ void app.whenReady().then(async () => { // Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the // usage fetch must read the WSL-side credentials when that's the configured runtime (#12370). rateLimits.setKimiHomeResolver(() => resolveKimiHome(getKimiRuntimeTarget(store!.getSettings()))) + // agy signs in per runtime and keeps the token in that runtime's keyring, so ask the one that owns it. + rateLimits.setAntigravityRuntimeTargetResolver(() => + resolveLocalAccountRuntimeTarget(store!.getSettings()) + ) rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( rateLimits, diff --git a/src/main/rate-limits/antigravity-cli-usage.test.ts b/src/main/rate-limits/antigravity-cli-usage.test.ts new file mode 100644 index 000000000000..cdf40f9ff4f3 --- /dev/null +++ b/src/main/rate-limits/antigravity-cli-usage.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { parseAntigravityCliUsage } from './antigravity-cli-usage' + +// Real `agy -p "/usage"` output: family, window label, remaining percentage, reset instant. +const SAMPLE = [ + 'Gemini Models\tWeekly Limit Remaining\t100%\t2026-09-02T17:02:52Z', + 'Gemini Models\tFive Hour Limit Remaining\t100%\t2026-08-26T22:02:52Z', + 'Claude and GPT models\tWeekly Limit Remaining\t100%\t2026-09-02T17:02:52Z', + 'Claude and GPT models\tFive Hour Limit Remaining\t100%\t2026-08-26T22:02:52Z' +].join('\n') + +const UPDATED_AT = 1_700_000_000_000 + +describe('parseAntigravityCliUsage', () => { + it('maps the five hour row to the session window', () => { + const limits = parseAntigravityCliUsage(SAMPLE, UPDATED_AT) + + expect(limits.provider).toBe('antigravity') + expect(limits.status).toBe('ok') + expect(limits.error).toBeNull() + expect(limits.session?.windowMinutes).toBe(300) + expect(limits.session?.resetsAt).toBe(Date.parse('2026-08-26T22:02:52Z')) + }) + + it('maps the weekly row to the weekly window', () => { + const limits = parseAntigravityCliUsage(SAMPLE, UPDATED_AT) + + expect(limits.weekly?.windowMinutes).toBe(10080) + expect(limits.weekly?.resetsAt).toBe(Date.parse('2026-09-02T17:02:52Z')) + }) + + // Why: the CLI reports what is left, but every other provider in Orca reports what is spent. + it('converts remaining percentage into used percentage', () => { + const limits = parseAntigravityCliUsage(SAMPLE, UPDATED_AT) + + expect(limits.session?.usedPercent).toBe(0) + expect(limits.weekly?.usedPercent).toBe(0) + + const partial = parseAntigravityCliUsage( + 'Gemini Models\tFive Hour Limit Remaining\t42%\t2026-08-26T22:02:52Z', + UPDATED_AT + ) + + expect(partial.session?.usedPercent).toBe(58) + }) + + // Why: the CLI prints both families; Gemini is the provider's own quota and must win. + it('prefers the Gemini family over the Claude and GPT family', () => { + const limits = parseAntigravityCliUsage( + [ + 'Claude and GPT models\tFive Hour Limit Remaining\t10%\t2026-08-26T22:02:52Z', + 'Gemini Models\tFive Hour Limit Remaining\t70%\t2026-08-26T22:02:52Z' + ].join('\n'), + UPDATED_AT + ) + + expect(limits.session?.usedPercent).toBe(30) + }) + + it('reports unavailable when no usage row can be read', () => { + const limits = parseAntigravityCliUsage('Please sign in to view usage.', UPDATED_AT) + + expect(limits.status).toBe('unavailable') + expect(limits.session).toBeNull() + expect(limits.weekly).toBeNull() + expect(limits.error).toBeTruthy() + }) + + // Why: Antigravity bills two independent pools with their own resets (see #9122), + // so both must survive as named buckets, not just the headline Gemini numbers. + it('exposes both quota pools as named buckets', () => { + const limits = parseAntigravityCliUsage(SAMPLE, UPDATED_AT) + + expect(limits.buckets?.map((bucket) => bucket.name)).toEqual([ + 'Gemini Models · 7d', + 'Gemini Models · 5h', + 'Claude and GPT models · 7d', + 'Claude and GPT models · 5h' + ]) + }) + + it('marks the source as cli so the meter can explain itself', () => { + expect(parseAntigravityCliUsage(SAMPLE, UPDATED_AT).usageMetadata?.source).toBe('cli') + expect(parseAntigravityCliUsage('nope', UPDATED_AT).usageMetadata?.failureKind).toBe( + 'usage-unavailable' + ) + }) +}) diff --git a/src/main/rate-limits/antigravity-cli-usage.ts b/src/main/rate-limits/antigravity-cli-usage.ts new file mode 100644 index 000000000000..65adb90e239a --- /dev/null +++ b/src/main/rate-limits/antigravity-cli-usage.ts @@ -0,0 +1,265 @@ +import { access, constants } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { windowsSystem32Binary } from '../../shared/child-process/windows-system-binary' +import type { LocalAccountRuntimeTarget } from '../../shared/local-account-runtime' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' +import type { + ProviderRateLimits, + RateLimitBucket, + RateLimitWindow +} from '../../shared/rate-limit-types' +import { + buildWslCapturedLoginShellCommand, + buildWslExecArgs +} from '../../shared/wsl-login-shell-command' +import { translateMain } from '../i18n/main-i18n' + +const SESSION_WINDOW_MINUTES = 300 +const WEEKLY_WINDOW_MINUTES = 10080 + +// `agy` keeps its token in the OS keyring, so only the CLI itself can report the quota. +const USAGE_ARGS = ['-p', '/usage', '--print-timeout', '30s'] +// Outlives the CLI's own 30s print timeout so the child reports rather than being killed. +const PROCESS_TIMEOUT_MS = 45_000 + +const cliMissingReason = (): string => + translateMain( + 'rateLimits.antigravity.cliMissing', + 'Antigravity usage is not available. The Antigravity CLI (agy) was not found on this machine.' + ) + +const quotaUnreadableReason = (): string => + translateMain( + 'rateLimits.antigravity.quotaUnreadable', + 'Antigravity usage is not available. The Antigravity CLI did not report a quota; sign in with `agy` and try again.' + ) + +function unavailable( + reason: string, + failureKind: 'cli-unavailable' | 'usage-unavailable' +): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: reason, + status: 'unavailable', + usageMetadata: { source: 'cli', failureKind } + } +} + +/** Exists and is executable; X_OK is a no-op on Windows, where the PATH sweep does the work. */ +async function isExecutable(filePath: string): Promise { + try { + await access(filePath, constants.X_OK) + return true + } catch { + return false + } +} + +/** Host `agy` path from PATH, then the installer's fixed prefix. Null when none is executable. */ +async function resolveHostBinary(signal?: AbortSignal): Promise { + const lookup = + process.platform === 'win32' + ? { program: windowsSystem32Binary('where.exe'), args: ['agy'] } + : { program: '/usr/bin/which', args: ['agy'] } + try { + const result = await runProcess({ ...lookup, timeoutMs: 5_000, signal }) + // Every hit, not just the first: a stale entry earlier on PATH would hide a working one. + for (const candidate of result.stdout.trim().split(/\r?\n/)) { + if (candidate && (await isExecutable(candidate))) { + return candidate + } + } + } catch { + // ignore where/which failure + } + + // The installer writes the User PATH registry entry, which an already-running Orca never sees. + const fallbacks = + process.platform === 'win32' + ? [path.join(process.env.LOCALAPPDATA ?? '', 'agy', 'bin', 'agy.exe')] + : [ + path.join(homedir(), '.local', 'bin', 'agy'), + '/usr/local/bin/agy', + '/opt/homebrew/bin/agy' + ] + for (const candidate of fallbacks) { + if (candidate && (await isExecutable(candidate))) { + return candidate + } + } + + return null +} + +type ParsedRow = { + family: string + isGeminiFamily: boolean + windowMinutes: number + window: RateLimitWindow +} + +/** One `family window remaining% reset` row, or null when it is not one. */ +function parseRow(line: string): ParsedRow | null { + // Tabs normally, but padded spaces survive some terminals. + const columns = line + .split(/\t|\s{2,}/) + .map((column) => column.trim()) + .filter(Boolean) + if (columns.length < 3) { + return null + } + + const [family, label, percent, resetAt] = columns + const remaining = /^(\d+(?:\.\d+)?)%$/.exec(percent)?.[1] + if (remaining === undefined) { + return null + } + + const windowMinutes = /five\s*hour/i.test(label) + ? SESSION_WINDOW_MINUTES + : /week/i.test(label) + ? WEEKLY_WINDOW_MINUTES + : null + if (windowMinutes === null) { + return null + } + + // The CLI reports what is left; every other Orca provider reports what is spent. + const usedPercent = Math.min(100, Math.max(0, 100 - Number(remaining))) + const parsedReset = resetAt ? Date.parse(resetAt) : Number.NaN + + return { + family, + isGeminiFamily: /gemini/i.test(family), + windowMinutes, + window: { + usedPercent, + windowMinutes, + resetsAt: Number.isNaN(parsedReset) ? null : parsedReset, + resetDescription: null + } + } +} + +/** + * Converts `agy -p "/usage"` output into provider rate limits. + * + * Antigravity bills two independent pools with their own resets (#9122), so both survive + * as named buckets; the headline windows come from the Gemini pool. + */ +export function parseAntigravityCliUsage(stdout: string, updatedAt: number): ProviderRateLimits { + const rows = stdout + .split(/\r?\n/) + .map(parseRow) + .filter((row): row is ParsedRow => row !== null) + + const pick = (windowMinutes: number): RateLimitWindow | null => { + const candidates = rows.filter((row) => row.windowMinutes === windowMinutes) + const preferred = candidates.find((row) => row.isGeminiFamily) ?? candidates[0] + return preferred?.window ?? null + } + + const session = pick(SESSION_WINDOW_MINUTES) + const weekly = pick(WEEKLY_WINDOW_MINUTES) + + if (!session && !weekly) { + return { ...unavailable(quotaUnreadableReason(), 'usage-unavailable'), updatedAt } + } + + const buckets: RateLimitBucket[] = rows.map((row) => ({ + ...row.window, + name: `${row.family} · ${row.windowMinutes === SESSION_WINDOW_MINUTES ? '5h' : '7d'}` + })) + + return { + provider: 'antigravity', + session, + weekly, + buckets, + updatedAt, + error: null, + status: 'ok', + usageMetadata: { source: 'cli' } + } +} + +/** Asks the distro's own `agy`, fenced because the login shell prints its banner to stdout. */ +async function fetchFromWsl(distro: string, signal?: AbortSignal): Promise { + // skipWindowsMountDirs so the Windows agy on the mounted PATH cannot answer for the guest. + const command = [ + buildPosixCommandPathLookupScript( + { kind: 'literal', value: 'agy' }, + { skipWindowsMountDirs: true } + ), + 'if [ -z "$resolved" ]; then exit 127; fi', + `exec "$resolved" ${USAGE_ARGS.join(' ')}` + ].join('\n') + const captured = buildWslCapturedLoginShellCommand(command) + + try { + const result = await runProcess({ + program: windowsSystem32Binary('wsl.exe'), + args: buildWslExecArgs(distro, ['sh', '-c', captured.command]), + timeoutMs: PROCESS_TIMEOUT_MS, + signal + }) + const payload = captured.readStdout(result.stdout) + if (result.code !== 0 || payload === null) { + return unavailable( + result.code === 127 ? cliMissingReason() : quotaUnreadableReason(), + result.code === 127 ? 'cli-unavailable' : 'usage-unavailable' + ) + } + return parseAntigravityCliUsage(payload, Date.now()) + } catch { + return unavailable(quotaUnreadableReason(), 'usage-unavailable') + } +} + +/** + * Reads Antigravity quota from the CLI on the runtime that owns it. + * + * `agy` signs in per runtime and keeps the token in that runtime's keyring, so a WSL target + * must be asked inside WSL — the host copy would answer for a different account (#12370). + * + * `RateLimitService` models only host and WSL, so SSH targets cannot be honoured here yet; + * that needs execution-host awareness across the service (see ssh-execution-boundary.md). + */ +export async function fetchAntigravityRateLimits(options?: { + target?: LocalAccountRuntimeTarget + signal?: AbortSignal +}): Promise { + const { target, signal } = options ?? {} + + if (target?.runtime === 'wsl') { + return target.wslDistro + ? await fetchFromWsl(target.wslDistro, signal) + : unavailable(cliMissingReason(), 'cli-unavailable') + } + + const binary = await resolveHostBinary(signal) + if (!binary) { + return unavailable(cliMissingReason(), 'cli-unavailable') + } + + try { + const result = await runProcess({ + program: binary, + args: USAGE_ARGS, + timeoutMs: PROCESS_TIMEOUT_MS, + signal + }) + // A non-zero exit means signed out or an unreachable backend, not an Orca error. + return result.code === 0 + ? parseAntigravityCliUsage(result.stdout, Date.now()) + : unavailable(quotaUnreadableReason(), 'usage-unavailable') + } catch { + return unavailable(quotaUnreadableReason(), 'usage-unavailable') + } +} 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 00d3db160029..15326fdbebbb 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -4,6 +4,7 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import type { RateLimitService } from './service' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-cli-usage' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' @@ -95,6 +96,16 @@ export function mockFreshBackgroundProviderFetches(): void { export function resetRateLimitProviderMocks(): void { vi.clearAllMocks() vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) + // Default to a machine without the Antigravity CLI, which is the mirror path. + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue({ + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Antigravity CLI not found.', + status: 'unavailable', + usageMetadata: { source: 'cli', failureKind: 'cli-unavailable' } + }) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now())) vi.mocked(fetchKimiRateLimits).mockResolvedValue(okProvider('kimi', 0, Date.now())) vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now())) 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 86008a4bd846..76e35185f4b0 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -24,6 +24,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-cli-usage', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: 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 b617d9099443..e994977366a2 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { RateLimitService } from './service' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-cli-usage' import { errorProvider, okProvider, @@ -23,6 +25,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-cli-usage', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) @@ -47,11 +53,25 @@ vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) +function cliUnavailable(failureKind: 'cli-unavailable' | 'usage-unavailable'): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Antigravity usage is not available.', + status: 'unavailable', + usageMetadata: { source: 'cli', failureKind } + } +} + describe('RateLimitService Antigravity usage', () => { beforeEach(() => { resetRateLimitProviderMocks() vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) + // Default to a machine without the CLI, so the mirror stays under test. + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(cliUnavailable('cli-unavailable')) }) it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { @@ -97,4 +117,30 @@ describe('RateLimitService Antigravity usage', () => { expect(service.getState().antigravity?.status).toBe('unavailable') expect(service.getState().antigravity?.session).toBeNull() }) + + it('prefers a real CLI reading over the Gemini mirror', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 11, Date.now()) + ) + const service = new RateLimitService() + + await service.refresh() + + // Why: the CLI describes Antigravity's own pools; the mirror only ever approximated them. + expect(service.getState().antigravity?.session?.usedPercent).toBe(11) + }) + + it('lets a signed-out CLI answer for itself instead of blaming Gemini', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(cliUnavailable('usage-unavailable')) + const service = new RateLimitService() + + await service.refresh() + + const state = service.getState() + expect(state.antigravity?.status).toBe('unavailable') + // Why: mirroring here would show Gemini numbers for an account that is not signed in. + expect(state.antigravity?.session).toBeNull() + }) }) 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 e734b52172a4..68ab60f065ef 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -31,6 +31,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-cli-usage', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: 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 59c1300532da..baf43fdebe78 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -28,6 +28,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-cli-usage', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: 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 737ca0ff03d1..b1a04dc5063a 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -25,6 +25,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-cli-usage', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: 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 7ac7f22f1649..430470f0a6e8 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -32,6 +32,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-cli-usage', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: 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 a4a1dc14f767..18c626cb236b 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -33,6 +33,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-cli-usage', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index d6f291d6b38e..f664fce40b6b 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -21,6 +21,8 @@ import { } from '../claude-accounts/runtime-selection' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' +import { fetchAntigravityRateLimits } from './antigravity-cli-usage' +import type { LocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { fetchKimiRateLimits } from './kimi-fetcher' import type { KimiHomeResolution } from '../kimi/kimi-runtime-home' import { fetchGrokRateLimits } from './grok-fetcher' @@ -228,6 +230,7 @@ export class RateLimitService { } // Why: resolved per cycle — the local-account runtime policy can flip between fetches. private kimiHomeResolver: KimiHomeResolver | null = null + private antigravityRuntimeTargetResolver: (() => LocalAccountRuntimeTarget) | null = null private claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null private claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { runtime: 'host', @@ -286,6 +289,10 @@ export class RateLimitService { this.kimiHomeResolver = resolver } + setAntigravityRuntimeTargetResolver(resolver: () => LocalAccountRuntimeTarget): void { + this.antigravityRuntimeTargetResolver = resolver + } + // Why: resolving a WSL home probes wsl.exe, so it must not run before the other // providers' fetches are started; chaining keeps the no-resolver path immediate. private fetchKimiWithResolvedHome(): Promise { @@ -1308,6 +1315,13 @@ export class RateLimitService { return left.runtime === right.runtime && left.wslDistro === right.wslDistro } + private isSameAntigravityTarget( + left: LocalAccountRuntimeTarget | undefined, + right: LocalAccountRuntimeTarget | undefined + ): boolean { + return left?.runtime === right?.runtime && left?.wslDistro === right?.wslDistro + } + private getCodexProvenance( target: NormalizedCodexAccountSelectionTarget, codexHomePath: string | null @@ -1688,41 +1702,53 @@ export class RateLimitService { // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. const claudeFetchGated = !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) - - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = - await Promise.allSettled([ - claudeFetchGated - ? Promise.resolve(previousState.claude as ProviderRateLimits) - : fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), - codexFetchGated - ? Promise.resolve(previousState.codex as ProviderRateLimits) - : (missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - })), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( - cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), - this.fetchKimiWithResolvedHome(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels - }) - ]) + const antigravityTarget = this.antigravityRuntimeTargetResolver?.() + + const [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult, + antigravityResult + ] = await Promise.allSettled([ + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + this.fetchKimiWithResolvedHome(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels + }), + fetchAntigravityRateLimits({ + target: antigravityTarget, + signal + }) + ]) if (signal.aborted) { return @@ -1767,8 +1793,25 @@ export class RateLimitService { 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) + // Why: `agy` keeps its token in the OS keyring, so the CLI is the only source that + // describes real Antigravity quota. The Gemini mirror stays as the fallback for machines + // where the Antigravity CLI is not installed; a signed-out CLI still answers for itself, + // because blaming a missing Gemini sign-in would send the user to a dead product. + // A mid-flight runtime switch means this answer describes the runtime we left; keep the + // previous reading and let the next refresh ask the runtime that is current now. + const antigravityTargetStale = !this.isSameAntigravityTarget( + antigravityTarget, + this.antigravityRuntimeTargetResolver?.() + ) + const antigravityCli = + !antigravityTargetStale && antigravityResult.status === 'fulfilled' + ? antigravityResult.value + : null + const antigravity = antigravityTargetStale + ? (previousState.antigravity ?? deriveAntigravityRateLimits(gemini)) + : antigravityCli && antigravityCli.usageMetadata?.failureKind !== 'cli-unavailable' + ? antigravityCli + : deriveAntigravityRateLimits(gemini) const opencodeGo = opencodeGoResult.status === 'fulfilled'