Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/main/rate-limits/antigravity-usage-mirror.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
29 changes: 29 additions & 0 deletions src/main/rate-limits/antigravity-usage-mirror.ts
Original file line number Diff line number Diff line change
@@ -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' }
Comment on lines +13 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not copy Gemini-only buckets into Antigravity state.

Line 15 copies buckets, but ProviderRateLimits defines them as Gemini-only. Construct the Antigravity result from shared quota fields. Add a regression test with Gemini buckets and assert that Antigravity has no buckets.

Proposed fix
   if (gemini.status === 'ok') {
-    return { ...gemini, provider: 'antigravity' }
+    return {
+      provider: 'antigravity',
+      session: gemini.session,
+      weekly: gemini.weekly,
+      updatedAt: gemini.updatedAt,
+      error: null,
+      status: 'ok',
+      usageMetadata: gemini.usageMetadata
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits {
if (gemini.status === 'ok') {
return { ...gemini, provider: 'antigravity' }
export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits {
if (gemini.status === 'ok') {
return {
provider: 'antigravity',
session: gemini.session,
weekly: gemini.weekly,
updatedAt: gemini.updatedAt,
error: null,
status: 'ok',
usageMetadata: gemini.usageMetadata
}

}
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'
}
}
100 changes: 100 additions & 0 deletions src/main/rate-limits/service-antigravity-usage.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
8 changes: 3 additions & 5 deletions src/main/rate-limits/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down