diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts new file mode 100644 index 000000000000..84c92e1ad22f --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-mirror.test.ts @@ -0,0 +1,70 @@ +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 new file mode 100644 index 000000000000..3cea6decc111 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-mirror.ts @@ -0,0 +1,29 @@ +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/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts new file mode 100644 index 000000000000..b617d9099443 --- /dev/null +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RateLimitService } from './service' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { fetchCodexRateLimits } from './codex-fetcher' +import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { + errorProvider, + okProvider, + resetRateLimitProviderMocks +} from './rate-limit-service-test-harness' + +vi.mock('./claude-fetcher', () => ({ + fetchClaudeRateLimits: vi.fn(), + fetchManagedAccountUsage: vi.fn() +})) + +vi.mock('./codex-fetcher', () => ({ + consumeCodexRateLimitResetCredit: vi.fn(), + fetchCodexRateLimits: vi.fn() +})) + +vi.mock('./gemini-usage-fetcher', () => ({ + fetchGeminiRateLimits: vi.fn() +})) + +vi.mock('./kimi-fetcher', () => ({ + fetchKimiRateLimits: vi.fn() +})) + +vi.mock('./opencode-go-usage-fetcher', () => ({ + fetchOpenCodeGoRateLimits: vi.fn() +})) + +vi.mock('./minimax-fetcher', () => ({ + fetchMiniMaxRateLimits: vi.fn() +})) + +vi.mock('./grok-fetcher', () => ({ + fetchGrokRateLimits: vi.fn() +})) + +vi.mock('./grok-auth', () => ({ + readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + +vi.mock('../minimax/minimax-cookie-store', () => ({ + hasMiniMaxSessionCookie: vi.fn(() => false) +})) + +describe('RateLimitService Antigravity usage', () => { + beforeEach(() => { + resetRateLimitProviderMocks() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) + }) + + 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 () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + const service = new RateLimitService() + + await service.refresh() + + const state = service.getState() + expect(state.antigravity?.status).toBe('ok') + expect(state.antigravity?.provider).toBe('antigravity') + expect(state.antigravity?.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() + + vi.mocked(fetchGeminiRateLimits).mockResolvedValue( + errorProvider('gemini', 'Token refresh failed') + ) + 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() + }) +}) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 02bd1930bb6b..d6f291d6b38e 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 { deriveAntigravityRateLimits } from './antigravity-usage-mirror' import { fetchKimiRateLimits } from './kimi-fetcher' import type { KimiHomeResolution } from '../kimi/kimi-runtime-home' import { fetchGrokRateLimits } from './grok-fetcher' @@ -1766,11 +1767,8 @@ 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' - } + // 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'