Skip to content
Closed
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
40 changes: 40 additions & 0 deletions src/main/rate-limits/antigravity-native-usage-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import {
ANTIGRAVITY_NO_NATIVE_SOURCE_REASON,
buildAntigravityNativeUnavailable,
fetchAntigravityRateLimits
} from './antigravity-native-usage-source'

describe('antigravity native usage source', () => {
it('reports Antigravity-native unavailability rather than any other provider quota', async () => {
const antigravity = await fetchAntigravityRateLimits()

expect(antigravity.provider).toBe('antigravity')
expect(antigravity.status).toBe('unavailable')
expect(antigravity.error).toBe(ANTIGRAVITY_NO_NATIVE_SOURCE_REASON)
expect(antigravity.session).toBeNull()
expect(antigravity.weekly).toBeNull()
expect(antigravity.buckets).toBeUndefined()
})

it('never blames Gemini for an Antigravity unavailability', async () => {
const antigravity = await fetchAntigravityRateLimits()

expect(antigravity.error).not.toMatch(/gemini/i)
expect(antigravity.error).not.toMatch(/code assist/i)
})

it('takes no account or host input, so two accounts cannot share state', async () => {
// Why: the source reads nothing account-scoped; identical output is the isolation proof
// until a native per-account probe exists.
const first = buildAntigravityNativeUnavailable(1_700_000_000_000)
const second = buildAntigravityNativeUnavailable(1_700_000_000_000)

expect(first).toEqual(second)
expect(fetchAntigravityRateLimits.length).toBe(0)
})

it('stamps the snapshot so activation freshness checks can age it out', () => {
expect(buildAntigravityNativeUnavailable(1_700_000_000_000).updatedAt).toBe(1_700_000_000_000)
})
})
27 changes: 27 additions & 0 deletions src/main/rate-limits/antigravity-native-usage-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ProviderRateLimits } from '../../shared/rate-limit-types'

// Why: Antigravity has no quota source Orca can read. `agy` keeps its token in the OS keyring
// and its subcommands (agent, changelog, install, mcp, mic-serve, models, plugin,
// remote-control, update) expose no usage/quota output. Deriving this provider from Gemini's
// Code Assist quota published a different provider's pools under the Antigravity label
// (#14515) and made Antigravity usage depend on a Gemini CLI install (#9122), so this source
// reports native unavailability instead of substituting another provider.
export const ANTIGRAVITY_NO_NATIVE_SOURCE_REASON =
'Antigravity usage is not available. Orca cannot read the Antigravity account quota pools yet.'
Comment on lines +9 to +10

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Localize the Antigravity unavailability message.

buildAntigravityNativeUnavailable() stores raw English in ProviderRateLimits.error, and the unavailable-provider tooltip renders p.error directly. This bypasses translate() and shows English for non-English locales. Add an intent-named localization ID and resolve it at this display boundary.


export function buildAntigravityNativeUnavailable(now: number = Date.now()): ProviderRateLimits {
return {
provider: 'antigravity',
session: null,
weekly: null,
updatedAt: now,
error: ANTIGRAVITY_NO_NATIVE_SOURCE_REASON,
status: 'unavailable'
}
}

// Why: async and argument-free like every other provider fetcher, so a real native probe can
// replace the body without moving the call site back into a derivation of another provider.
export async function fetchAntigravityRateLimits(): Promise<ProviderRateLimits> {
return buildAntigravityNativeUnavailable()
}
70 changes: 0 additions & 70 deletions src/main/rate-limits/antigravity-usage-mirror.test.ts

This file was deleted.

29 changes: 0 additions & 29 deletions src/main/rate-limits/antigravity-usage-mirror.ts

This file was deleted.

48 changes: 40 additions & 8 deletions src/main/rate-limits/service-antigravity-usage.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RateLimitService } from './service'
import { ANTIGRAVITY_NO_NATIVE_SOURCE_REASON } from './antigravity-native-usage-source'
import { fetchClaudeRateLimits } from './claude-fetcher'
import { fetchCodexRateLimits } from './codex-fetcher'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import {
errorProvider,
okProvider,
resetRateLimitProviderMocks
resetRateLimitProviderMocks,
unavailableProvider
} from './rate-limit-service-test-harness'

