From 104e84593fb521c6865c01e46eebd46f2393ca41 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:09:19 -0700 Subject: [PATCH 1/2] fix(rate-limits): stop showing Gemini failures as Antigravity "Refresh failed" The Antigravity snapshot was synthesized as `{ ...gemini, provider: 'antigravity' }`, so a Gemini fetch error was republished under the Antigravity provider id even though Orca never queries Antigravity (its CLI keeps the token in the OS keyring). Derive the snapshot instead: mirror only a successful Gemini read, otherwise report `unavailable` with Antigravity-specific copy. This also drops Antigravity out of the error retry lane. Fixes #14227 --- .../antigravity-usage-mirror.test.ts | 61 +++++++++++ .../rate-limits/antigravity-usage-mirror.ts | 23 ++++ .../service-antigravity-usage.test.ts | 100 ++++++++++++++++++ src/main/rate-limits/service.ts | 8 +- 4 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 src/main/rate-limits/antigravity-usage-mirror.test.ts create mode 100644 src/main/rate-limits/antigravity-usage-mirror.ts create mode 100644 src/main/rate-limits/service-antigravity-usage.test.ts 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..84d7d618b5ea --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-mirror.test.ts @@ -0,0 +1,61 @@ +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('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('uses the same Antigravity copy 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') + }) +}) 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..1c462e79fba7 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-mirror.ts @@ -0,0 +1,23 @@ +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_UNAVAILABLE_REASON = + 'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.' + +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: ANTIGRAVITY_UNAVAILABLE_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' From 935061299efbbf3ae6b8b5c2be3aeadd6df11b46 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:29:19 -0700 Subject: [PATCH 2/2] fix(rate-limits): distinguish Antigravity quota-read failure from missing sign-in --- src/main/rate-limits/antigravity-usage-mirror.test.ts | 11 ++++++++++- src/main/rate-limits/antigravity-usage-mirror.ts | 10 ++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts index 84d7d618b5ea..84c92e1ad22f 100644 --- a/src/main/rate-limits/antigravity-usage-mirror.test.ts +++ b/src/main/rate-limits/antigravity-usage-mirror.test.ts @@ -43,13 +43,21 @@ describe('deriveAntigravityRateLimits', () => { 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('uses the same Antigravity copy when the Gemini opt-in is off', () => { + 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') ) @@ -57,5 +65,6 @@ describe('deriveAntigravityRateLimits', () => { 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 index 1c462e79fba7..3cea6decc111 100644 --- a/src/main/rate-limits/antigravity-usage-mirror.ts +++ b/src/main/rate-limits/antigravity-usage-mirror.ts @@ -4,8 +4,11 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' // 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_UNAVAILABLE_REASON = +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') { @@ -17,7 +20,10 @@ export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): Provide weekly: null, // Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle. updatedAt: gemini.updatedAt, - error: ANTIGRAVITY_UNAVAILABLE_REASON, + error: + gemini.status === 'unavailable' + ? ANTIGRAVITY_NO_SIGN_IN_REASON + : ANTIGRAVITY_QUOTA_UNREADABLE_REASON, status: 'unavailable' } }