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 000000000000..2df13007f725 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { + fetchAntigravityRateLimits, + parseAntigravityUsageOutput +} from './antigravity-usage-fetcher' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('../../shared/node-cli-command-resolution', () => ({ resolveCliCommand: vi.fn() })) + +const usagePayload = JSON.stringify({ + status: 'SUCCESS', + command: { + name: 'usage', + data: { + groups: [ + { + name: 'Gemini Models', + buckets: [ + { + id: 'gemini-weekly', + remaining_fraction: 0.98, + reset_time: '2026-08-29T12:00:00Z' + }, + { + id: 'gemini-5h', + remaining_fraction: 0.91, + reset_time: '2026-08-22T16:00:00Z' + } + ] + }, + { + name: 'Claude and GPT models', + buckets: [ + { + id: '3p-weekly', + remaining_fraction: 1, + reset_time: '2026-08-29T13:00:00Z' + }, + { + id: '3p-5h', + remaining_fraction: 0.995, + reset_time: '2026-08-22T17:00:00Z' + } + ] + } + ] + } + } +}) + +describe('parseAntigravityUsageOutput', () => { + it('preserves all four native quota identities', () => { + expect(parseAntigravityUsageOutput(usagePayload)).toEqual([ + { + name: 'Gemini weekly', + usedPercent: 2, + windowMinutes: 10_080, + resetsAt: Date.parse('2026-08-29T12:00:00Z'), + resetDescription: null + }, + { + name: 'Gemini 5h', + usedPercent: 9, + windowMinutes: 300, + resetsAt: Date.parse('2026-08-22T16:00:00Z'), + resetDescription: null + }, + { + name: 'Claude/GPT weekly', + usedPercent: 0, + windowMinutes: 10_080, + resetsAt: Date.parse('2026-08-29T13:00:00Z'), + resetDescription: null + }, + { + name: 'Claude/GPT 5h', + usedPercent: 1, + windowMinutes: 300, + resetsAt: Date.parse('2026-08-22T17:00:00Z'), + resetDescription: null + } + ]) + }) + + it('ignores unknown buckets and keeps partial recognized data', () => { + const payload = JSON.stringify({ + status: 'SUCCESS', + command: { + name: 'usage', + data: { + groups: [ + { + buckets: [ + { id: 'future-window', remaining_fraction: 0.5 }, + { id: 'gemini-5h', remaining_fraction: 0.4, reset_time: 'invalid' } + ] + } + ] + } + } + }) + + expect(parseAntigravityUsageOutput(payload)).toEqual([ + { + name: 'Gemini 5h', + usedPercent: 60, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + ]) + }) + + it.each([ + ['non-JSON output', 'not JSON'], + ['a non-usage command', JSON.stringify({ status: 'SUCCESS', command: { name: 'models' } })], + [ + 'no recognized buckets', + JSON.stringify({ + status: 'SUCCESS', + command: { name: 'usage', data: { groups: [{ buckets: [] }] } } + }) + ] + ])('rejects %s', (_name, output) => { + expect(() => parseAntigravityUsageOutput(output)).toThrow() + }) +}) + +describe('fetchAntigravityRateLimits', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.mocked(resolveCliCommand).mockReturnValue('C:\\Agy\\agy.exe') + }) + + it('runs Agy print mode and returns native quota summaries', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: usagePayload, + stderr: '', + timedOut: false + }) + const controller = new AbortController() + + const result = await fetchAntigravityRateLimits({ signal: controller.signal }) + + expect(resolveCliCommand).toHaveBeenCalledWith('agy') + expect(runProcess).toHaveBeenCalledWith({ + program: 'C:\\Agy\\agy.exe', + args: ['-p', '/usage', '--output-format', 'json', '--print-timeout', '20s'], + timeoutMs: 25_000, + maxOutputBytes: 1024 * 1024, + signal: controller.signal + }) + expect(result).toMatchObject({ + provider: 'antigravity', + status: 'ok', + error: null, + session: { usedPercent: 9, windowMinutes: 300 }, + weekly: null, + usageMetadata: { source: 'cli', attemptedSources: ['cli'] } + }) + expect(result.buckets).toHaveLength(4) + }) + + it('reports a missing Agy executable as unavailable', async () => { + vi.mocked(runProcess).mockRejectedValue( + Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }) + ) + + await expect(fetchAntigravityRateLimits()).resolves.toMatchObject({ + provider: 'antigravity', + status: 'unavailable', + error: 'Antigravity CLI not found', + usageMetadata: { failureKind: 'cli-unavailable' } + }) + }) + + it('reports command timeouts without parsing partial output', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: null, + signal: 'SIGTERM', + stdout: usagePayload, + stderr: '', + timedOut: true + }) + + await expect(fetchAntigravityRateLimits()).resolves.toMatchObject({ + status: 'error', + error: 'Agy usage request timed out', + usageMetadata: { failureKind: 'usage-unavailable' } + }) + }) + + it('classifies malformed successful output as a parse failure', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '{}', + stderr: '', + timedOut: false + }) + + await expect(fetchAntigravityRateLimits()).resolves.toMatchObject({ + status: 'error', + usageMetadata: { failureKind: 'parse' } + }) + }) +}) 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 000000000000..b249aebf55ce --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -0,0 +1,195 @@ +import type { + ProviderRateLimits, + RateLimitBucket, + RateLimitWindow +} from '../../shared/rate-limit-types' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' + +const AGY_USAGE_ARGS = [ + '-p', + '/usage', + '--output-format', + 'json', + '--print-timeout', + '20s' +] as const +const AGY_PROCESS_TIMEOUT_MS = 25_000 + +const AGY_BUCKETS = { + 'gemini-weekly': { name: 'Gemini weekly', windowMinutes: 10_080 }, + 'gemini-5h': { name: 'Gemini 5h', windowMinutes: 300 }, + '3p-weekly': { name: 'Claude/GPT weekly', windowMinutes: 10_080 }, + '3p-5h': { name: 'Claude/GPT 5h', windowMinutes: 300 } +} as const + +type AgyBucketId = keyof typeof AGY_BUCKETS + +type AgyUsageBucket = { + id: AgyBucketId + remainingFraction: number + resetTime: string | null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function parseResetTime(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +function parseAgyBucket(value: unknown): AgyUsageBucket | null { + if (!isRecord(value) || typeof value.id !== 'string' || !(value.id in AGY_BUCKETS)) { + return null + } + if (typeof value.remaining_fraction !== 'number' || !Number.isFinite(value.remaining_fraction)) { + return null + } + return { + id: value.id as AgyBucketId, + remainingFraction: value.remaining_fraction, + resetTime: parseResetTime(value.reset_time) + } +} + +function toRateLimitBucket(bucket: AgyUsageBucket): RateLimitBucket { + const definition = AGY_BUCKETS[bucket.id] + const remainingFraction = Math.min(1, Math.max(0, bucket.remainingFraction)) + const resetsAt = bucket.resetTime ? Date.parse(bucket.resetTime) : Number.NaN + return { + name: definition.name, + usedPercent: Math.round((1 - remainingFraction) * 100), + windowMinutes: definition.windowMinutes, + resetsAt: Number.isFinite(resetsAt) ? resetsAt : null, + resetDescription: null + } +} + +function tightestWindow(buckets: RateLimitBucket[], windowMinutes: number): RateLimitWindow | null { + const matches = buckets.filter((bucket) => bucket.windowMinutes === windowMinutes) + if (matches.length === 0) { + return null + } + const tightest = matches.reduce((current, candidate) => + candidate.usedPercent > current.usedPercent ? candidate : current + ) + const { name: _name, ...window } = tightest + return window +} + +export function parseAntigravityUsageOutput(output: string): RateLimitBucket[] { + const parsed: unknown = JSON.parse(output) + if (!isRecord(parsed) || parsed.status !== 'SUCCESS' || !isRecord(parsed.command)) { + throw new Error('Agy returned an invalid usage response') + } + if (parsed.command.name !== 'usage' || !isRecord(parsed.command.data)) { + throw new Error('Agy did not return usage data') + } + const groups = parsed.command.data.groups + if (!Array.isArray(groups)) { + throw new Error('Agy usage groups are missing') + } + + const discovered = new Map() + for (const group of groups) { + if (!isRecord(group) || !Array.isArray(group.buckets)) { + continue + } + for (const rawBucket of group.buckets) { + const bucket = parseAgyBucket(rawBucket) + if (bucket && !discovered.has(bucket.id)) { + discovered.set(bucket.id, bucket) + } + } + } + + const buckets = (Object.keys(AGY_BUCKETS) as AgyBucketId[]) + .map((id) => discovered.get(id)) + .filter((bucket): bucket is AgyUsageBucket => bucket !== undefined) + .map(toRateLimitBucket) + if (buckets.length === 0) { + throw new Error('Agy returned no recognized usage buckets') + } + return buckets +} + +function makeResult( + status: ProviderRateLimits['status'], + error: string, + failureKind: NonNullable['failureKind'] +): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [], + updatedAt: Date.now(), + error, + status, + usageMetadata: { + source: 'cli', + attemptedSources: ['cli'], + failureKind + } + } +} + +export async function fetchAntigravityRateLimits(options?: { + signal?: AbortSignal +}): Promise { + let result + try { + result = await runProcess({ + program: resolveCliCommand('agy'), + args: AGY_USAGE_ARGS, + timeoutMs: AGY_PROCESS_TIMEOUT_MS, + maxOutputBytes: 1024 * 1024, + signal: options?.signal + }) + } catch (error) { + const unavailable = isRecord(error) && error.code === 'ENOENT' + return makeResult( + unavailable ? 'unavailable' : 'error', + unavailable ? 'Antigravity CLI not found' : 'Agy usage command could not start', + unavailable ? 'cli-unavailable' : 'unknown' + ) + } + + if (result.timedOut) { + return makeResult('error', 'Agy usage request timed out', 'usage-unavailable') + } + if (result.code !== 0) { + return makeResult( + 'error', + `Agy usage command failed (exit code ${result.code ?? 'unknown'})`, + 'usage-unavailable' + ) + } + + let buckets: RateLimitBucket[] + try { + buckets = parseAntigravityUsageOutput(result.stdout) + } catch (error) { + return makeResult( + 'error', + error instanceof Error ? error.message : 'Agy returned invalid usage data', + 'parse' + ) + } + + return { + provider: 'antigravity', + session: tightestWindow(buckets, 300), + // Detailed views read the independent weekly identities from buckets. + weekly: null, + buckets, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'cli', + attemptedSources: ['cli'] + } + } +} 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..f7a7ceed4239 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-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' @@ -85,6 +86,7 @@ export function unavailableProvider( export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 24)) vi.mocked(fetchGeminiRateLimits).mockImplementation(async () => okProvider('gemini', 0)) + vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () => okProvider('antigravity', 0)) vi.mocked(fetchOpenCodeGoRateLimits).mockImplementation(async () => okProvider('opencode-go', 0)) vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) @@ -95,6 +97,7 @@ export function mockFreshBackgroundProviderFetches(): void { export function resetRateLimitProviderMocks(): void { vi.clearAllMocks() vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 0, Date.now())) 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..790ccc01e883 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-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) 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..15b64b1e843a 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-usage-fetcher', () => ({ + 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..949bc740be42 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-usage-fetcher', () => ({ + 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..0376674a0f1d 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-usage-fetcher', () => ({ + 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..e8e5d612dd21 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -4,6 +4,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 { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' @@ -32,6 +33,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) @@ -361,7 +366,7 @@ describe('RateLimitService', () => { expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1) }) - it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => { + it('fetches Gemini, Antigravity, and OpenCode Go alongside Claude and Codex', async () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: 'session=abc123', @@ -377,6 +382,9 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValueOnce( + okProvider('antigravity', 35, Date.now()) + ) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 40, Date.now()) ) @@ -388,13 +396,17 @@ describe('RateLimitService', () => { expect.objectContaining({ authPreparation: undefined, allowPtyFallback: false, - allowUsagePanelSupplement: true, + allowUsagePanelSupplement: process.platform !== 'win32', signal: expect.any(AbortSignal) }) ) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + expect(fetchAntigravityRateLimits).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal) + }) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith( 'session=abc123', @@ -413,6 +425,8 @@ describe('RateLimitService', () => { expect(state.codex?.session?.usedPercent).toBe(20) expect(state.gemini?.status).toBe('ok') expect(state.gemini?.session?.usedPercent).toBe(30) + expect(state.antigravity?.status).toBe('ok') + expect(state.antigravity?.session?.usedPercent).toBe(35) expect(state.opencodeGo?.status).toBe('ok') expect(state.opencodeGo?.session?.usedPercent).toBe(40) }) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index a4a1dc14f767..1c87a7707374 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-usage-fetcher', () => ({ + 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 02bd1930bb6b..22153d97087d 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -20,6 +20,7 @@ import { type NormalizedClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import type { KimiHomeResolution } from '../kimi/kimi-runtime-home' import { fetchGrokRateLimits } from './grok-fetcher' @@ -1688,40 +1689,48 @@ export class RateLimitService { 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 [ + claudeResult, + codexResult, + geminiResult, + antigravityResult, + 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), + fetchAntigravityRateLimits({ signal }), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + this.fetchKimiWithResolvedHome(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels + }) + ]) if (signal.aborted) { return @@ -1766,11 +1775,21 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity shares Gemini credentials today; mirror the Gemini snapshot so its status-bar UI gets a real lifecycle instead of null. - const antigravity: ProviderRateLimits = { - ...gemini, - provider: 'antigravity' - } + const antigravity = + antigravityResult.status === 'fulfilled' + ? antigravityResult.value + : ({ + provider: 'antigravity', + session: null, + weekly: null, + buckets: [], + updatedAt: Date.now(), + error: + antigravityResult.reason instanceof Error + ? antigravityResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) const opencodeGo = opencodeGoResult.status === 'fulfilled' diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx index de4f5a0e06f8..1acae678290f 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -247,6 +247,44 @@ describe('UsageRow', () => { expect(markup).toContain('25%') expect(markup).toContain('60%') }) + + it('renders all four independent Antigravity quota buckets', () => { + const bucket = (name: string, usedPercent: number, windowMinutes: number) => ({ + name, + usedPercent, + windowMinutes, + resetsAt: null, + resetDescription: null + }) + const markup = renderToStaticMarkup( + + ) + + expect(markup.match(/data-usage-window=/g)).toHaveLength(4) + expect(markup).toContain('Gemini weekly') + expect(markup).toContain('Gemini 5h') + expect(markup).toContain('Claude/GPT weekly') + expect(markup).toContain('Claude/GPT 5h') + }) }) describe('UsageRosterPanel density picker', () => { diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx index feb066fadca4..d3d6a4e90db0 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -35,7 +35,7 @@ function providerMaxUsed(sections: UsageSection[]): number { : 0 } -// Buckets (Gemini Flash/Pro) keep their model name; windows use their duration. +// Named buckets keep their provider label; fixed windows use their duration. function shortLabel( p: ProviderRateLimits, section: UsageSection, diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index e833bce7fa4c..74be8520d607 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -121,11 +121,7 @@ describe('hasUsageProviderSettings', () => { expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) - // Why: antigravity durability requires the Gemini OAuth opt-in; the - // checked item alone must not suppress the usage setup CTA. - expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe( - false - ) + expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) }) @@ -160,21 +156,19 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) }) - it('requires both a checked Antigravity item and Gemini OAuth as the durable Antigravity signal', () => { + it('uses the checked Antigravity item as its durable signal', () => { expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) ) ).toBe(true) - // Why: the snapshot mirrors the Gemini fetch — without the OAuth opt-in it - // is permanently unavailable, so the checked item alone is not durable. expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(false) + ).toBe(true) expect( hasUsageProviderSettingsForProvider( 'antigravity', @@ -338,26 +332,24 @@ describe('getVisibleUsageProvider', () => { }) }) - it('hides Antigravity while Gemini OAuth is off even when its status item is checked', () => { - // Why: without the OAuth opt-in the mirrored snapshot is permanently - // 'unavailable'; the default-on item must not pin a dead bar. + it('keeps Antigravity visible without Gemini OAuth when its status item is checked', () => { expect( getVisibleUsageProvider( 'antigravity', null, usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(null) + ).toMatchObject({ provider: 'antigravity', status: 'fetching' }) expect( getVisibleUsageProvider( 'antigravity', provider('unavailable', { provider: 'antigravity', - error: 'Gemini CLI OAuth is disabled in settings' + error: 'Antigravity CLI not found' }), usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(null) + ).toMatchObject({ provider: 'antigravity', status: 'unavailable' }) }) }) @@ -499,9 +491,7 @@ describe('isUsageEmptyState', () => { ).toBe(false) }) - it('still shows the setup CTA when Antigravity is checked but Gemini OAuth is off', () => { - // Why: the default-on Antigravity item is not configured usage on its own; - // it must not hide the teaching CTA from users who set nothing up. + it('waits for checked Antigravity usage without requiring Gemini OAuth', () => { expect( isUsageEmptyState( { @@ -516,6 +506,6 @@ describe('isUsageEmptyState', () => { }, usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(true) + ).toBe(false) }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index f1afd97f5f4b..611d5df899b3 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -8,11 +8,7 @@ export type UsageProviderSettings = Pick< | 'opencodeSessionCookie' | 'geminiCliOAuthEnabled' > & { - // Why: Antigravity has no separate persisted usage credential in Orca. The - // checked status-bar item is the durable user signal; StatusBar only sets - // this after PATH detection says the agent is available. Durability further - // requires geminiCliOAuthEnabled — the snapshot mirrors the Gemini fetch, - // which never yields data while that opt-in is off. + // Why: the checked item is durable after PATH detection confirms Agy exists. antigravityUsageConfigured: boolean // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean @@ -74,8 +70,7 @@ export function hasUsageProviderSettings( (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || - // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is - // already covered by the gemini term above. + settings?.antigravityUsageConfigured === true || settings?.minimaxCookieConfigured === true || settings?.grokAuthConfigured === true ) @@ -101,10 +96,7 @@ export function hasUsageProviderSettingsForProvider( return Boolean(settings.opencodeSessionCookie?.trim()) } if (providerId === 'antigravity') { - // Why: the Antigravity snapshot mirrors the Gemini fetch, which stays - // 'unavailable' until the user opts into Gemini CLI OAuth. Without that - // gate the default-on checked item would pin a permanently dead bar. - return settings.antigravityUsageConfigured === true && settings.geminiCliOAuthEnabled === true + return settings.antigravityUsageConfigured === true } if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 83210fba2ccb..8d356642696a 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -63,7 +63,7 @@ export type ProviderRateLimits = { fableWeekly?: RateLimitWindow | null /** 30-day monthly window (OpenCode Go, Grok unified billing), null if not available. */ monthly?: RateLimitWindow | null - /** Named per-model buckets (Gemini only). */ + /** Named provider-specific quota buckets. */ buckets?: RateLimitBucket[] /** Available earned Codex rate-limit reset credits, if reported. */ rateLimitResetCredits?: {