vi.mock('./claude-fetcher', () => ({
Expand Down Expand Up @@ -54,7 +56,7 @@ describe('RateLimitService Antigravity usage', () => {
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20))
})

it('does not republish a Gemini failure as an Antigravity refresh failure', async () => {
it('does not republish a Gemini failure as an Antigravity failure', async () => {
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(
errorProvider('gemini', 'Gemini project ID not found')
)
Expand All @@ -71,16 +73,47 @@ describe('RateLimitService Antigravity usage', () => {
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()))
it('never surfaces successful Gemini quota under the Antigravity label (#14515)', async () => {
vi.mocked(fetchGeminiRateLimits).mockResolvedValue({
...okProvider('gemini', 42, Date.now()),
buckets: [
{
name: 'Gemini 3 Pro',
usedPercent: 42,
windowMinutes: 300,
resetsAt: null,
resetDescription: null
}
]
})
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)
expect(state.antigravity?.status).toBe('unavailable')
expect(state.antigravity?.session).toBeNull()
expect(state.antigravity?.buckets).toBeUndefined()
expect(state.antigravity?.error).toBe(ANTIGRAVITY_NO_NATIVE_SOURCE_REASON)
// Why: Gemini's own snapshot is untouched; only the impersonation is gone.
expect(state.gemini?.session?.usedPercent).toBe(42)
})

it('reports native unavailability when no Gemini CLI sign-in exists at all (#9122)', async () => {
// Why: "no Gemini binary" is the #9122 repro; Antigravity must not inherit that failure
// or name Gemini in its own reason.
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(
unavailableProvider('gemini', 'Gemini CLI credentials not found')
)
const service = new RateLimitService()

await service.refresh()

const antigravity = service.getState().antigravity
expect(antigravity?.provider).toBe('antigravity')
expect(antigravity?.status).toBe('unavailable')
expect(antigravity?.error).not.toMatch(/gemini/i)
expect(antigravity?.error).toBe(ANTIGRAVITY_NO_NATIVE_SOURCE_REASON)
})

it('never leaves a cached Antigravity snapshot in the error retry lane', async () => {
Expand All @@ -93,7 +126,6 @@ describe('RateLimitService Antigravity usage', () => {
)
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()
})
Expand Down
13 changes: 9 additions & 4 deletions src/main/rate-limits/service/service-full-cycle-application.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation'
import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror'
import { buildAntigravityNativeUnavailable } from '../antigravity-native-usage-source'
import type { ProviderRateLimits } from './service-types'

export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation {
Expand Down Expand Up @@ -32,7 +32,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
geminiResult,
opencodeGoResult,
kimiResult,
miniMaxResult
miniMaxResult,
antigravityResult
],
grokResultPromise
} = prepared
Expand Down Expand Up @@ -79,8 +80,12 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
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: Antigravity owns its own source; a rejected probe must still read as Antigravity-native
// unavailability, never another provider's quota or another provider's failure (#14515, #9122).
const antigravity =
antigravityResult.status === 'fulfilled'
? antigravityResult.value
: buildAntigravityNativeUnavailable()

const opencodeGo =
opencodeGoResult.status === 'fulfilled'
Expand Down
83 changes: 39 additions & 44 deletions src/main/rate-limits/service/service-full-cycle-preparation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fetchAntigravityRateLimits } from '../antigravity-native-usage-source'
import { fetchClaudeRateLimits } from '../claude-fetcher'
import { fetchCodexRateLimits } from '../codex-fetcher'
import { fetchGeminiRateLimits } from '../gemini-usage-fetcher'
Expand Down Expand Up @@ -36,6 +37,7 @@ export type FetchAllCyclePrepared = {
PromiseSettledResult<ProviderRateLimits>,
PromiseSettledResult<ProviderRateLimits>,
PromiseSettledResult<ProviderRateLimits>,
PromiseSettledResult<ProviderRateLimits>,
PromiseSettledResult<ProviderRateLimits>
]
grokResultPromise: Promise<
Expand Down Expand Up @@ -138,42 +140,42 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
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,
endpointMode: miniMaxEndpoint,
apiKey: miniMaxApiKey
})
])
const results = 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,
endpointMode: miniMaxEndpoint,
apiKey: miniMaxApiKey
}),
fetchAntigravityRateLimits()
])

if (signal.aborted) {
return null
Expand All @@ -194,14 +196,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
miniMaxConfigChanged,
miniMaxGeneration,
claudeFetchGated,
results: [
claudeResult,
codexResult,
geminiResult,
opencodeGoResult,
kimiResult,
miniMaxResult
],
results,
grokResultPromise
}
}
Expand Down
Loading
Loading