diff --git a/mobile/src/components/accounts-snapshot.ts b/mobile/src/components/accounts-snapshot.ts index 8baf02136235..1cc5077a263f 100644 --- a/mobile/src/components/accounts-snapshot.ts +++ b/mobile/src/components/accounts-snapshot.ts @@ -31,16 +31,7 @@ const RateLimitResetCreditsSchema = z export const ProviderRateLimitsSchema = z .object({ - provider: z.enum([ - 'claude', - 'codex', - 'gemini', - 'opencode-go', - 'kimi', - 'minimax', - 'grok', - 'antigravity' - ]), + provider: z.enum(['claude', 'codex', 'opencode-go', 'kimi', 'minimax', 'grok', 'antigravity']), session: RateLimitWindowSchema.nullable(), weekly: RateLimitWindowSchema.nullable(), fableWeekly: RateLimitWindowSchema.nullable().optional(), diff --git a/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts b/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts index 6cad595f8665..627e0721ab6d 100644 --- a/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts +++ b/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts @@ -113,7 +113,6 @@ export function createSettings(overrides: TestSettingsOverrides = {}): GlobalSet opencodeWorkspaceId: '', minimaxGroupId: '', minimaxUsageModels: 'general', - geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, confirmClosePinnedTab: true, diff --git a/src/main/codex-accounts/service-reset-credit-test-fixtures.ts b/src/main/codex-accounts/service-reset-credit-test-fixtures.ts index 4578831985aa..fa5d0766fdd5 100644 --- a/src/main/codex-accounts/service-reset-credit-test-fixtures.ts +++ b/src/main/codex-accounts/service-reset-credit-test-fixtures.ts @@ -29,7 +29,6 @@ export function createResetRateLimitState( return { claude: null, codex, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, diff --git a/src/main/codex-accounts/service-test-harness.ts b/src/main/codex-accounts/service-test-harness.ts index adc1f4304dd6..e6352ee07ae1 100644 --- a/src/main/codex-accounts/service-test-harness.ts +++ b/src/main/codex-accounts/service-test-harness.ts @@ -134,7 +134,6 @@ export function createSettings(overrides: Partial = {}): GlobalS opencodeWorkspaceId: '', minimaxGroupId: '', minimaxUsageModels: 'general', - geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, confirmClosePinnedTab: true, diff --git a/src/main/index.ts b/src/main/index.ts index a142fc6cf7db..449c78df07ff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2651,7 +2651,6 @@ void app.whenReady().then(async () => { models: settings.minimaxUsageModels } }) - rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled) rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings()) keybindings = new KeybindingService({ homePath: app.getPath('home'), diff --git a/src/main/persistence-initial-load.test.ts b/src/main/persistence-initial-load.test.ts index e1926a678779..d4ea0841ff9e 100644 --- a/src/main/persistence-initial-load.test.ts +++ b/src/main/persistence-initial-load.test.ts @@ -75,6 +75,17 @@ describe('Store', () => { expect(store.getRepos()).toEqual([]) }, 15_000) + it('removes the deprecated Gemini CLI OAuth usage setting on load', async () => { + writeDataFile({ settings: { geminiCliOAuthEnabled: true } }) + + const store = await createStore() + + expect(store.getSettings()).not.toHaveProperty('geminiCliOAuthEnabled') + store.flush() + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings).not.toHaveProperty('geminiCliOAuthEnabled') + }) + it('clone-reads and synchronously persists the main-owned Codex reset ledger', async () => { const store = await createStore() const ledger = { diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts index 6c04673ca07a..a45aec4061f6 100644 --- a/src/main/persistence/applying-settings/terminal-settings-migrations.ts +++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts @@ -59,6 +59,7 @@ export function readLegacyTerminalScrollbackSettings( type RetiredGlobalSettings = { terminalScrollbackBytes?: unknown enableGitHubAttribution?: unknown + geminiCliOAuthEnabled?: unknown } export function stripRetiredGlobalSettings( @@ -67,10 +68,12 @@ export function stripRetiredGlobalSettings( const { terminalScrollbackBytes: _legacyScrollbackBytes, enableGitHubAttribution: _legacyGitHubAttribution, + geminiCliOAuthEnabled: _deprecatedGeminiCliOAuthEnabled, ...rest } = (settings ?? {}) as Partial & RetiredGlobalSettings void _legacyScrollbackBytes void _legacyGitHubAttribution + void _deprecatedGeminiCliOAuthEnabled return rest } diff --git a/src/main/rate-limits/antigravity-endpoint-selection.ts b/src/main/rate-limits/antigravity-endpoint-selection.ts new file mode 100644 index 000000000000..f8248c172f03 --- /dev/null +++ b/src/main/rate-limits/antigravity-endpoint-selection.ts @@ -0,0 +1,33 @@ +export type AgyQuotaEndpoint = { + pid: number + port: number + csrfToken: string | null +} + +export async function inspectAgyProcessCommands( + pids: Iterable, + inspect: (pid: number) => Promise +): Promise> { + const entries = await Promise.all( + [...pids].map(async (pid) => { + try { + return [pid, await inspect(pid)] as const + } catch { + return null + } + }) + ) + return new Map(entries.filter((entry): entry is readonly [number, string] => entry !== null)) +} + +export function deduplicateAgyQuotaEndpoints(endpoints: AgyQuotaEndpoint[]): AgyQuotaEndpoint[] { + const unique = new Map() + for (const endpoint of endpoints) { + const key = `${endpoint.pid}:${endpoint.port}` + const current = unique.get(key) + if (!current || (!current.csrfToken && endpoint.csrfToken)) { + unique.set(key, endpoint) + } + } + return [...unique.values()] +} diff --git a/src/main/rate-limits/antigravity-quota-parser.ts b/src/main/rate-limits/antigravity-quota-parser.ts new file mode 100644 index 000000000000..ddd947434856 --- /dev/null +++ b/src/main/rate-limits/antigravity-quota-parser.ts @@ -0,0 +1,113 @@ +import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types' + +type AgySummaryBucket = { + bucketId?: unknown + remainingFraction?: unknown + resetTime?: unknown +} + +type AgySummaryGroup = { + buckets?: unknown +} + +type AgySummaryResponse = { + response?: { + groups?: unknown + } +} + +export type AgyQuotaWindows = { + session: RateLimitWindow | null + weekly: RateLimitWindow | null + buckets: RateLimitBucket[] +} + +const AGY_BUCKET_SPECS = { + 'gemini-5h': { name: 'Gemini 5h', windowMinutes: 300 }, + 'gemini-weekly': { name: 'Gemini wk', windowMinutes: 10_080 }, + '3p-5h': { name: 'Claude/GPT 5h', windowMinutes: 300 }, + '3p-weekly': { name: 'Claude/GPT wk', windowMinutes: 10_080 } +} as const + +const AGY_BUCKET_ORDER = ['gemini-5h', 'gemini-weekly', '3p-5h', '3p-weekly'] as const + +type AgyBucketId = (typeof AGY_BUCKET_ORDER)[number] + +function isSummaryBucket(value: unknown): value is AgySummaryBucket & { bucketId: AgyBucketId } { + if (!value || typeof value !== 'object') { + return false + } + const bucket = value as AgySummaryBucket + return ( + typeof bucket.bucketId === 'string' && + Object.hasOwn(AGY_BUCKET_SPECS, bucket.bucketId) && + typeof bucket.remainingFraction === 'number' && + Number.isFinite(bucket.remainingFraction) && + (bucket.resetTime === undefined || typeof bucket.resetTime === 'string') + ) +} + +function toBucket(bucket: AgySummaryBucket & { bucketId: AgyBucketId }): RateLimitBucket { + const spec = AGY_BUCKET_SPECS[bucket.bucketId] + const remainingFraction = Math.min(1, Math.max(0, bucket.remainingFraction as number)) + const resetsAt = typeof bucket.resetTime === 'string' ? Date.parse(bucket.resetTime) : Number.NaN + return { + name: spec.name, + usedPercent: (1 - remainingFraction) * 100, + windowMinutes: spec.windowMinutes, + resetsAt: Number.isFinite(resetsAt) ? resetsAt : null, + resetDescription: null + } +} + +function mostConstrainedWindow( + buckets: RateLimitBucket[], + windowMinutes: number +): RateLimitWindow | null { + const matching = buckets.filter((bucket) => bucket.windowMinutes === windowMinutes) + if (matching.length === 0) { + return null + } + const mostConstrained = matching.reduce((current, candidate) => + candidate.usedPercent > current.usedPercent ? candidate : current + ) + const { name: _name, ...window } = mostConstrained + return window +} + +export function parseAgyQuotaSummary(data: unknown): AgyQuotaWindows | null { + const groups = (data as AgySummaryResponse | null)?.response?.groups + if (!Array.isArray(groups)) { + return null + } + const byId = new Map() + for (const group of groups) { + const rawBuckets = (group as AgySummaryGroup | null)?.buckets + if (!Array.isArray(rawBuckets)) { + continue + } + for (const rawBucket of rawBuckets) { + if (!isSummaryBucket(rawBucket)) { + continue + } + const current = byId.get(rawBucket.bucketId) + if ( + !current || + (rawBucket.remainingFraction as number) < (current.remainingFraction as number) + ) { + byId.set(rawBucket.bucketId, rawBucket) + } + } + } + const buckets = AGY_BUCKET_ORDER.map((id) => byId.get(id)) + .filter((bucket): bucket is AgySummaryBucket & { bucketId: AgyBucketId } => Boolean(bucket)) + .map(toBucket) + if (buckets.length === 0) { + return null + } + return { + session: mostConstrainedWindow(buckets, 300), + weekly: mostConstrainedWindow(buckets, 10_080), + buckets + } +} 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..f68e859c7547 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) + +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) + +import { + collectLiveAgyLogEndpoints, + fetchAntigravityRateLimits, + parseAgyLogEndpoint, + parseAgyQuotaSummary, + parseCsrfToken, + parseLsofListeners +} from './antigravity-usage-fetcher' +import { + deduplicateAgyQuotaEndpoints, + inspectAgyProcessCommands +} from './antigravity-endpoint-selection' + +function response(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: vi.fn().mockResolvedValue(body) + } as unknown as Response +} + +const quotaSummary = { + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-weekly', + window: 'weekly', + remainingFraction: 0.9873386, + resetTime: '2026-08-06T04:21:42Z' + }, + { + bucketId: 'gemini-5h', + window: '5h', + remainingFraction: 0.9240316, + resetTime: '2026-07-30T12:25:31Z' + } + ] + }, + { + displayName: 'Claude and GPT models', + buckets: [ + { bucketId: '3p-weekly', window: 'weekly', remainingFraction: 1 }, + { bucketId: '3p-5h', window: '5h', remainingFraction: 1 } + ] + } + ] + } +} + +describe('Agy endpoint discovery parsers', () => { + it('reads the live HTTP endpoint from an Agy CLI log', () => { + expect( + parseAgyLogEndpoint(` +I0730 16:55:25.550727 27262 server.go:1424] Starting language server process with pid 27262 +I0730 16:55:25.554099 27262 server.go:568] Language server listening on random port at 50590 for HTTP +`) + ).toEqual({ pid: 27262, port: 50590, csrfToken: null }) + }) + + it('reads Agy and desktop language-server listeners from lsof fields', () => { + expect( + parseLsofListeners('p27262\ncagy\nn127.0.0.1:50590\np27451\nclanguage_\nn127.0.0.1:50610\n') + ).toEqual([ + { pid: 27262, processName: 'agy', port: 50590 }, + { pid: 27451, processName: 'language_', port: 50610 } + ]) + }) + + it('reads both supported CSRF argument forms', () => { + expect(parseCsrfToken('language_server --csrf_token abc-123 --standalone')).toBe('abc-123') + expect(parseCsrfToken('language_server --csrf_token=def-456')).toBe('def-456') + }) + + it('keeps valid process commands when another process exits during inspection', async () => { + const inspect = vi.fn(async (pid: number) => { + if (pid === 27451) { + throw new Error('process exited') + } + return `agy --pid ${pid}` + }) + + await expect(inspectAgyProcessCommands([27262, 27451], inspect)).resolves.toEqual( + new Map([[27262, 'agy --pid 27262']]) + ) + }) + + it('prefers a CSRF-authenticated duplicate endpoint', () => { + expect( + deduplicateAgyQuotaEndpoints([ + { pid: 27451, port: 50610, csrfToken: null }, + { pid: 27451, port: 50610, csrfToken: 'csrf-token' } + ]) + ).toEqual([{ pid: 27451, port: 50610, csrfToken: 'csrf-token' }]) + }) + + it('keeps valid log endpoints when another selected log disappears', async () => { + const readLog = vi.fn(async (name: string) => { + if (name === 'removed.log') { + throw new Error('ENOENT') + } + return ` +I0730 16:55:25.550727 27262 server.go:1424] Starting language server process with pid 27262 +I0730 16:55:25.554099 27262 server.go:568] Language server listening on random port at 50590 for HTTP +` + }) + + await expect( + collectLiveAgyLogEndpoints(['removed.log', 'active.log'], readLog, () => true) + ).resolves.toEqual([{ pid: 27262, port: 50590, csrfToken: null }]) + }) +}) + +describe('Agy quota summary', () => { + beforeEach(() => { + netFetchMock.mockReset() + }) + + it('preserves both model groups and derives the tightest five-hour and weekly windows', () => { + const windows = parseAgyQuotaSummary(quotaSummary) + + expect(windows?.session?.windowMinutes).toBe(300) + expect(windows?.session?.usedPercent).toBeCloseTo(7.59684) + expect(windows?.session?.resetsAt).toBe(Date.parse('2026-07-30T12:25:31Z')) + expect(windows?.weekly?.windowMinutes).toBe(10_080) + expect(windows?.weekly?.usedPercent).toBeCloseTo(1.26614) + expect(windows?.buckets.map((bucket) => [bucket.name, bucket.usedPercent])).toEqual([ + ['Gemini 5h', expect.closeTo(7.59684)], + ['Gemini wk', expect.closeTo(1.26614)], + ['Claude/GPT 5h', 0], + ['Claude/GPT wk', 0] + ]) + }) + + it('does not synthesize fixed windows from unrelated model buckets', () => { + expect(parseAgyQuotaSummary({ response: { groups: [] } })).toBeNull() + }) + + it('fetches the live Agy summary with its CSRF token', async () => { + netFetchMock.mockResolvedValue(response(quotaSummary)) + + const limits = await fetchAntigravityRateLimits(async () => [ + { pid: 27451, port: 50610, csrfToken: 'csrf-token' } + ]) + + expect(limits.status).toBe('ok') + expect(limits.session?.windowMinutes).toBe(300) + expect(limits.weekly?.windowMinutes).toBe(10_080) + expect(limits.buckets).toHaveLength(4) + expect(netFetchMock).toHaveBeenCalledWith( + expect.stringContaining('RetrieveUserQuotaSummary'), + expect.objectContaining({ + headers: expect.objectContaining({ 'x-codeium-csrf-token': 'csrf-token' }) + }) + ) + }) + + it('tries the next Agy listener when the adjacent HTTPS port rejects HTTP', async () => { + netFetchMock + .mockRejectedValueOnce(new Error('Client sent an HTTP request to an HTTPS server')) + .mockResolvedValueOnce(response(quotaSummary)) + + const limits = await fetchAntigravityRateLimits(async () => [ + { pid: 27262, port: 50589, csrfToken: null }, + { pid: 27262, port: 50590, csrfToken: null } + ]) + + expect(limits.status).toBe('ok') + expect(netFetchMock).toHaveBeenCalledTimes(2) + }) + + it('reports unavailable when no Agy runtime is active', async () => { + const limits = await fetchAntigravityRateLimits(async () => []) + + expect(limits.status).toBe('unavailable') + expect(limits.error).toContain('not running') + expect(netFetchMock).not.toHaveBeenCalled() + }) +}) 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..40558a6fd093 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -0,0 +1,233 @@ +import { open, readdir } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' +import { net } from 'electron' +import { runProcess } from '../../shared/child-process/run-process' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { + deduplicateAgyQuotaEndpoints, + inspectAgyProcessCommands, + type AgyQuotaEndpoint +} from './antigravity-endpoint-selection' +import { parseAgyQuotaSummary, type AgyQuotaWindows } from './antigravity-quota-parser' + +export { parseAgyQuotaSummary } from './antigravity-quota-parser' + +const COMMAND_TIMEOUT_MS = 4_000 +const FETCH_TIMEOUT_MS = 5_000 +const LOG_PREFIX_BYTES = 16 * 1024 +const SUMMARY_PATH = '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' +const AGY_LOG_DIR = path.join(homedir(), '.gemini', 'antigravity-cli', 'log') + +type LsofListener = { + pid: number + processName: string + port: number +} + +function result( + status: ProviderRateLimits['status'], + error: string | null, + windows?: AgyQuotaWindows +): ProviderRateLimits { + return { + provider: 'antigravity', + session: windows?.session ?? null, + weekly: windows?.weekly ?? null, + ...(windows ? { buckets: windows.buckets } : {}), + updatedAt: Date.now(), + error, + status, + usageMetadata: { + source: 'live-session', + credentialSource: 'agy-local-service', + authProvenance: 'antigravity', + ...(status === 'unavailable' ? { failureKind: 'cli-unavailable' as const } : {}) + } + } +} + +async function runCommand(command: string, args: string[]): Promise { + const commandResult = await runProcess({ + program: command, + args, + timeoutMs: COMMAND_TIMEOUT_MS, + maxOutputBytes: 2 * 1024 * 1024 + }) + if (commandResult.code !== 0) { + throw new Error(commandResult.stderr || `${command} exited without success`) + } + return commandResult.stdout +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EPERM' + ) + } +} + +async function readFilePrefix(filePath: string): Promise { + const handle = await open(filePath, 'r') + try { + const buffer = Buffer.alloc(LOG_PREFIX_BYTES) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + return buffer.subarray(0, bytesRead).toString('utf8') + } finally { + await handle.close() + } +} + +export function parseAgyLogEndpoint(log: string): AgyQuotaEndpoint | null { + const pid = Number.parseInt( + log.match(/Starting language server process with pid\s+(\d+)/)?.[1] ?? '', + 10 + ) + const port = Number.parseInt( + log.match(/Language server listening on (?:random|fixed) port at\s+(\d+)\s+for HTTP\b/)?.[1] ?? + '', + 10 + ) + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port) || port < 1 || port > 65_535) { + return null + } + return { pid, port, csrfToken: null } +} + +export function parseLsofListeners(output: string): LsofListener[] { + const listeners: LsofListener[] = [] + let pid: number | null = null + let processName = '' + for (const line of output.split('\n')) { + if (line.startsWith('p')) { + pid = Number.parseInt(line.slice(1), 10) + processName = '' + continue + } + if (line.startsWith('c')) { + processName = line.slice(1) + continue + } + if (!line.startsWith('n') || !pid || !processName) { + continue + } + const port = Number.parseInt(line.match(/:(\d+)(?:\s+\(LISTEN\))?$/)?.[1] ?? '', 10) + if (Number.isInteger(port) && port > 0 && port <= 65_535) { + listeners.push({ pid, processName, port }) + } + } + return listeners +} + +export function parseCsrfToken(commandLine: string): string | null { + return commandLine.match(/(?:^|\s)--csrf_token(?:=|\s+)([^\s]+)/)?.[1] ?? null +} + +export async function collectLiveAgyLogEndpoints( + names: string[], + readLog: (name: string) => Promise, + processAlive: (pid: number) => boolean = isProcessAlive +): Promise { + const endpoints: AgyQuotaEndpoint[] = [] + for (const name of names) { + try { + const endpoint = parseAgyLogEndpoint(await readLog(name)) + if (endpoint && processAlive(endpoint.pid)) { + endpoints.push(endpoint) + } + } catch { + continue + } + } + return endpoints +} + +async function discoverLogEndpoints(): Promise { + try { + const names = (await readdir(AGY_LOG_DIR)) + .filter((name) => /^cli-\d{8}_\d{6}\.log$/.test(name)) + .sort() + .toReversed() + .slice(0, 8) + return collectLiveAgyLogEndpoints(names, (name) => readFilePrefix(path.join(AGY_LOG_DIR, name))) + } catch { + return [] + } +} + +async function discoverLsofEndpoints(): Promise { + if (process.platform === 'win32') { + return [] + } + try { + const listeners = parseLsofListeners( + await runCommand('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn']) + ).filter(({ processName }) => /^(agy|language_?(?:server)?)$/i.test(processName)) + const commandLines = await inspectAgyProcessCommands( + new Set(listeners.map((listener) => listener.pid)), + (pid) => runCommand('ps', ['-p', String(pid), '-o', 'command=']) + ) + return listeners.flatMap(({ pid, processName, port }) => { + const commandLine = commandLines.get(pid) ?? '' + if (/^agy$/i.test(processName)) { + return [{ pid, port, csrfToken: null }] + } + if ( + !commandLine.includes('language_server') || + !commandLine.toLowerCase().includes('antigravity') + ) { + return [] + } + const csrfToken = parseCsrfToken(commandLine) + return csrfToken ? [{ pid, port, csrfToken }] : [] + }) + } catch { + return [] + } +} + +export async function discoverAgyQuotaEndpoints(): Promise { + const endpoints = [...(await discoverLogEndpoints()), ...(await discoverLsofEndpoints())] + return deduplicateAgyQuotaEndpoints(endpoints) +} + +async function fetchSummary(endpoint: AgyQuotaEndpoint): Promise { + const response = await net.fetch(`http://127.0.0.1:${endpoint.port}${SUMMARY_PATH}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(endpoint.csrfToken ? { 'x-codeium-csrf-token': endpoint.csrfToken } : {}) + }, + body: '{}', + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + return response.ok ? await response.json() : null +} + +export async function fetchAntigravityRateLimits( + discoverEndpoints: () => Promise = discoverAgyQuotaEndpoints +): Promise { + try { + const endpoints = await discoverEndpoints() + if (endpoints.length === 0) { + return result('unavailable', 'Agy local usage service is not running') + } + for (const endpoint of endpoints) { + try { + const windows = parseAgyQuotaSummary(await fetchSummary(endpoint)) + if (windows) { + return result('ok', null, windows) + } + } catch { + // Agy exposes adjacent HTTPS and HTTP listeners; only the HTTP endpoint answers here. + } + } + return result('error', 'Agy model quota summary is unavailable') + } catch (error) { + return result('error', error instanceof Error ? error.message : 'Unknown Agy usage error') + } +} diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts deleted file mode 100644 index 84c92e1ad22f..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 3cea6decc111..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/gemini-bucket-formatting.test.ts b/src/main/rate-limits/gemini-bucket-formatting.test.ts deleted file mode 100644 index b806fbef9024..000000000000 --- a/src/main/rate-limits/gemini-bucket-formatting.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { RateLimitBucket } from '../../shared/rate-limit-types' -import { getBucketName, deriveSessionSummary } from './gemini-bucket-formatting' - -describe('getBucketName', () => { - it('maps known model IDs to stable names', () => { - expect(getBucketName('gemini-2.5-pro')).toBe('Pro') - expect(getBucketName('gemini-2.5-flash')).toBe('Flash') - expect(getBucketName('gemini-2.5-flash-lite')).toBe('Flash Lite') - expect(getBucketName('gemini-2.0-flash-lite')).toBe('2.0 Flash Lite') - expect(getBucketName('gemini-2.0-flash')).toBe('2.0 Flash') - }) - - it('humanizes unknown model IDs by stripping the gemini- prefix', () => { - expect(getBucketName('gemini-3.0-ultra')).toBe('3.0 Ultra') - expect(getBucketName('gemini-experimental')).toBe('Exp') - expect(getBucketName('some-random-id')).toBe('Some Random Id') - }) -}) - -describe('deriveSessionSummary', () => { - it('returns null for empty buckets', () => { - expect(deriveSessionSummary([])).toBeNull() - }) - - it('picks the most constrained bucket (highest usedPercent) as session summary', () => { - const buckets: RateLimitBucket[] = [ - { name: 'Pro', usedPercent: 30, windowMinutes: 60, resetsAt: null, resetDescription: null }, - { name: 'Flash', usedPercent: 80, windowMinutes: 60, resetsAt: null, resetDescription: null }, - { - name: 'Flash Lite', - usedPercent: 10, - windowMinutes: 60, - resetsAt: null, - resetDescription: null - } - ] - const summary = deriveSessionSummary(buckets) - expect(summary).not.toBeNull() - expect(summary!.usedPercent).toBe(80) - expect(summary!.windowMinutes).toBe(60) - }) - - it('preserves reset metadata from the most constrained bucket', () => { - const buckets: RateLimitBucket[] = [ - { - name: 'Pro', - usedPercent: 30, - windowMinutes: 60, - resetsAt: 1000, - resetDescription: '2:00 PM' - }, - { - name: 'Flash', - usedPercent: 80, - windowMinutes: 60, - resetsAt: 2000, - resetDescription: '3:00 PM' - } - ] - const summary = deriveSessionSummary(buckets) - expect(summary!.resetsAt).toBe(2000) - expect(summary!.resetDescription).toBe('3:00 PM') - }) -}) diff --git a/src/main/rate-limits/gemini-bucket-formatting.ts b/src/main/rate-limits/gemini-bucket-formatting.ts deleted file mode 100644 index 3d58db808b73..000000000000 --- a/src/main/rate-limits/gemini-bucket-formatting.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types' - -const MODEL_ID_TO_BUCKET_NAME: Record = { - 'gemini-3.1-pro': '3.1 Pro', - 'gemini-3.1-flash': '3.1 Flash', - 'gemini-3.1-flash-lite': '3.1 Flash Lite', - 'gemini-3.0-pro': '3.0 Pro', - 'gemini-3.0-flash': '3.0 Flash', - 'gemini-2.5-pro': 'Pro', - 'gemini-2.5-flash': 'Flash', - 'gemini-2.5-flash-lite': 'Flash Lite', - 'gemini-2.0-pro': '2.0 Pro', - 'gemini-2.0-flash': '2.0 Flash', - 'gemini-2.0-flash-lite': '2.0 Flash Lite', - 'gemini-1.5-pro': '1.5 Pro', - 'gemini-1.5-flash': '1.5 Flash', - 'gemini-exp': 'Exp', - 'gemini-experimental': 'Exp' -} - -function humanizeModelId(modelId: string): string { - const withoutPrefix = modelId.replace(/^gemini-/i, '') - return withoutPrefix - .split('-') - .map((part) => (part.length > 0 ? part[0]!.toUpperCase() + part.slice(1) : part)) - .join(' ') -} - -export function getBucketName(modelId: string): string { - return MODEL_ID_TO_BUCKET_NAME[modelId] ?? humanizeModelId(modelId) -} - -export function buildRateLimitBucket(b: { - remainingFraction: number - resetTime: string - modelId: string -}): RateLimitBucket { - const usedPercent = Math.min(100, Math.max(0, Math.round((1 - b.remainingFraction) * 100))) - const resetsAtTime = new Date(b.resetTime).getTime() - return { - name: getBucketName(b.modelId), - usedPercent, - windowMinutes: 60, - resetsAt: !Number.isNaN(resetsAtTime) ? resetsAtTime : null, - resetDescription: null - } -} - -export function deduplicateBuckets( - buckets: (RateLimitBucket & { modelId: string })[] -): RateLimitBucket[] { - const result: (RateLimitBucket & { modelId: string })[] = [] - const seenKeys = new Map() - for (const b of buckets) { - const key = `${b.usedPercent}-${b.resetsAt}` - const existingIndex = seenKeys.get(key) - if (existingIndex === undefined) { - seenKeys.set(key, result.length) - result.push(b) - continue - } - const existing = result[existingIndex]! - const existingInMap = existing.modelId in MODEL_ID_TO_BUCKET_NAME - const currentInMap = b.modelId in MODEL_ID_TO_BUCKET_NAME - if ( - (currentInMap && !existingInMap) || - (currentInMap === existingInMap && b.name.length < existing.name.length) - ) { - result[existingIndex] = b - } - } - return result.map(({ modelId: _id, ...rest }) => rest) -} - -export function deriveSessionSummary(buckets: RateLimitBucket[]): RateLimitWindow | null { - if (buckets.length === 0) { - return null - } - const mostConstrained = buckets.reduce((worst, bucket) => { - return bucket.usedPercent > worst.usedPercent ? bucket : worst - }) - const { name: _name, ...window } = mostConstrained - return window -} diff --git a/src/main/rate-limits/gemini-cli-oauth-extractor.ts b/src/main/rate-limits/gemini-cli-oauth-extractor.ts deleted file mode 100644 index 7b5d5e7e3f37..000000000000 --- a/src/main/rate-limits/gemini-cli-oauth-extractor.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { execFile } from 'node:child_process' -import { access, readdir, readFile, realpath } from 'node:fs/promises' -import { promisify } from 'node:util' -import { homedir } from 'node:os' -import path from 'node:path' - -const execFileAsync = promisify(execFile) - -async function fileExists(filePath: string): Promise { - try { - await access(filePath) - return true - } catch { - return false - } -} - -// The oauth2.js relative path inside a @google/gemini-cli-core package. -const OAUTH2_SUBPATH = path.join('dist', 'src', 'code_assist', 'oauth2.js') - -async function resolveGeminiBinary(): Promise { - const [lookup, args] = - process.platform === 'win32' ? ['where.exe', ['gemini']] : ['which', ['gemini']] - try { - // execFile, not exec: `exec` implies `shell: true`, which silently makes - // windowsHide a no-op (see run-process.ts, #14543), so the console-subsystem - // `where` would still flash a conhost (#10488). - const { stdout } = await execFileAsync(lookup, args, { - encoding: 'utf-8', - windowsHide: true - }) - const fromPath = stdout.trim().split(/\r?\n/)[0] - if (fromPath && (await fileExists(fromPath))) { - return fromPath - } - } catch { - // ignore which/where failure - } - - // Why: on macOS/Linux GUI apps, the PATH might not include the binary. - // Checking common installation prefixes as fallbacks. - if (process.platform !== 'win32') { - const fallbacks = [ - '/usr/local/bin/gemini', - '/opt/homebrew/bin/gemini', - path.join(homedir(), '.local', 'bin', 'gemini'), - path.join(homedir(), 'bin', 'gemini') - ] - for (const candidate of fallbacks) { - if (await fileExists(candidate)) { - return candidate - } - } - } - - return null -} - -// Why: on all platforms the gemini binary may be a symlink (e.g. Homebrew's bin/ -// symlinks into Cellar). We must resolve it before deriving sibling paths — otherwise -// dirname points to the symlink directory, not the real installation root. -async function resolveSymlink(filePath: string): Promise { - try { - return await realpath(filePath) - } catch { - return filePath - } -} - -function parseOAuthCredentials(content: string): { clientId: string; clientSecret: string } | null { - const idMatch = content.match(/OAUTH_CLIENT_ID\s*=\s*['"]([^'"]+)['"]/)?.[1] - const secretMatch = content.match(/OAUTH_CLIENT_SECRET\s*=\s*['"]([^'"]+)['"]/)?.[1] - if (idMatch && secretMatch) { - return { clientId: idMatch, clientSecret: secretMatch } - } - return null -} - -async function tryReadCredentials( - filePath: string -): Promise<{ clientId: string; clientSecret: string } | null> { - try { - const content = await readFile(filePath, 'utf-8') - return parseOAuthCredentials(content) - } catch { - return null - } -} - -// Why: these are the known stable layouts for every major Gemini CLI install method. -// Checking explicit paths is fast and avoids walking the entire directory tree. -async function extractFromKnownPaths( - realGeminiPath: string -): Promise<{ clientId: string; clientSecret: string } | null> { - const binDir = path.dirname(realGeminiPath) - const baseDir = path.dirname(binDir) - - const candidates = [ - // Homebrew: bin -> Cellar//bin, real files live under libexec/lib - path.join( - baseDir, - 'libexec', - 'lib', - 'node_modules', - '@google', - 'gemini-cli', - 'node_modules', - '@google', - 'gemini-cli-core', - OAUTH2_SUBPATH - ), - // Homebrew alternate (some versions skip the extra nesting) - path.join( - baseDir, - 'lib', - 'node_modules', - '@google', - 'gemini-cli', - 'node_modules', - '@google', - 'gemini-cli-core', - OAUTH2_SUBPATH - ), - // Nix package layout - path.join( - baseDir, - 'share', - 'gemini-cli', - 'node_modules', - '@google', - 'gemini-cli-core', - OAUTH2_SUBPATH - ), - // npm/bun global install: gemini-cli-core is a sibling of gemini-cli - path.join(baseDir, '..', 'gemini-cli-core', OAUTH2_SUBPATH), - // npm nested inside gemini-cli - path.join(baseDir, 'node_modules', '@google', 'gemini-cli-core', OAUTH2_SUBPATH) - ] - - for (const candidate of candidates) { - const creds = await tryReadCredentials(path.normalize(candidate)) - if (creds) { - return creds - } - } - - return null -} - -// Why: newer Gemini CLI versions (>=0.38) ship everything bundled into hash-named -// chunks with no oauth2.js source file. Scanning the bundle dir for the credential -// constants is the only reliable fallback for those installs. -async function extractFromBundleDir( - geminiCliPackageRoot: string -): Promise<{ clientId: string; clientSecret: string } | null> { - const bundleDir = path.join(geminiCliPackageRoot, 'bundle') - if (!(await fileExists(bundleDir))) { - return null - } - - let entries: string[] - try { - entries = (await readdir(bundleDir)).filter((f) => f.endsWith('.js')) - } catch { - return null - } - - for (const entry of entries) { - const creds = await tryReadCredentials(path.join(bundleDir, entry)) - if (creds) { - return creds - } - } - - return null -} - -// Resolves the gemini-cli package root directory by walking up the directory -// tree from the real binary path, looking for package.json with the right name, -// or the global Node layout under lib/node_modules. -async function findGeminiPackageRoot(realGeminiPath: string): Promise { - const MAX_ASCENTS = 8 - let current = path.dirname(realGeminiPath) - - for (let i = 0; i <= MAX_ASCENTS; i++) { - const pkgJson = path.join(current, 'package.json') - if (await fileExists(pkgJson)) { - try { - const raw = await readFile(pkgJson, 'utf-8') - const pkg = JSON.parse(raw) as { name?: string } - if (pkg.name === '@google/gemini-cli') { - return current - } - } catch { - // malformed package.json — keep walking - } - } - - // Global Node layout: /lib/node_modules/@google/gemini-cli - const globalPkg = path.join( - current, - 'lib', - 'node_modules', - '@google', - 'gemini-cli', - 'package.json' - ) - if (await fileExists(globalPkg)) { - return path.join(current, 'lib', 'node_modules', '@google', 'gemini-cli') - } - - // Windows global install layout: /node_modules/@google/gemini-cli - const windowsGlobalPkg = path.join( - current, - 'node_modules', - '@google', - 'gemini-cli', - 'package.json' - ) - if (await fileExists(windowsGlobalPkg)) { - return path.join(current, 'node_modules', '@google', 'gemini-cli') - } - - const parent = path.dirname(current) - if (parent === current) { - break - } - current = parent - } - - return null -} - -export async function extractOAuthClientCredentials(): Promise<{ - clientId: string - clientSecret: string -} | null> { - const geminiPath = await resolveGeminiBinary() - if (!geminiPath) { - return null - } - - const realPath = await resolveSymlink(geminiPath) - - // 1. Known static paths (fast, covers most installs with source layout) - const fromKnown = await extractFromKnownPaths(realPath) - if (fromKnown) { - return fromKnown - } - - // 2. Walk up to find the package root, then try source layout + bundle dir - const packageRoot = await findGeminiPackageRoot(realPath) - if (packageRoot) { - const fromSource = - (await tryReadCredentials( - path.join(packageRoot, 'node_modules', '@google', 'gemini-cli-core', OAUTH2_SUBPATH) - )) ?? (await tryReadCredentials(path.join(packageRoot, OAUTH2_SUBPATH))) - if (fromSource) { - return fromSource - } - - const fromBundle = await extractFromBundleDir(packageRoot) - if (fromBundle) { - return fromBundle - } - } - - return null -} diff --git a/src/main/rate-limits/gemini-oauth-sources.ts b/src/main/rate-limits/gemini-oauth-sources.ts deleted file mode 100644 index 386c8b281f3a..000000000000 --- a/src/main/rate-limits/gemini-oauth-sources.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { readFile, writeFile, rename } from 'node:fs/promises' -import { homedir } from 'node:os' -import path from 'node:path' -import { net } from 'electron' -import { extractOAuthClientCredentials } from './gemini-cli-oauth-extractor' - -const API_TIMEOUT_MS = 10_000 -const OAUTH_CREDS_PATH = path.join(homedir(), '.gemini', 'oauth_creds.json') -const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token' -const LOAD_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist' - -export type GeminiCredentials = { - access_token: string - refresh_token: string - expiry_date: number -} - -export type GoogleAuthEntry = { - type: 'oauth' - access: string - expires: number - refresh: string -} - -type AuthJson = { - google?: GoogleAuthEntry - 'opencode-go'?: { type: 'api'; key: string } -} - -export async function readAuthJson(): Promise { - const candidates = [ - process.env.APPDATA ? path.join(process.env.APPDATA, 'opencode', 'auth.json') : null, - process.env.XDG_DATA_HOME - ? path.join(process.env.XDG_DATA_HOME, 'opencode', 'auth.json') - : null, - path.join(homedir(), '.local', 'share', 'opencode', 'auth.json'), - path.join(homedir(), 'Library', 'Application Support', 'opencode', 'auth.json') - ].filter((candidate): candidate is string => candidate !== null) - - for (const candidate of candidates) { - try { - const raw = await readFile(candidate, 'utf-8') - return JSON.parse(raw) as AuthJson - } catch (err) { - if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { - continue - } - throw err - } - } - - return null -} - -export async function readGeminiCredentials(): Promise { - try { - const raw = await readFile(OAUTH_CREDS_PATH, 'utf-8') - const parsed = JSON.parse(raw) as unknown - if ( - parsed && - typeof parsed === 'object' && - 'access_token' in parsed && - typeof parsed.access_token === 'string' && - 'refresh_token' in parsed && - typeof parsed.refresh_token === 'string' && - 'expiry_date' in parsed && - typeof parsed.expiry_date === 'number' - ) { - return parsed as GeminiCredentials - } - return null - } catch (err) { - if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { - return null - } - throw err - } -} - -export async function saveGeminiCredentials(creds: GeminiCredentials): Promise { - const tmpPath = `${OAUTH_CREDS_PATH}.${process.pid}.tmp` - await writeFile(tmpPath, JSON.stringify(creds, null, 2), 'utf-8') - await rename(tmpPath, OAUTH_CREDS_PATH) -} - -export type RefreshTokenResult = { - accessToken: string | null - newRefreshToken: string | null - expiresIn?: number -} - -export async function refreshAccessToken( - refreshToken: string, - clientId: string, - clientSecret: string -): Promise { - const res = await net.fetch(GOOGLE_TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - grant_type: 'refresh_token' - }).toString(), - signal: AbortSignal.timeout(API_TIMEOUT_MS) - }) - - if (!res.ok) { - return { accessToken: null, newRefreshToken: null } - } - - const data = (await res.json()) as { - access_token?: string - refresh_token?: string - expires_in?: number - } - return { - accessToken: typeof data.access_token === 'string' ? data.access_token : null, - newRefreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : null, - expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined - } -} - -export async function loadProjectId(accessToken: string): Promise { - const res = await net.fetch(LOAD_CODE_ASSIST_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}` - }, - body: JSON.stringify({ metadata: { ideType: 'GEMINI_CLI', pluginType: 'GEMINI' } }), - signal: AbortSignal.timeout(API_TIMEOUT_MS) - }) - - if (!res.ok) { - throw new Error(`Failed to load Gemini project ID (HTTP ${res.status})`) - } - - const data = (await res.json()) as { cloudaicompanionProject?: string } - if (typeof data.cloudaicompanionProject !== 'string') { - throw new Error('Gemini project ID not found in API response') - } - return data.cloudaicompanionProject -} - -// Why: accepts a plain refresh token string so both the oauth_creds.json path -// (GeminiCredentials) and the auth.json path (pipe-split string) can share -// the same bundle credential extraction without coupling to either struct. -export async function tryRefreshTokenFromBundle( - refreshToken: string, - allowCliOAuth = true -): Promise { - if (!allowCliOAuth) { - return null - } - const clientCreds = await extractOAuthClientCredentials() - if (!clientCreds) { - return null - } - - return refreshAccessToken(refreshToken, clientCreds.clientId, clientCreds.clientSecret) -} diff --git a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts deleted file mode 100644 index fddd0a77d59f..000000000000 --- a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - expiredCreds, - makeResponse, - quotaResponse, - validCreds -} from './gemini-usage-fetcher.test-fixtures' - -const { readFileMock, extractCredsMock, netFetchMock } = vi.hoisted(() => ({ - readFileMock: vi.fn(), - extractCredsMock: vi.fn(), - netFetchMock: vi.fn() -})) - -// Why: mock the CLI-credential extractor at the module boundary. The extractor -// is a self-contained dependency with a simple async contract (returns a -// { clientId, clientSecret } record or null). Mocking it here keeps these -// tests focused on the oauth_creds.json refresh → loadCodeAssist → quota -// flow rather than on filesystem plumbing, and avoids having to keep pace -// with extractor internals when they change. -vi.mock('./gemini-cli-oauth-extractor', () => ({ - extractOAuthClientCredentials: extractCredsMock -})) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock, - // Why: saveGeminiCredentials is exercised on the refresh path. The atomic - // tmp+rename write has no observable side effect in these tests, so the - // stubs just resolve. - writeFile: vi.fn().mockResolvedValue(undefined), - rename: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('electron', () => ({ - net: { fetch: netFetchMock } -})) - -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' - -describe('fetchGeminiRateLimits fallback oauth creds', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z')) - readFileMock.mockReset() - extractCredsMock.mockReset() - netFetchMock.mockReset() - extractCredsMock.mockResolvedValue({ - clientId: 'client-id-123', - clientSecret: 'client-secret-456' - }) - }) - - it('falls back to oauth_creds.json when auth.json has no google key', async () => { - readFileMock.mockImplementation(async (filePath: string) => { - if (filePath.includes('auth.json')) { - return JSON.stringify({ 'opencode-go': { type: 'api', key: 'k' } }) - } - if (filePath.includes('oauth_creds.json')) { - return JSON.stringify(validCreds) - } - throw { code: 'ENOENT' } - }) - netFetchMock - .mockResolvedValueOnce(makeResponse({ cloudaicompanionProject: 'proj-123' })) - .mockResolvedValueOnce(makeResponse(quotaResponse)) - - const result = await fetchGeminiRateLimits(true) - - expect(result.status).toBe('ok') - expect(result.error).toBeNull() - expect(result.session).not.toBeNull() - }) - - it('falls back to oauth_creds.json and resolves project via loadCodeAssist', async () => { - readFileMock.mockImplementation(async (filePath: string) => { - if (filePath.includes('auth.json')) { - return JSON.stringify({}) - } - if (filePath.includes('oauth_creds.json')) { - return JSON.stringify(validCreds) - } - throw { code: 'ENOENT' } - }) - netFetchMock - .mockResolvedValueOnce(makeResponse({ cloudaicompanionProject: 'cli-proj-456' })) - .mockResolvedValueOnce(makeResponse(quotaResponse)) - - const result = await fetchGeminiRateLimits(true) - - expect(result.status).toBe('ok') - expect(result.error).toBeNull() - expect(result.session).not.toBeNull() - - const quotaCall = netFetchMock.mock.calls.find( - (call) => typeof call[0] === 'string' && call[0].includes('retrieveUserQuota') - ) - expect(quotaCall).toBeDefined() - const quotaBody = JSON.parse((quotaCall![1] as RequestInit).body as string) - expect(quotaBody.project).toBe('cli-proj-456') - }) - - it('refreshes via bundled client credentials when expiry passed', async () => { - readFileMock.mockImplementation(async (filePath: string) => { - if (filePath.includes('auth.json')) { - return JSON.stringify({}) - } - if (filePath.includes('oauth_creds.json')) { - return JSON.stringify(expiredCreds) - } - throw { code: 'ENOENT' } - }) - netFetchMock - .mockResolvedValueOnce( - makeResponse({ access_token: 'bundle-refreshed-token', expires_in: 3600 }) - ) - .mockResolvedValueOnce(makeResponse({ cloudaicompanionProject: 'cli-proj-456' })) - .mockResolvedValueOnce(makeResponse(quotaResponse)) - - const result = await fetchGeminiRateLimits(true) - - expect(result.status).toBe('ok') - expect(result.error).toBeNull() - expect(result.session).not.toBeNull() - - const refreshCall = netFetchMock.mock.calls.find( - (call) => typeof call[0] === 'string' && call[0].includes('oauth2.googleapis.com') - ) - expect(refreshCall).toBeDefined() - const refreshBody = new URLSearchParams((refreshCall![1] as RequestInit).body as string) - expect(refreshBody.get('client_id')).toBe('client-id-123') - expect(refreshBody.get('client_secret')).toBe('client-secret-456') - }) - - it('returns error when oauth_creds.json token expired and bundle refresh fails', async () => { - readFileMock.mockImplementation(async (filePath: string) => { - if (filePath.includes('auth.json')) { - return JSON.stringify({}) - } - if (filePath.includes('oauth_creds.json')) { - return JSON.stringify(expiredCreds) - } - throw { code: 'ENOENT' } - }) - // Simulate: no Gemini CLI installed, so the extractor returns null and - // tryRefreshTokenFromBundle can't obtain client credentials to refresh. - extractCredsMock.mockResolvedValue(null) - - const result = await fetchGeminiRateLimits(true) - - expect(result.status).toBe('error') - expect(result.error).toContain('Token refresh failed') - expect(result.session).toBeNull() - expect(result.weekly).toBeNull() - }) - - it('returns error when loadCodeAssist cannot resolve a project for oauth_creds path', async () => { - // Why: when the fallback (oauth_creds.json) path has no project embedded - // and loadCodeAssist fails, we surface a clear "project ID not found" - // error rather than silently posting an empty project to the quota API — - // an empty project causes a 400 that looks like an auth failure. - readFileMock.mockImplementation(async (filePath: string) => { - if (filePath.includes('auth.json')) { - return JSON.stringify({}) - } - if (filePath.includes('oauth_creds.json')) { - return JSON.stringify(validCreds) - } - throw { code: 'ENOENT' } - }) - netFetchMock.mockResolvedValueOnce(makeResponse('Internal Server Error', 500)) - - const result = await fetchGeminiRateLimits(true) - - expect(result.status).toBe('error') - expect(result.error).toContain('Gemini project ID not found') - }) - - it('returns unavailable without reading OAuth files when geminiCliOAuthEnabled=false', async () => { - const result = await fetchGeminiRateLimits(false) - - expect(result.status).toBe('unavailable') - expect(result.error).toContain('disabled') - expect(readFileMock).not.toHaveBeenCalled() - // No network calls should have been made. - expect(netFetchMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts b/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts deleted file mode 100644 index 9e21d7c81f2a..000000000000 --- a/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts +++ /dev/null @@ -1,43 +0,0 @@ -export function makeResponse(body: unknown, status = 200): Response { - return { - ok: status >= 200 && status < 300, - status, - json: async () => body, - text: async () => (typeof body === 'string' ? body : JSON.stringify(body)) - } as Response -} - -export const authJsonGoogle = { - google: { - type: 'oauth', - access: 'auth-json-access-token', - expires: new Date('2026-04-24T13:00:00.000Z').getTime(), - refresh: 'refresh-token-abc|proj-123|managed-456' - } -} as const - -export const authJsonGoogleExpired = { - google: { - type: 'oauth', - access: 'expired-access-token', - expires: new Date('2026-04-24T11:00:00.000Z').getTime(), - refresh: 'refresh-token-abc|proj-123|managed-456' - } -} as const - -export const validCreds = { - access_token: 'valid-token', - refresh_token: 'refresh-token', - expiry_date: new Date('2026-04-24T13:00:00.000Z').getTime() -} - -export const expiredCreds = { - access_token: 'expired-token', - refresh_token: 'refresh-token', - expiry_date: new Date('2026-04-24T11:00:00.000Z').getTime() -} - -export const quotaResponse = [ - { remainingFraction: 0.75, resetTime: '2026-04-24T13:00:00.000Z', modelId: 'gemini-2.5-pro' }, - { remainingFraction: 0.9, resetTime: '2026-04-24T14:00:00.000Z', modelId: 'gemini-2.5-flash' } -] diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts deleted file mode 100644 index e8415c2e07e9..000000000000 --- a/src/main/rate-limits/gemini-usage-fetcher.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - authJsonGoogle, - authJsonGoogleExpired, - makeResponse, - quotaResponse -} from './gemini-usage-fetcher.test-fixtures' - -const { readFileMock, extractCredsMock, netFetchMock } = vi.hoisted(() => ({ - readFileMock: vi.fn(), - extractCredsMock: vi.fn(), - netFetchMock: vi.fn() -})) - -// Why: mock the extractor at the module boundary rather than re-routing every -// child_process/fs call. The extractor is a self-contained dependency with a -// simple async contract; mocking it directly keeps tests focused on the -// fetcher's refresh/quota logic rather than on filesystem plumbing that has -// already been integration-tested elsewhere. -vi.mock('./gemini-cli-oauth-extractor', () => ({ - extractOAuthClientCredentials: extractCredsMock -})) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock, - writeFile: vi.fn().mockResolvedValue(undefined), - rename: vi.fn().mockResolvedValue(undefined) -})) -vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) - -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' - -describe('fetchGeminiRateLimits', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z')) - readFileMock.mockReset() - extractCredsMock.mockReset() - netFetchMock.mockReset() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - if (url.includes('token')) { - return Promise.resolve(makeResponse({ access_token: 'new-token', expires_in: 3600 })) - } - return Promise.resolve(makeResponse({ error: `Unhandled fetch to ${url}` }, 500)) - }) - // Default: no CLI installed, refresh path cannot find client credentials. - extractCredsMock.mockResolvedValue(null) - readFileMock.mockRejectedValue({ code: 'ENOENT' }) - }) - - const setupAuthJsonValid = () => { - readFileMock.mockImplementation(async (p: string) => { - if (p.includes('auth.json')) { - return JSON.stringify(authJsonGoogle) - } - throw { code: 'ENOENT' } - }) - } - const setupAuthJsonExpired = () => { - readFileMock.mockImplementation(async (p: string) => { - if (p.includes('auth.json')) { - return JSON.stringify(authJsonGoogleExpired) - } - throw { code: 'ENOENT' } - }) - } - - it('returns unavailable when no credentials exist', async () => { - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('unavailable') - }) - - it('returns quota via auth.json', async () => { - setupAuthJsonValid() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve(makeResponse(quotaResponse)) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - expect(result.buckets).toHaveLength(2) - }) - - it('deduplicates buckets', async () => { - setupAuthJsonValid() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve( - makeResponse([ - { - remainingFraction: 0.82, - resetTime: '2026-04-24T13:00:00.000Z', - modelId: 'gemini-1.5-flash' - }, - { - remainingFraction: 0.82, - resetTime: '2026-04-24T13:00:00.000Z', - modelId: 'gemini-3-flash-preview' - } - ]) - ) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - expect(result.buckets).toHaveLength(1) - expect(result.buckets![0].name).toBe('1.5 Flash') - }) - - it('handles empty bucket list', async () => { - setupAuthJsonValid() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve(makeResponse([])) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - expect(result.buckets).toEqual([]) - }) - - it('returns error when token refresh fails', async () => { - vi.useRealTimers() - setupAuthJsonExpired() - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('error') - expect(result.error).toContain('Token refresh failed') - vi.useFakeTimers() - }) - - it('handles wrapped buckets response', async () => { - setupAuthJsonValid() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve(makeResponse({ buckets: quotaResponse })) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - expect(result.session?.usedPercent).toBe(25) - }) - - it('filters out NaN buckets', async () => { - setupAuthJsonValid() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve( - makeResponse([ - { - remainingFraction: Number.NaN, - resetTime: '2026-04-24T13:00:00.000Z', - modelId: 'gemini-1.5-pro' - }, - { - remainingFraction: 0.9, - resetTime: '2026-04-24T13:00:00.000Z', - modelId: 'gemini-1.5-flash' - } - ]) - ) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - expect(result.buckets).toHaveLength(1) - }) - - it('retries refresh on 401', async () => { - setupAuthJsonValid() - extractCredsMock.mockResolvedValue({ clientId: 'cid', clientSecret: 'csec' }) - let quotaCallCount = 0 - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - quotaCallCount += 1 - if (quotaCallCount === 1) { - return Promise.resolve(makeResponse({ error: 'Unauthenticated' }, 401)) - } - return Promise.resolve(makeResponse(quotaResponse)) - } - if (url.includes('token')) { - return Promise.resolve(makeResponse({ access_token: 'retried-token', expires_in: 3600 })) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - // The second quota call should have been made with the refreshed token. - expect(quotaCallCount).toBe(2) - }) -}) diff --git a/src/main/rate-limits/gemini-usage-fetcher.ts b/src/main/rate-limits/gemini-usage-fetcher.ts deleted file mode 100644 index 1f214b4b5eb5..000000000000 --- a/src/main/rate-limits/gemini-usage-fetcher.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { net } from 'electron' -import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { - loadProjectId, - readAuthJson, - readGeminiCredentials, - saveGeminiCredentials, - tryRefreshTokenFromBundle, - type GeminiCredentials, - type GoogleAuthEntry -} from './gemini-oauth-sources' -import { - buildRateLimitBucket, - deduplicateBuckets, - deriveSessionSummary -} from './gemini-bucket-formatting' - -const API_TIMEOUT_MS = 10_000 -const RETRIEVE_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota' - -type QuotaBucket = { remainingFraction: number; resetTime: string; modelId: string } - -function isQuotaBucket(o: unknown): o is QuotaBucket { - return ( - typeof o === 'object' && - o !== null && - typeof (o as QuotaBucket).remainingFraction === 'number' && - Number.isFinite((o as QuotaBucket).remainingFraction) && - typeof (o as QuotaBucket).resetTime === 'string' && - typeof (o as QuotaBucket).modelId === 'string' - ) -} - -function parseQuotaResponse(data: unknown): QuotaBucket[] { - let rawBuckets: unknown[] = [] - if (Array.isArray(data)) { - rawBuckets = data - } else if (data && typeof data === 'object' && 'buckets' in data && Array.isArray(data.buckets)) { - rawBuckets = data.buckets - } - return rawBuckets.filter((b) => isQuotaBucket(b)) -} - -async function fetchQuota(accessToken: string, projectId: string): Promise { - const controller = new AbortController() - const timeout = setTimeout(() => { - controller.abort() - }, API_TIMEOUT_MS) - try { - const res = await net.fetch(RETRIEVE_QUOTA_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, - body: JSON.stringify({ project: projectId }), - signal: controller.signal - }) - if (!res.ok) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: `Quota fetch failed (${res.status})`, - status: 'error' - } - } - const data = (await res.json()) as unknown - const buckets = deduplicateBuckets( - parseQuotaResponse(data).map((b) => ({ ...buildRateLimitBucket(b), modelId: b.modelId })) - ) - return { - provider: 'gemini', - session: deriveSessionSummary(buckets), - weekly: null, - buckets, - updatedAt: Date.now(), - error: null, - status: 'ok' - } - } finally { - clearTimeout(timeout) - } -} - -async function fetchViaAuthJson( - auth: GoogleAuthEntry, - geminiCliOAuthEnabled = false -): Promise { - let accessToken = auth.access - const refreshToken = (auth.refresh || '').split('|')[0] ?? '' - if (auth.expires < Date.now() || !accessToken) { - const refreshResult = await tryRefreshTokenFromBundle(refreshToken, geminiCliOAuthEnabled) - if (!refreshResult?.accessToken) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Token refresh failed', - status: 'error' - } - } - accessToken = refreshResult.accessToken - } - let effectiveProjectId = '' - try { - effectiveProjectId = await loadProjectId(accessToken) - } catch { - effectiveProjectId = - (auth.refresh || '').split('|')[1] || (auth.refresh || '').split('|')[2] || '' - } - if (!effectiveProjectId) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Gemini project ID not found', - status: 'error' - } - } - const result = await fetchQuota(accessToken, effectiveProjectId) - if (result.status === 'error' && result.error?.includes('401')) { - const refreshResult = await tryRefreshTokenFromBundle(refreshToken, geminiCliOAuthEnabled) - if (refreshResult?.accessToken) { - const newProjectId = await loadProjectId(refreshResult.accessToken).catch(() => { - return effectiveProjectId - }) - return fetchQuota(refreshResult.accessToken, newProjectId) - } - } - return result -} - -async function fetchViaOauthCreds( - creds: GeminiCredentials, - geminiCliOAuthEnabled = false -): Promise { - let accessToken = creds.access_token - let currentCreds = creds - if (creds.expiry_date < Date.now()) { - const refreshResult = await tryRefreshTokenFromBundle( - creds.refresh_token, - geminiCliOAuthEnabled - ) - if (!refreshResult?.accessToken) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Token refresh failed', - status: 'error' - } - } - accessToken = refreshResult.accessToken - currentCreds = { - ...creds, - access_token: accessToken, - expiry_date: refreshResult.expiresIn - ? Date.now() + refreshResult.expiresIn * 1000 - : creds.expiry_date - } - await saveGeminiCredentials(currentCreds) - } - const projectId = await loadProjectId(accessToken).catch(() => { - return '' - }) - if (!projectId) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Gemini project ID not found', - status: 'error' - } - } - const result = await fetchQuota(accessToken, projectId) - if (result.status === 'error' && result.error?.includes('401')) { - const refreshResult = await tryRefreshTokenFromBundle( - currentCreds.refresh_token, - geminiCliOAuthEnabled - ) - if (refreshResult?.accessToken) { - const newProjectId = await loadProjectId(refreshResult.accessToken).catch(() => { - return '' - }) - if (newProjectId) { - await saveGeminiCredentials({ - ...currentCreds, - access_token: refreshResult.accessToken, - expiry_date: refreshResult.expiresIn - ? Date.now() + refreshResult.expiresIn * 1000 - : currentCreds.expiry_date - }) - return fetchQuota(refreshResult.accessToken, newProjectId) - } - } - } - return result -} - -export async function fetchGeminiRateLimits( - geminiCliOAuthEnabled = false -): Promise { - if (!geminiCliOAuthEnabled) { - // Why: the OAuth sources include other apps' data folders on macOS. - // Do not touch them during background polling unless the user opts in. - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Gemini CLI OAuth is disabled in settings', - status: 'unavailable' - } - } - - try { - const authJson = await readAuthJson() - const result = - authJson?.google?.type === 'oauth' - ? await fetchViaAuthJson(authJson.google, geminiCliOAuthEnabled) - : await (async () => { - const creds = await readGeminiCredentials() - return !creds - ? ({ - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Gemini CLI credentials not found', - status: 'unavailable' - } as ProviderRateLimits) - : await fetchViaOauthCreds(creds, geminiCliOAuthEnabled) - })() - return result - } catch (err) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - } - } -} 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..3bb05c3a444c 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -2,8 +2,8 @@ import { EventEmitter } from 'node:events' import { vi, type Mock } from 'vitest' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import type { RateLimitService } from './service' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' @@ -84,7 +84,7 @@ export function unavailableProvider( // individual retry lane need healthy providers minted fresh at fetch time. 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)) @@ -94,7 +94,7 @@ export function mockFreshBackgroundProviderFetches(): void { /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ 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..0cf9727ac89a 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -20,8 +20,8 @@ vi.mock('./codex-fetcher', () => ({ fetchCodexRateLimits: vi.fn() })) -vi.mock('./gemini-usage-fetcher', () => ({ - fetchGeminiRateLimits: vi.fn() +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() })) vi.mock('./kimi-fetcher', () => ({ diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts deleted file mode 100644 index b617d9099443..000000000000 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -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-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index e734b52172a4..4e2c35845fea 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -27,8 +27,8 @@ vi.mock('./codex-fetcher', () => ({ fetchCodexRateLimits: vi.fn() })) -vi.mock('./gemini-usage-fetcher', () => ({ - fetchGeminiRateLimits: vi.fn() +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() })) vi.mock('./kimi-fetcher', () => ({ 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..16c7edda3cb8 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -24,8 +24,8 @@ vi.mock('./codex-fetcher', () => ({ fetchCodexRateLimits: vi.fn() })) -vi.mock('./gemini-usage-fetcher', () => ({ - fetchGeminiRateLimits: vi.fn() +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() })) vi.mock('./kimi-fetcher', () => ({ diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 737ca0ff03d1..0c6413d62562 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -21,8 +21,8 @@ vi.mock('./codex-fetcher', () => ({ fetchCodexRateLimits: vi.fn() })) -vi.mock('./gemini-usage-fetcher', () => ({ - fetchGeminiRateLimits: vi.fn() +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() })) vi.mock('./kimi-fetcher', () => ({ diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 7ac7f22f1649..75afab63f0ee 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { RateLimitService } from './service' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' @@ -28,8 +28,8 @@ vi.mock('./codex-fetcher', () => ({ fetchCodexRateLimits: vi.fn() })) -vi.mock('./gemini-usage-fetcher', () => ({ - fetchGeminiRateLimits: vi.fn() +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() })) vi.mock('./kimi-fetcher', () => ({ @@ -111,7 +111,7 @@ describe('RateLimitService', () => { }) expect(fetchClaudeRateLimits).not.toHaveBeenCalled() expect(fetchCodexRateLimits).not.toHaveBeenCalled() - expect(fetchGeminiRateLimits).not.toHaveBeenCalled() + expect(fetchAntigravityRateLimits).not.toHaveBeenCalled() expect(fetchOpenCodeGoRateLimits).not.toHaveBeenCalled() expect(fetchKimiRateLimits).not.toHaveBeenCalled() expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled() @@ -271,7 +271,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', 30, Date.now()) + ) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 40, Date.now()) ) @@ -287,7 +289,7 @@ describe('RateLimitService', () => { const pendingGrokState = service.getState() expect(pendingGrokState.claude?.status).toBe('ok') expect(pendingGrokState.codex?.status).toBe('ok') - expect(pendingGrokState.gemini?.status).toBe('ok') + expect(pendingGrokState.antigravity?.status).toBe('ok') expect(pendingGrokState.opencodeGo?.status).toBe('ok') expect(pendingGrokState.kimi?.status).toBe('ok') expect(pendingGrokState.minimax?.status).toBe('ok') @@ -361,7 +363,7 @@ describe('RateLimitService', () => { expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1) }) - it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => { + it('fetches Antigravity and OpenCode Go alongside Claude and Codex', async () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: 'session=abc123', @@ -372,11 +374,11 @@ describe('RateLimitService', () => { httpProxyBypassRules: 'localhost' } service.setNetworkProxySettingsResolver(() => networkProxySettings) - service.setGeminiCliOAuthEnabledResolver(() => true) - 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', 30, Date.now()) + ) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 40, Date.now()) ) @@ -393,8 +395,8 @@ describe('RateLimitService', () => { }) ) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + expect(fetchAntigravityRateLimits).toHaveBeenCalledWith() expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith( 'session=abc123', @@ -411,8 +413,8 @@ describe('RateLimitService', () => { expect(state.claude?.session?.usedPercent).toBe(10) expect(state.codex?.status).toBe('ok') 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(30) expect(state.opencodeGo?.status).toBe('ok') expect(state.opencodeGo?.session?.usedPercent).toBe(40) }) @@ -447,11 +449,11 @@ describe('RateLimitService', () => { expect(fetchKimiRateLimits).toHaveBeenCalledWith({ home: undefined }) }) - it('preserves Gemini buckets through getState after fetch', async () => { + it('preserves Antigravity buckets through getState after fetch', async () => { const service = new RateLimitService() - const geminiWithBuckets: ProviderRateLimits = { - provider: 'gemini', + const antigravityWithBuckets: ProviderRateLimits = { + provider: 'antigravity', session: { usedPercent: 80, windowMinutes: 300, resetsAt: null, resetDescription: null }, weekly: null, buckets: [ @@ -477,7 +479,7 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(geminiWithBuckets) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValueOnce(antigravityWithBuckets) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 0, Date.now()) ) @@ -485,11 +487,11 @@ describe('RateLimitService', () => { await service.refresh() const state = service.getState() - expect(state.gemini?.buckets).toHaveLength(2) - expect(state.gemini?.buckets![0].name).toBe('Pro') - expect(state.gemini?.buckets![1].name).toBe('Flash') + expect(state.antigravity?.buckets).toHaveLength(2) + expect(state.antigravity?.buckets![0].name).toBe('Pro') + expect(state.antigravity?.buckets![1].name).toBe('Flash') // Why: session summary is derived from bucket data and must match the most constrained bucket. - expect(state.gemini?.session?.usedPercent).toBe(80) + expect(state.antigravity?.session?.usedPercent).toBe(80) }) it('isolates provider failures so one error does not block others', async () => { @@ -501,7 +503,7 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockRejectedValueOnce(new Error('claude down')) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockRejectedValueOnce(new Error('gemini down')) + vi.mocked(fetchAntigravityRateLimits).mockRejectedValueOnce(new Error('antigravity down')) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 40, Date.now()) ) @@ -512,8 +514,8 @@ describe('RateLimitService', () => { expect(state.claude?.status).toBe('error') expect(state.claude?.error).toBe('claude down') expect(state.codex?.status).toBe('ok') - expect(state.gemini?.status).toBe('error') - expect(state.gemini?.error).toBe('gemini down') + expect(state.antigravity?.status).toBe('error') + expect(state.antigravity?.error).toBe('antigravity down') expect(state.opencodeGo?.status).toBe('ok') }) @@ -528,7 +530,9 @@ describe('RateLimitService', () => { // 1. Success fetch vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 30, Date.now()) + ) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue( okProvider('opencode-go', 40, Date.now()) ) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index a4a1dc14f767..95a826e988b3 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { RateLimitService } from './service' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' @@ -29,8 +29,8 @@ vi.mock('./codex-fetcher', () => ({ fetchCodexRateLimits: vi.fn() })) -vi.mock('./gemini-usage-fetcher', () => ({ - fetchGeminiRateLimits: vi.fn() +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() })) vi.mock('./kimi-fetcher', () => ({ @@ -187,7 +187,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1) @@ -545,7 +545,7 @@ describe('RateLimitService', () => { try { vi.mocked(fetchClaudeRateLimits).mockResolvedValue(unavailableProvider('claude')) vi.mocked(fetchCodexRateLimits).mockResolvedValue(unavailableProvider('codex')) - vi.mocked(fetchGeminiRateLimits).mockResolvedValue(unavailableProvider('gemini')) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity')) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(unavailableProvider('opencode-go')) vi.mocked(fetchKimiRateLimits).mockResolvedValue(unavailableProvider('kimi')) @@ -562,7 +562,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) @@ -572,7 +572,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(2) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(2) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index d6f291d6b38e..11d6ae1e450c 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -19,8 +19,7 @@ import { type ClaudeAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' -import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import type { KimiHomeResolution } from '../kimi/kimi-runtime-home' import { fetchGrokRateLimits } from './grok-fetcher' @@ -62,7 +61,6 @@ type MiniMaxResolvedConfig = { error: string | null } -type GeminiCliOAuthEnabledResolver = () => boolean type ActiveRateLimitProvider = ProviderRateLimits['provider'] type ActiveProviderState = { provider: ActiveRateLimitProvider @@ -104,7 +102,6 @@ const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 type InternalRateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null - gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null antigravity: ProviderRateLimits | null @@ -171,7 +168,6 @@ export class RateLimitService { private state: InternalRateLimitState = { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, @@ -186,7 +182,6 @@ export class RateLimitService { private lastActiveFailureRetryAtByProvider: Record = { claude: 0, codex: 0, - gemini: 0, 'opencode-go': 0, kimi: 0, minimax: 0, @@ -197,7 +192,6 @@ export class RateLimitService { private activeFailureStreakByProvider: Record = { claude: 0, codex: 0, - gemini: 0, 'opencode-go': 0, kimi: 0, minimax: 0, @@ -235,7 +229,6 @@ export class RateLimitService { } private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null - private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null private networkProxySettingsResolver: (() => NetworkProxySettings) | null = null @@ -311,10 +304,6 @@ export class RateLimitService { this.miniMaxConfigResolver = resolver } - setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { - this.geminiCliOAuthEnabledResolver = resolver - } - setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { this.networkProxySettingsResolver = resolver } @@ -867,7 +856,6 @@ export class RateLimitService { const byProvider: Record = { claude: this.state.claude, codex: this.state.codex, - gemini: this.state.gemini, 'opencode-go': this.state.opencodeGo, kimi: this.state.kimi, minimax: this.state.minimax, @@ -1570,15 +1558,7 @@ export class RateLimitService { private withFetchingStatus( current: ProviderRateLimits | null, - provider: - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'kimi' - | 'minimax' - | 'grok' - | 'antigravity' + provider: 'claude' | 'codex' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' | 'antigravity' ): ProviderRateLimits { if (!current) { return { @@ -1633,7 +1613,6 @@ export class RateLimitService { const miniMaxCookie = miniMaxConfigResult.config.sessionCookie const miniMaxGroupId = miniMaxConfigResult.config.groupId const miniMaxModels = miniMaxConfigResult.config.models - const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. const grokAuthReadResult = readGrokAuthSession() this.grokAuthConfigured = grokAuthReadResult.status === 'ok' @@ -1663,7 +1642,6 @@ export class RateLimitService { codex: codexFetchGated ? codexStateBeforeFetch : this.withFetchingStatus(previousState.codex, 'codex'), - gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), opencodeGo: opencodeConfigChanged ? this.withFetchingStatus(null, 'opencode-go') : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), @@ -1689,40 +1667,46 @@ 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, + 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 + })), + fetchAntigravityRateLimits(), + 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 @@ -1754,22 +1738,21 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - const gemini = - geminiResult.status === 'fulfilled' - ? geminiResult.value + const antigravity = + antigravityResult.status === 'fulfilled' + ? antigravityResult.value : ({ - provider: 'gemini', + provider: 'antigravity', session: null, weekly: null, updatedAt: Date.now(), error: - geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', + antigravityResult.reason instanceof Error + ? antigravityResult.reason.message + : 'Unknown error', 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) - const opencodeGo = opencodeGoResult.status === 'fulfilled' ? opencodeGoResult.value @@ -1843,7 +1826,6 @@ export class RateLimitService { if (shouldApplyCodex) { this.trackActiveFailureStreak('codex', codex) } - this.trackActiveFailureStreak('gemini', gemini) this.trackActiveFailureStreak('antigravity', antigravity) if (shouldApplyOpencode) { this.trackActiveFailureStreak('opencode-go', opencodeGo) @@ -1864,7 +1846,6 @@ export class RateLimitService { : codexBecameUnavailable ? codexStateBeforeFetch : this.state.codex, - gemini: this.applyStalePolicy(gemini, previousState.gemini), opencodeGo: shouldApplyOpencode ? opencodeConfigChanged ? opencodeGo diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 79b923a55db6..d87417339ca9 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -50,7 +50,6 @@ const AgentActivityDisplayMode = z.enum(['compact', 'full']) const StatusBarItem = z.enum([ 'claude', 'codex', - 'gemini', 'antigravity', 'opencode-go', 'kimi', diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index bf7656856d5d..55fbe1f552e7 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -85,7 +85,6 @@ describe('AccountsPane', () => { const markup = renderPane(getDefaultSettings('/tmp')) expect(markup).toContain('Showing accounts for this device. New accounts are added there.') - expect(markup).toContain('authenticate with Google for this device. This uses credentials') expect(markup).not.toContain('ShowingThis device') expect(markup).not.toContain('forThis device') }) diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index c778a982aa0b..e354bc3cc091 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: AccountsPane owns all per-provider account UI - (Claude, Codex, Gemini, OpenCode Go, and future providers). Each provider's + (Claude, Codex, OpenCode Go, and future providers). Each provider's add/select/reauth/remove flow is tightly coupled to the provider-specific error handling and restart prompts below; splitting them into separate files would scatter those flows without a meaningful abstraction boundary. */ @@ -19,7 +19,6 @@ import { Label } from '../ui/label' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' import { Separator } from '../ui/separator' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' -import { Switch } from '../ui/switch' import { AlertTriangle, ExternalLink, @@ -34,18 +33,11 @@ import { X } from 'lucide-react' import { useAppStore } from '../../store' -import { - ClaudeIcon, - GeminiIcon, - MiniMaxIcon, - OpenAIIcon, - OpenCodeGoIcon -} from '../status-bar/icons' +import { ClaudeIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from '../status-bar/icons' import { toast } from 'sonner' import { getAccountsClaudeSearchEntries, getAccountsCodexSearchEntries, - getAccountsGeminiSearchEntries, getAccountsLocationSearchEntries, getAccountsGrokSearchEntries, getAccountsMiniMaxSearchEntries, @@ -402,10 +394,6 @@ export function AccountsPane({ !navigator.userAgent.includes('Windows') ? `${accountRuntime.label.charAt(0).toLocaleLowerCase()}${accountRuntime.label.slice(1)}` : accountRuntime.label - const localAccountRuntimeSentenceLabel = - localAccountRuntime.runtime === 'host' && !navigator.userAgent.includes('Windows') - ? `${localAccountRuntime.label.charAt(0).toLocaleLowerCase()}${localAccountRuntime.label.slice(1)}` - : localAccountRuntime.label // Why: users read the remote-scoped list as their desktop accounts being // deleted (#8186); say they are intact and link the default-runtime control. // The web client has no desktop-owned accounts and cannot select Local @@ -1544,72 +1532,6 @@ export function AccountsPane({ ) : null, - matchesSettingsSearch(searchQuery, getAccountsGeminiSearchEntries()) ? ( -
-
-

- - {translate('auto.components.settings.AccountsPane.0c64dc2a64', 'Gemini')} -

-

- {translate( - 'auto.components.settings.AccountsPane.973741a871', - 'Configure Gemini provider settings.' - )} -

-
- - -
- -

- {translate( - 'auto.components.settings.AccountsPane.c2aee76420', - 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google for {{value0}}. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.', - { value0: localAccountRuntimeSentenceLabel } - )} -

-
- { - recordFeatureInteraction('usage-tracking') - updateSettings({ - geminiCliOAuthEnabled: checked - }) - }} - /> -
-
- ) : null, matchesSettingsSearch(searchQuery, getAccountsOpencodeSearchEntries()) ? (
diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index c8d63b3866ad..8e4f84f194f4 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -48,7 +48,6 @@ function recordStatusBarToggleInteraction( } else if ( id === 'claude' || id === 'codex' || - id === 'gemini' || id === 'opencode-go' || id === 'kimi' || id === 'antigravity' || diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 6ff07366bc9a..a00373414b08 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -96,37 +96,6 @@ export const getAccountsCodexSearchEntries = createLocalizedCatalog(() => [ } ]) -export const getAccountsGeminiSearchEntries = createLocalizedCatalog(() => [ - { - title: translate( - 'auto.components.settings.accounts.search.d819755b02', - 'Use Gemini CLI credentials' - ), - description: translate( - 'auto.components.settings.accounts.search.bada4a3218', - 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.accounts.search.e8e1ff3887', 'gemini'), - ...translateSearchKeyword('auto.components.settings.accounts.search.8630464352', 'cli'), - ...translateSearchKeyword('auto.components.settings.accounts.search.933deaf732', 'oauth'), - ...translateSearchKeyword( - 'auto.components.settings.accounts.search.7118d2f908', - 'credentials' - ), - ...translateSearchKeyword( - 'auto.components.settings.accounts.search.b7c2cee442', - 'experimental' - ), - ...translateSearchKeyword( - 'auto.components.settings.accounts.search.e949b08ffb', - 'rate limit' - ), - ...translateSearchKeyword('auto.components.settings.accounts.search.86edc96bc9', 'status bar') - ] - } -]) - export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [ { title: translate( @@ -216,7 +185,6 @@ export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsS ...getAccountsLocationSearchEntries(), ...getAccountsClaudeSearchEntries(), ...getAccountsCodexSearchEntries(), - ...getAccountsGeminiSearchEntries(), ...getAccountsOpencodeSearchEntries(), ...getAccountsMiniMaxSearchEntries(), ...getAccountsGrokSearchEntries() diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 9be73da6a912..32adc21d000c 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -71,35 +71,6 @@ export const getStatusBarToggles = createLocalizedCatalog( 'Show Codex token and cost usage for the active workspace.' ) }, - { - id: 'gemini', - title: translate('auto.components.settings.appearance.search.5bfb874d05', 'Gemini Usage'), - description: translate( - 'auto.components.settings.appearance.search.9660c5b2f1', - 'Show Gemini token and cost usage in the status bar.' - ), - keywords: [ - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.896eb53fd4', - 'status bar' - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.2804a920ad', - 'gemini' - ), - ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.afbb6a3767', - 'tokens' - ), - ...translateSearchKeyword('auto.components.settings.appearance.search.edbf0f63a0', 'cost'), - ...translateSearchKeyword('auto.components.settings.appearance.search.51b0ccd6a2', 'google') - ], - toggleDescription: translate( - 'settings.appearance.statusBar.geminiToggleDescription', - 'Show Gemini token and cost usage for the active workspace.' - ) - }, getAntigravityStatusBarToggleSearchEntry(), { id: 'opencode-go', diff --git a/src/renderer/src/components/stats/GrokUsagePane.test.tsx b/src/renderer/src/components/stats/GrokUsagePane.test.tsx index d1ef3700f834..b55fcf282a3c 100644 --- a/src/renderer/src/components/stats/GrokUsagePane.test.tsx +++ b/src/renderer/src/components/stats/GrokUsagePane.test.tsx @@ -19,7 +19,6 @@ const mockStoreState = { rateLimits: { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 436da40e59c3..c7870eeb948a 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -59,7 +59,7 @@ import { getProviderDisplayName, getProviderUsageStatusLabel } from './tooltip' -import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' +import { ClaudeIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { AgentIcon } from '@/lib/agent-catalog' import { UsageRosterPanel, getTightestUsageSection } from './UsageRosterPanel' import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target' @@ -1148,8 +1148,6 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { switch (provider) { case 'claude': return 'C' - case 'gemini': - return 'G' case 'opencode-go': return 'O' case 'kimi': @@ -1169,7 +1167,7 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { // Provider segment // --------------------------------------------------------------------------- -// Why: Gemini exposes extra experimental buckets that made the pre-existing verbose footer noisy. +// Why: providers can expose several model buckets; keep the persistent footer to primary tiers. const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) function VerboseProviderUsage({ @@ -1179,7 +1177,7 @@ function VerboseProviderUsage({ p: ProviderRateLimits display: UsagePercentageDisplay }): React.JSX.Element { - if (p.buckets && p.buckets.length > 0) { + if (p.provider !== 'antigravity' && p.buckets && p.buckets.length > 0) { const visibleBuckets = p.buckets.filter((bucket) => STATUS_BAR_BUCKET_NAMES.has(bucket.name)) return ( <> @@ -2106,11 +2104,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele return null } - const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits + const { claude, codex, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. - // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. - // Why: Antigravity visibility also requires geminiCliOAuthEnabled because its usage snapshot mirrors the Gemini fetch. + // Why: Agy persists auth in the OS keyring, so a checked status item + detected CLI is the durable "show its slot" signal. const antigravityUsageConfigured = statusBarItems.includes('antigravity') && isStatusBarItemAvailable('antigravity', detectedAgentIds) @@ -2123,7 +2120,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele } const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) - const visibleGemini = getVisibleUsageProvider('gemini', gemini, usageSettings) const visibleKimi = getVisibleUsageProvider('kimi', kimi, usageSettings) const visibleAntigravity = getVisibleUsageProvider('antigravity', antigravity, usageSettings) const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) @@ -2136,10 +2132,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele visibleCodex !== null && statusBarItems.includes('codex') && isStatusBarItemAvailable('codex', detectedAgentIds) - const showGemini = - visibleGemini !== null && - statusBarItems.includes('gemini') && - isStatusBarItemAvailable('gemini', detectedAgentIds) const showKimi = visibleKimi !== null && statusBarItems.includes('kimi') && @@ -2166,7 +2158,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const hasVisibleUsageMeters = showClaude || showCodex || - showGemini || showOpencodeGo || showKimi || showAntigravity || @@ -2175,7 +2166,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const anyVisible = hasVisibleUsageMeters || showResourceUsage // Why: include Settings so durable managed accounts count — a configured user isn't shown the empty state while snapshots hydrate. const isEmptyUsageState = isUsageEmptyState( - { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok }, + { claude, codex, opencodeGo, kimi, antigravity, minimax, grok }, usageSettings ) // Why: one-time nudge — once dismissed, stays hidden even if providers reconnect later. @@ -2183,7 +2174,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const anyFetching = claude?.status === 'fetching' || codex?.status === 'fetching' || - gemini?.status === 'fetching' || opencodeGo?.status === 'fetching' || kimi?.status === 'fetching' || antigravity?.status === 'fetching' || @@ -2202,7 +2192,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const rosterProviders = [ showClaude ? visibleClaude : null, showCodex ? visibleCodex : null, - showGemini ? visibleGemini : null, showAntigravity ? visibleAntigravity : null, showOpencodeGo ? visibleOpencodeGo : null, showKimi ? visibleKimi : null, @@ -2475,18 +2464,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele {translate('auto.components.status.bar.StatusBar.c0909c686e', 'Codex Usage')} )} - {isStatusBarItemAvailable('gemini', detectedAgentIds) && ( - { - recordFeatureInteraction('usage-tracking') - toggleStatusBarItem('gemini') - }} - > - - {translate('auto.components.status.bar.StatusBar.c1df0d67ec', 'Gemini Usage')} - - )} {isStatusBarItemAvailable('antigravity', detectedAgentIds) && ( - +
diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx index de4f5a0e06f8..4d12c5940a30 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -40,6 +40,47 @@ const signedOutCodex: ProviderRateLimits = { status: 'error' } +function antigravityLimits(tightestResetsAt: number | null = null): ProviderRateLimits { + return { + provider: 'antigravity', + session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 90, windowMinutes: 10_080, resetsAt: null, resetDescription: null }, + buckets: [ + { + name: 'Gemini 5h', + usedPercent: 20, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + name: 'Gemini wk', + usedPercent: 60, + windowMinutes: 10_080, + resetsAt: null, + resetDescription: null + }, + { + name: 'Claude/GPT 5h', + usedPercent: 40, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + name: 'Claude/GPT wk', + usedPercent: 90, + windowMinutes: 10_080, + resetsAt: tightestResetsAt, + resetDescription: null + } + ], + updatedAt: 0, + error: null, + status: 'ok' + } +} + describe('UsageRow', () => { beforeEach(() => { mocks.useResetCountdownClock.mockClear() @@ -247,6 +288,47 @@ describe('UsageRow', () => { expect(markup).toContain('25%') expect(markup).toContain('60%') }) + + it('renders all four Antigravity buckets in Detailed mode', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup.match(/data-usage-window=/g)).toHaveLength(4) + expect(markup).toContain('Gemini 5h') + expect(markup).toContain('Gemini wk') + expect(markup).toContain('Claude/GPT 5h') + expect(markup).toContain('Claude/GPT wk') + }) + + it('renders only the tightest Antigravity bucket in Compact mode', () => { + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(mocks.now) + try { + const markup = renderToStaticMarkup( + + ) + + expect(markup.match(/data-usage-window=/g)).toHaveLength(1) + expect(markup).toContain('43m') + expect(markup).toContain('90%') + expect(markup).not.toContain('>Claude/GPT wk<') + } finally { + dateNow.mockRestore() + } + }) }) 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..0397cfcfbaf9 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -35,13 +35,14 @@ function providerMaxUsed(sections: UsageSection[]): number { : 0 } -// Buckets (Gemini Flash/Pro) keep their model name; windows use their duration. +// Named model buckets keep their model name; fixed provider windows use their duration. function shortLabel( p: ProviderRateLimits, section: UsageSection, useRemainingDuration = false ): string { - if (p.buckets?.some((b) => b.name === section.label)) { + const isNamedBucket = p.buckets?.some((b) => b.name === section.label) + if (isNamedBucket && !(p.provider === 'antigravity' && useRemainingDuration)) { return section.label } // fableWeekly shares the 7d window with weekly; label it distinctly so the two diff --git a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx index 3bab0e1bfcb3..3166cd563798 100644 --- a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx +++ b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx @@ -43,7 +43,45 @@ function grokMonthlyLimits(status: ProviderRateLimits['status']): ProviderRateLi } } +function antigravityBucketLimits(tightestResetsAt: number | null = null): ProviderRateLimits { + return { + provider: 'antigravity', + session: windowOf(40, 300), + weekly: windowOf(80, 10_080), + buckets: [ + { ...windowOf(20, 300), name: 'Gemini 5h' }, + { ...windowOf(80, 10_080, tightestResetsAt), name: 'Gemini wk' }, + { ...windowOf(40, 300), name: 'Claude/GPT 5h' }, + { ...windowOf(10, 10_080), name: 'Claude/GPT wk' } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + describe('ProviderSegment monthly window', () => { + it.each([ + ['used', '8% used 5h'], + ['remaining', '92% left 5h'] + ] as const)('shows Antigravity usage using the global %s option', async (display, expected) => { + const { ProviderSegment } = await import('./StatusBar') + const limits: ProviderRateLimits = { + provider: 'antigravity', + session: windowOf(7.59684, 300), + weekly: windowOf(1.26614, 10080), + updatedAt: Date.now(), + error: null, + status: 'ok' + } + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain(expected) + }) + it('renders a monthly-only snapshot in the chip instead of a bare icon', async () => { const { ProviderSegment } = await import('./StatusBar') @@ -94,7 +132,7 @@ describe('ProviderSegment monthly window', () => { it('selects a named bucket as the tightest provider window', async () => { const { ProviderSegment } = await import('./StatusBar') const limits: ProviderRateLimits = { - provider: 'gemini', + provider: 'opencode-go', session: null, weekly: null, buckets: [ @@ -182,6 +220,45 @@ describe('ProviderSegment monthly window', () => { expect(markup).toContain('30% used Fable') expect(markup).not.toContain('40% used') }) + + it('keeps Antigravity verbose output to its derived summary windows', async () => { + const { ProviderSegment } = await import('./StatusBar') + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('40% used 5h') + expect(markup).toContain('80% used wk') + expect(markup).not.toContain('Gemini wk') + expect(markup).not.toContain('Claude/GPT') + }) + + it("selects Antigravity's tightest named bucket in compact mode", async () => { + const { ProviderSegment } = await import('./StatusBar') + const now = 1_700_000_000_000 + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) + try { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('80% used 43m') + expect(markup).not.toContain('Gemini wk') + expect(markup).not.toContain('Claude/GPT') + } finally { + dateNow.mockRestore() + } + }) }) describe('undefined provider window safety (crash d2c1da69 / bb74236c)', () => { diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts index cd98315df505..e05177180448 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts @@ -17,7 +17,6 @@ describe('isStatusBarItemAvailable', () => { // don't want a flash of empty status bar on cold start. expect(isStatusBarItemAvailable('claude', null)).toBe(true) expect(isStatusBarItemAvailable('codex', null)).toBe(true) - expect(isStatusBarItemAvailable('gemini', null)).toBe(true) expect(isStatusBarItemAvailable('antigravity', null)).toBe(true) expect(isStatusBarItemAvailable('grok', null)).toBe(true) }) @@ -25,7 +24,6 @@ describe('isStatusBarItemAvailable', () => { it('hides CLI items not detected on PATH', () => { expect(isStatusBarItemAvailable('claude', [])).toBe(false) expect(isStatusBarItemAvailable('codex', ['claude'])).toBe(false) - expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('antigravity', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false) }) @@ -33,7 +31,6 @@ describe('isStatusBarItemAvailable', () => { it('shows CLI items detected on PATH', () => { expect(isStatusBarItemAvailable('claude', ['claude'])).toBe(true) expect(isStatusBarItemAvailable('codex', ['codex', 'claude'])).toBe(true) - expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true) expect(isStatusBarItemAvailable('antigravity', ['antigravity'])).toBe(true) expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true) }) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index 145abef3a900..58a10c1a6f2b 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -2,15 +2,13 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { StatusBarItem } from '../../../../shared/ui-chrome-types' // Why: CLI-backed usage bars are surface noise when the underlying -// CLI isn't installed (e.g. a fresh Ubuntu install showing "Gemini Usage" -// when no Gemini CLI is on PATH). We hide both the bar and its toggle when +// CLI isn't installed. We hide both the bar and its toggle when // PATH detection reports the agent as missing. Pre-detection (null) keeps // the legacy behavior so the bar/toggle don't flicker on cold start, and // re-show automatically once the agent appears on PATH. const CLI_GATED_ITEMS: ReadonlySet = new Set([ 'claude', 'codex', - 'gemini', 'kimi', 'antigravity', 'grok' 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..6db06acab73c 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 @@ -17,7 +17,7 @@ function provider( overrides: Partial = {} ): ProviderRateLimits { return { - provider: 'gemini', + provider: 'opencode-go', session: null, weekly: null, updatedAt: 0, @@ -34,15 +34,14 @@ describe('isProviderConfigured', () => { }) it('hides an unconfigured (unavailable) provider', () => { - // The bug: Gemini OAuth off / OpenCode Go cookie unset returns a non-null - // `unavailable` object, which previously slipped past the `!== null` gate - // and rendered a "--" bar for a provider the user never configured. + // An unset provider can return a non-null `unavailable` object, which must + // not render a "--" bar for a provider the user never configured. expect(isProviderConfigured(provider('unavailable'))).toBe(false) }) it('hides a first-load fetching provider until it has proven usage data', () => { // The initial fetch marks every provider as `fetching`; without prior data - // that state is not proof the user configured Gemini or OpenCode Go. + // that state is not proof the user configured the provider. expect(isProviderConfigured(provider('fetching'))).toBe(false) }) @@ -70,7 +69,6 @@ function usageSettings(overrides: Partial = {}): UsagePro codexManagedAccounts: [], claudeManagedAccounts: [], opencodeSessionCookie: '', - geminiCliOAuthEnabled: false, antigravityUsageConfigured: false, minimaxCookieConfigured: false, grokAuthConfigured: false, @@ -117,15 +115,10 @@ describe('hasUsageProviderSettings', () => { }) it('treats explicit non-managed provider settings as configured usage providers', () => { - expect(hasUsageProviderSettings(usageSettings({ geminiCliOAuthEnabled: true }))).toBe(true) 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,27 +153,13 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) }) - it('requires both a checked Antigravity item and Gemini OAuth as the durable Antigravity 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. + it('uses the checked and detected Agy item as the durable Antigravity signal', () => { expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(false) - expect( - hasUsageProviderSettingsForProvider( - 'antigravity', - usageSettings({ geminiCliOAuthEnabled: true }) - ) - ).toBe(false) + ).toBe(true) expect(hasUsageProviderSettingsForProvider('antigravity', usageSettings())).toBe(false) expect(hasUsageProviderSettingsForProvider('antigravity', null)).toBe(false) }) @@ -262,7 +241,7 @@ describe('getVisibleUsageProvider', () => { it('hides providers with no live data or durable configuration', () => { expect(getVisibleUsageProvider('codex', null, usageSettings())).toBe(null) expect(getVisibleUsageProvider('grok', undefined, usageSettings())).toBe(null) - expect(getVisibleUsageProvider('gemini', provider('fetching'), usageSettings())).toBe(null) + expect(getVisibleUsageProvider('opencode-go', provider('fetching'), usageSettings())).toBe(null) }) it('creates a pending snapshot when an older main process omits a configured provider', () => { @@ -324,11 +303,11 @@ describe('getVisibleUsageProvider', () => { ).toBe(null) }) - it('keeps Antigravity visible while the snapshot is pending when checked and Gemini OAuth is on', () => { + it('keeps Antigravity visible while its Agy snapshot is pending', () => { const visible = getVisibleUsageProvider( 'antigravity', null, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true }) ) expect(visible).toMatchObject({ provider: 'antigravity', @@ -338,26 +317,20 @@ 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. - expect( - getVisibleUsageProvider( - 'antigravity', - null, - usageSettings({ antigravityUsageConfigured: true }) - ) - ).toBe(null) - expect( - getVisibleUsageProvider( - 'antigravity', - provider('unavailable', { - provider: 'antigravity', - error: 'Gemini CLI OAuth is disabled in settings' - }), - usageSettings({ antigravityUsageConfigured: true }) - ) - ).toBe(null) + it('keeps the checked Agy slot visible when local credentials are missing', () => { + const visible = getVisibleUsageProvider( + 'antigravity', + provider('unavailable', { + provider: 'antigravity', + error: 'Agy credentials not found' + }), + usageSettings({ antigravityUsageConfigured: true }) + ) + expect(visible).toMatchObject({ + provider: 'antigravity', + status: 'unavailable', + error: 'Agy credentials not found' + }) }) }) @@ -368,7 +341,6 @@ describe('isUsageEmptyState', () => { { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, @@ -386,7 +358,6 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: undefined, @@ -404,7 +375,6 @@ describe('isUsageEmptyState', () => { { claude: provider('fetching', { provider: 'claude' }), codex: provider('fetching', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: provider('unavailable', { provider: 'antigravity' }), @@ -422,7 +392,6 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: provider('unavailable', { provider: 'antigravity' }), @@ -451,7 +420,6 @@ describe('isUsageEmptyState', () => { { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, @@ -469,7 +437,6 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, @@ -487,27 +454,23 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, grok: provider('unavailable', { provider: 'grok' }), minimax: provider('unavailable', { provider: 'minimax' }) }, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true }) ) ).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('does not show the setup CTA while checked Agy usage is awaiting credentials', () => { expect( isUsageEmptyState( { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, @@ -516,6 +479,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..3f1249e0193f 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 @@ -3,16 +3,10 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' export type UsageProviderSettings = Pick< GlobalSettings, - | 'codexManagedAccounts' - | 'claudeManagedAccounts' - | 'opencodeSessionCookie' - | 'geminiCliOAuthEnabled' + 'codexManagedAccounts' | 'claudeManagedAccounts' | 'opencodeSessionCookie' > & { - // 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: Agy owns its persisted keyring login; the checked status-bar item plus + // PATH detection is the renderer's durable signal while main reads that login. antigravityUsageConfigured: boolean // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean @@ -22,7 +16,6 @@ export type UsageProviderSettings = Pick< type UsageProviderSnapshots = { claude: ProviderRateLimits | null | undefined codex: ProviderRateLimits | null | undefined - gemini: ProviderRateLimits | null | undefined opencodeGo: ProviderRateLimits | null | undefined kimi: ProviderRateLimits | null | undefined antigravity: ProviderRateLimits | null | undefined @@ -47,7 +40,7 @@ function isProviderSnapshotPending(provider: ProviderRateLimits | null | undefin } // Why: a provider that returns `unavailable` is explicitly not configured -// (Gemini OAuth off, OpenCode Go cookie unset, Claude on API-key billing). Its +// (OpenCode Go cookie unset, Claude on API-key billing). Its // fetch object is non-null, so a bare `!== null` check still renders a "--" // bar for a provider the user never set up. `error` is kept visible on purpose // — that's a *configured* provider failing transiently, and hiding it would @@ -72,10 +65,8 @@ export function hasUsageProviderSettings( return Boolean( (settings?.codexManagedAccounts?.length ?? 0) > 0 || (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 ) @@ -94,17 +85,11 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'codex') { return (settings.codexManagedAccounts?.length ?? 0) > 0 } - if (providerId === 'gemini') { - return settings.geminiCliOAuthEnabled === true - } if (providerId === 'opencode-go') { 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 @@ -121,7 +106,7 @@ function createPendingProviderSnapshot(providerId: UsageProviderId): ProviderRat session: null, weekly: null, ...(providerId === 'opencode-go' ? { monthly: null } : {}), - ...(providerId === 'gemini' ? { buckets: [] } : {}), + ...(providerId === 'antigravity' ? { buckets: [] } : {}), updatedAt: 0, error: null, status: 'fetching' @@ -160,7 +145,6 @@ export function isUsageEmptyState( if ( isProviderSnapshotPending(providers.claude) || isProviderSnapshotPending(providers.codex) || - isProviderSnapshotPending(providers.gemini) || isProviderSnapshotPending(providers.opencodeGo) || isProviderSnapshotPending(providers.kimi) || antigravitySnapshotPending || @@ -173,7 +157,6 @@ export function isUsageEmptyState( !hasUsageProviderSettings(settings) && !isProviderConfigured(providers.claude) && !isProviderConfigured(providers.codex) && - !isProviderConfigured(providers.gemini) && !isProviderConfigured(providers.opencodeGo) && !isProviderConfigured(providers.kimi) && !isProviderConfigured(providers.antigravity) && diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 2dc23cf100a5..7a1010e2310d 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -48,7 +48,6 @@ function provider(overrides: Partial = {}): ProviderRateLimi const PROVIDER_IDS: ProviderRateLimits['provider'][] = [ 'claude', 'codex', - 'gemini', 'antigravity', 'opencode-go', 'kimi', @@ -103,17 +102,17 @@ describe('provider usage error copy', () => { error: 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.' }) - const gemini = provider({ - provider: 'gemini', - error: 'Gemini CLI credentials not found' + const antigravity = provider({ + provider: 'antigravity', + error: 'Agy credentials not found' }) expect(getProviderUsageStatusLabel(codex)).toBe('Refresh failed') expect(getProviderUsageErrorMessage(codex)).toBe( 'Codex usage could not be refreshed. Agent sessions may still be signed in.' ) - expect(getProviderUsageErrorMessage(gemini)).toBe( - 'Gemini usage could not be refreshed. Agent sessions may still be signed in.' + expect(getProviderUsageErrorMessage(antigravity)).toBe( + 'Antigravity usage could not be refreshed. Agent sessions may still be signed in.' ) }) @@ -293,10 +292,55 @@ describe('provider usage error copy', () => { }) describe('getWindowSections', () => { + it('returns only Antigravity buckets because its summaries duplicate them', () => { + const p: ProviderRateLimits = { + provider: 'antigravity', + session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 80, windowMinutes: 10_080, resetsAt: null, resetDescription: null }, + buckets: [ + { + name: 'Gemini 5h', + usedPercent: 20, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + name: 'Gemini wk', + usedPercent: 80, + windowMinutes: 10_080, + resetsAt: null, + resetDescription: null + }, + { + name: 'Claude/GPT 5h', + usedPercent: 40, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + name: 'Claude/GPT wk', + usedPercent: 10, + windowMinutes: 10_080, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + + expect(getWindowSections(p)).toEqual( + p.buckets?.map((bucket) => ({ label: bucket.name, window: bucket })) + ) + }) + it('returns buckets as sections when present', () => { const p: ProviderRateLimits = { - provider: 'gemini', - session: { usedPercent: 80, windowMinutes: 300, resetsAt: null, resetDescription: null }, + provider: 'antigravity', + session: null, weekly: null, buckets: [ { @@ -321,8 +365,7 @@ describe('getWindowSections', () => { const sections = getWindowSections(p) expect(sections).toEqual([ { label: 'Pro', window: p.buckets![0] }, - { label: 'Flash', window: p.buckets![1] }, - { label: 'Weekly', window: null } + { label: 'Flash', window: p.buckets![1] } ]) }) @@ -367,7 +410,7 @@ describe('getWindowSections', () => { it('returns session and weekly for empty buckets array', () => { const p: ProviderRateLimits = { - provider: 'gemini', + provider: 'antigravity', session: { usedPercent: 50, windowMinutes: 300, resetsAt: null, resetDescription: null }, weekly: null, buckets: [], @@ -382,14 +425,10 @@ describe('getWindowSections', () => { ]) }) - it('does not expose bucket names via session window in compact rendering path', () => { - // Why: ProviderSegment (compact mode) reads only p.session — never p.buckets. - // This test locks the contract: getWindowSections returns buckets for detail - // views, while the plain session value remains independently available for - // compact rendering without bucket names bleeding through. + it('keeps Agy model quota separate from fixed provider windows', () => { const p: ProviderRateLimits = { - provider: 'gemini', - session: { usedPercent: 80, windowMinutes: 300, resetsAt: null, resetDescription: null }, + provider: 'antigravity', + session: null, weekly: null, buckets: [ { @@ -411,9 +450,8 @@ describe('getWindowSections', () => { error: null, status: 'ok' } - // Compact path uses p.session directly — independent of getWindowSections. - expect(p.session?.usedPercent).toBe(80) - // getWindowSections (detail path) returns bucket rows, not session label. + expect(p.session).toBeNull() + expect(p.weekly).toBeNull() const sections = getWindowSections(p) const labels = sections.map((s) => s.label) expect(labels).toContain('Pro') @@ -423,7 +461,7 @@ describe('getWindowSections', () => { it('preserves reset metadata inside bucket windows', () => { const p: ProviderRateLimits = { - provider: 'gemini', + provider: 'antigravity', session: null, weekly: null, buckets: [ @@ -440,7 +478,7 @@ describe('getWindowSections', () => { status: 'ok' } const sections = getWindowSections(p) - expect(sections).toHaveLength(2) + expect(sections).toHaveLength(1) expect(sections[0].label).toBe('Pro') expect(sections[0].window!.resetsAt).toBe(18000000) expect(sections[0].window!.resetDescription).toBe('5:00 PM') diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index db585ab64f7f..1564a697d086 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -4,7 +4,7 @@ import { formatResetDuration } from '../../../../shared/rate-limit-reset-format' import { AgentIcon } from '@/lib/agent-catalog' -import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' +import { ClaudeIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { translate } from '@/i18n/i18n' import { getProviderDisplayName, @@ -78,9 +78,6 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'codex') { return } - if (provider === 'gemini') { - return - } if (provider === 'opencode-go') { return } @@ -143,13 +140,18 @@ export function getWindowSections( ): { label: string; window: RateLimitWindow | null }[] { if (p.buckets?.length) { const bucketSections = p.buckets.map((b) => ({ label: b.name, window: b as RateLimitWindow })) - return [ - ...bucketSections, - { - label: translate('auto.components.status.bar.tooltip.252c096536', 'Weekly'), - window: p.weekly - } - ] + if (p.provider === 'antigravity') { + return bucketSections + } + return p.weekly + ? [ + ...bucketSections, + { + label: translate('auto.components.status.bar.tooltip.252c096536', 'Weekly'), + window: p.weekly + } + ] + : bucketSections } const sections: { label: string; window: RateLimitWindow | null }[] = [ { diff --git a/src/renderer/src/components/status-bar/usage-error-copy.test.ts b/src/renderer/src/components/status-bar/usage-error-copy.test.ts index c91d0704ace3..b16742e2a610 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.test.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.test.ts @@ -18,7 +18,6 @@ describe('getProviderDisplayName', () => { it('returns the existing provider brand names', () => { expect(getProviderDisplayName('claude')).toBe('Claude') expect(getProviderDisplayName('codex')).toBe('Codex') - expect(getProviderDisplayName('gemini')).toBe('Gemini') expect(getProviderDisplayName('opencode-go')).toBe('OpenCode Go') expect(getProviderDisplayName('kimi')).toBe('Kimi') expect(getProviderDisplayName('grok')).toBe('Grok') diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 39032ab5e2c3..81b721e7bf80 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -8,9 +8,6 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'codex') { return 'Codex' } - if (provider === 'gemini') { - return 'Gemini' - } if (provider === 'opencode-go') { return 'OpenCode Go' } diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts index cbcfcda04929..22ac8a29dda4 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts @@ -5,8 +5,7 @@ describe('getUsageProviderAccountsSectionId', () => { it('routes providers only to settings sections that exist', () => { expect(getUsageProviderAccountsSectionId('claude')).toBe('accounts-claude') expect(getUsageProviderAccountsSectionId('codex')).toBe('accounts-codex') - expect(getUsageProviderAccountsSectionId('gemini')).toBe('accounts-gemini') - expect(getUsageProviderAccountsSectionId('antigravity')).toBe('accounts-gemini') + expect(getUsageProviderAccountsSectionId('antigravity')).toBeNull() expect(getUsageProviderAccountsSectionId('opencode-go')).toBe('accounts-opencode-go') expect(getUsageProviderAccountsSectionId('minimax')).toBe('accounts-minimax') expect(getUsageProviderAccountsSectionId('grok')).toBe('accounts-grok') diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index 59ea71042490..31a66ce515a6 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -8,10 +8,9 @@ export function getUsageProviderAccountsSectionId( return 'accounts-claude' case 'codex': return 'accounts-codex' - case 'gemini': case 'antigravity': - // Why: Antigravity usage currently shares Gemini's OAuth configuration. - return 'accounts-gemini' + // Why: Agy quota comes from a host-local process, not an Orca-managed account. + return null case 'opencode-go': return 'accounts-opencode-go' case 'minimax': diff --git a/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts b/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts index f17905832928..f09cc511c662 100644 --- a/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts @@ -12,7 +12,6 @@ describe('useIpcEvents rate-limit hydration', () => { const staleState = { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, claudeTarget: { runtime: 'host', wslDistro: null }, diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 9983ba38f0a4..10af41ac3be9 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -187,7 +187,7 @@ export function buildSettingsNavigationMetadata({ ), description: translate( 'auto.hooks.useSettingsNavigationMetadata.b1c2f8b0ac', - 'Optional account switching and usage setup for Claude, Codex, Gemini, OpenCode Go, MiniMax, and Grok.' + 'Optional account switching and usage setup for Claude, Codex, OpenCode Go, MiniMax, and Grok.' ), icon: UserCog, searchEntries: getAccountsPaneSearchEntries(), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bbffbf8a974d..c82572955fa0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -118,7 +118,6 @@ "statusBar": { "claudeToggleDescription": "Show Claude token and cost usage for the active workspace.", "codexToggleDescription": "Show Codex token and cost usage for the active workspace.", - "geminiToggleDescription": "Show Gemini token and cost usage for the active workspace.", "opencodeGoToggleDescription": "Show OpenCode Go token and cost usage for the active workspace.", "kimiToggleDescription": "Show Kimi subscription usage for the active workspace.", "minimaxToggleDescription": "Show MiniMax subscription usage for the active workspace.", @@ -1035,7 +1034,7 @@ "cd50cec5d7": "Coordinate multiple coding agents through Orca.", "58a868e8e4": "Orchestration", "7c79d3b7bf": "Optional", - "b1c2f8b0ac": "Optional account switching and usage setup for Claude, Codex, Gemini, OpenCode Go, MiniMax, and Grok.", + "b1c2f8b0ac": "Optional account switching and usage setup for Claude, Codex, OpenCode Go, MiniMax, and Grok.", "f70ac54d38": "AI Provider Accounts", "4121f7a0a2": "Manage AI agents, set a default, and customize commands.", "b49abbd2f7": "Agents", @@ -3564,7 +3563,6 @@ "24ac89df1a": "Remote Hosts", "5e59007df4": "Kimi Usage", "8c86cd77b0": "OpenCode Go Usage", - "c1df0d67ec": "Gemini Usage", "c0909c686e": "Codex Usage", "3885eb74d8": "Claude Usage", "c8857b40f7": "Refresh usage data", @@ -3592,10 +3590,8 @@ "3325d996cb": "Refresh rate limits", "fda8146810": "Open Kimi usage details", "629251f4b6": "Open OpenCode Go usage details", - "d2375976eb": "Open Gemini usage details", "3d79122c3f": "kimi", "d7a0668acc": "opencode-go", - "68efc0345c": "gemini", "76b06d4da5": "claude", "a28a5dd9b1": "error", "cd9d7b40ff": "Restart {{value0}} Sessions", @@ -6226,12 +6222,6 @@ "36223200ac": "OpenCode Go Session Cookie", "ea631977b5": "Configure OpenCode Go provider settings.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google for {{value0}}. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.", - "96f3649526": "Use Gemini CLI credentials (experimental)", - "d676c41fc6": "Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.", - "0c7f915b01": "Use Gemini CLI credentials", - "973741a871": "Configure Gemini provider settings.", - "0c64dc2a64": "Gemini", "db209ee572": "Remove", "8a0f870153": "Re-authenticate", "3d245ef7d9": "Codex reported this sign-in is out of date", @@ -8652,9 +8642,6 @@ "7118d2f908": "credentials", "933deaf732": "oauth", "8630464352": "cli", - "e8e1ff3887": "gemini", - "bada4a3218": "Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google.", - "d819755b02": "Use Gemini CLI credentials", "35b461d817": "sign in", "f2d666a886": "optional", "8b06729e0f": "active", @@ -8871,9 +8858,6 @@ "7f72de7cbe": "Show OpenCode Go token and cost usage in the status bar.", "bc046e7899": "OpenCode Go Usage", "51b0ccd6a2": "google", - "2804a920ad": "gemini", - "9660c5b2f1": "Show Gemini token and cost usage in the status bar.", - "5bfb874d05": "Gemini Usage", "97957e374e": "openai", "8dfd676c28": "codex", "e9e4412545": "Show Codex token and cost usage in the status bar.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5142116a66b0..9769e804755d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -25,7 +25,6 @@ "statusBar": { "claudeToggleDescription": "Muestra el consumo de tokens y el costo de Claude para el espacio de trabajo activo.", "codexToggleDescription": "Muestra el consumo de tokens y el costo de Codex para el espacio de trabajo activo.", - "geminiToggleDescription": "Muestra el consumo de tokens y el costo de Gemini para el espacio de trabajo activo.", "opencodeGoToggleDescription": "Muestra el consumo de tokens y el costo de OpenCode Go para el espacio de trabajo activo.", "kimiToggleDescription": "Muestra el uso de la suscripción de Kimi para el espacio de trabajo activo.", "minimaxToggleDescription": "Muestra el uso de la suscripción de MiniMax para el espacio de trabajo activo.", @@ -806,7 +805,7 @@ "cd50cec5d7": "Coordina múltiples coding agents a través de Orca.", "58a868e8e4": "Orquestación", "7c79d3b7bf": "Opcional", - "b1c2f8b0ac": "Configuración opcional de cuentas y uso para Claude, Codex, Gemini, OpenCode Go, MiniMax y Grok.", + "b1c2f8b0ac": "Configuración opcional de cuentas y uso para Claude, Codex, OpenCode Go, MiniMax y Grok.", "f70ac54d38": "Cuentas de proveedores de IA", "4121f7a0a2": "Administra agentes de IA, define uno predeterminado y personaliza comandos.", "b49abbd2f7": "Agentes", @@ -3239,7 +3238,6 @@ "24ac89df1a": "Hosts remotos", "5e59007df4": "Uso de Kimi", "8c86cd77b0": "Uso de OpenCode Go", - "c1df0d67ec": "Uso de Gemini", "c0909c686e": "Uso del Codex", "3885eb74d8": "Uso de Claude", "c8857b40f7": "Actualizar datos de uso", @@ -3267,10 +3265,8 @@ "3325d996cb": "Actualizar rate limits", "fda8146810": "Abrir detalles de uso de Kimi", "629251f4b6": "Abrir detalles de uso de OpenCode Go", - "d2375976eb": "Abrir detalles de uso de Gemini", "3d79122c3f": "kimi", "d7a0668acc": "opencode-go", - "68efc0345c": "gemini", "76b06d4da5": "claude", "a28a5dd9b1": "error", "cd9d7b40ff": "Reiniciar {{value0}} sesiones", @@ -5350,12 +5346,6 @@ "36223200ac": "Cookie de sesión de OpenCode Go", "ea631977b5": "Configura los ajustes del proveedor OpenCode Go.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "Extrae credenciales OAuth de tu instalación local de Gemini CLI para autenticarse con Google para {{value0}}. Usa credenciales emitidas para la app Gemini CLI, no para Orca. Puede romperse si Google actualiza la CLI. Úsalo bajo tu propio riesgo.", - "96f3649526": "Usar credenciales de Gemini CLI (experimental)", - "d676c41fc6": "Extrae credenciales OAuth de tu instalación local de Gemini CLI para autenticarse con Google. Usa credenciales emitidas para la app Gemini CLI, no para Orca. Puede romperse si Google actualiza la CLI. Úsalo bajo tu propio riesgo.", - "0c7f915b01": "Usar credenciales de Gemini CLI", - "973741a871": "Configura los ajustes del proveedor Gemini.", - "0c64dc2a64": "Gemini", "db209ee572": "Eliminar", "8a0f870153": "Volver a autenticar", "3d245ef7d9": "Codex informó que este inicio de sesión no está actualizado", @@ -7665,9 +7655,6 @@ "7118d2f908": "credenciales", "933deaf732": "OAuth", "8630464352": "CLI", - "e8e1ff3887": "Gemini", - "bada4a3218": "Extrae las credenciales de OAuth de tu instalación local de la CLI de Gemini para autenticarte con Google.", - "d819755b02": "Usar credenciales de la CLI de Gemini", "35b461d817": "iniciar sesión", "f2d666a886": "opcional", "8b06729e0f": "activo", @@ -7882,9 +7869,6 @@ "7f72de7cbe": "Muestra el consumo de tokens y el costo de OpenCode Go en la barra de estado.", "bc046e7899": "Uso de OpenCode Go", "51b0ccd6a2": "Google", - "2804a920ad": "gemini", - "9660c5b2f1": "Muestra el consumo de tokens y el costo de Gemini en la barra de estado.", - "5bfb874d05": "Uso de Gemini", "97957e374e": "openai", "8dfd676c28": "codex", "e9e4412545": "Muestra el consumo de tokens y el costo de Codex en la barra de estado.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1c72acfa367e..0d4d78c9868c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -25,7 +25,6 @@ "statusBar": { "claudeToggleDescription": "アクティブなワークスペースの Claude トークンとコストの使用状況を表示します。", "codexToggleDescription": "アクティブなワークスペースの Codex トークンとコストの使用状況を表示します。", - "geminiToggleDescription": "アクティブなワークスペースの Gemini トークンとコストの使用状況を表示します。", "opencodeGoToggleDescription": "アクティブなワークスペースの OpenCode Go トークンと使用コストを表示します。", "kimiToggleDescription": "アクティブなワークスペースの Kimi サブスクリプション使用状況を表示します。", "minimaxToggleDescription": "アクティブなワークスペースの MiniMax サブスクリプション使用状況を表示します。", @@ -806,7 +805,7 @@ "cd50cec5d7": "Orca を通じて複数のコーディング Agent を調整します。", "58a868e8e4": "オーケストレーション", "7c79d3b7bf": "任意", - "b1c2f8b0ac": "Claude、Codex、Gemini、OpenCode Go、MiniMax、Grok の任意のアカウント切り替えと使用量設定。", + "b1c2f8b0ac": "Claude、Codex、OpenCode Go、MiniMax、Grok の任意のアカウント切り替えと使用量設定。", "f70ac54d38": "AI プロバイダーアカウント", "4121f7a0a2": "AI Agent を管理し、デフォルトを設定し、コマンドをカスタマイズします。", "b49abbd2f7": "Agent", @@ -3239,7 +3238,6 @@ "24ac89df1a": "リモートホスト", "5e59007df4": "Kimi 使用量", "8c86cd77b0": "OpenCode Go の使用量", - "c1df0d67ec": "Gemini 使用量", "c0909c686e": "Codex 使用量", "3885eb74d8": "Claude 使用量", "c8857b40f7": "使用状況データを更新する", @@ -3267,10 +3265,8 @@ "3325d996cb": "レート制限をリセット", "fda8146810": "Kimi の使用状況の詳細を開く", "629251f4b6": "OpenCode Go の使用状況の詳細を開く", - "d2375976eb": "Gemini の使用状況の詳細を開く", "3d79122c3f": "kimi", "d7a0668acc": "opencode-go", - "68efc0345c": "gemini", "76b06d4da5": "claude", "a28a5dd9b1": "エラー", "cd9d7b40ff": "{{value0}} セッションを再起動します", @@ -5326,12 +5322,6 @@ "36223200ac": "OpenCode Go セッション Cookie", "ea631977b5": "OpenCode Go プロバイダー設定を構成します。", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "ローカルの Gemini CLI インストールから OAuth 認証情報を抽出し、{{value0}} の Google 認証に使用します。これは、Orca ではなく、Gemini CLI アプリに発行された認証情報を使用します。Google が CLI を更新すると壊れる可能性があります。ご自身の責任でご使用ください。", - "96f3649526": "Gemini CLI 認証情報を使用する (実験的機能)", - "d676c41fc6": "ローカルの Gemini CLI インストールから OAuth 認証情報を抽出して、Google で認証します。これは、Orca ではなく、Gemini CLI アプリに発行された認証情報を使用します。 Google が CLI を更新すると壊れる可能性があります。ご自身の責任でご使用ください。", - "0c7f915b01": "Gemini CLI 認証情報を使用する", - "973741a871": "Gemini プロバイダー設定を構成します。", - "0c64dc2a64": "Gemini", "db209ee572": "削除", "8a0f870153": "再認証", "3d245ef7d9": "Codex はこのサインインが古いと報告しました", @@ -7678,9 +7668,6 @@ "7118d2f908": "認証情報", "933deaf732": "認証", "8630464352": "CLI", - "e8e1ff3887": "gemini", - "bada4a3218": "ローカルの Gemini CLI インストールから OAuth 認証情報を抽出して、Google で認証します。", - "d819755b02": "Gemini CLI 認証情報を使用する", "35b461d817": "サインイン", "f2d666a886": "オプション", "8b06729e0f": "アクティブ", @@ -7895,9 +7882,6 @@ "7f72de7cbe": "OpenCode Go トークンとコストの使用状況をステータスバーに表示します。", "bc046e7899": "OpenCode Go の使用量", "51b0ccd6a2": "グーグル", - "2804a920ad": "gemini", - "9660c5b2f1": "Gemini トークンとコストの使用状況をステータスバーに表示します。", - "5bfb874d05": "Gemini 使用量", "97957e374e": "openai", "8dfd676c28": "codex", "e9e4412545": "Codex トークンとコストの使用状況をステータスバーに表示します。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e5aefb9291a1..470f08e9bfab 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -25,7 +25,6 @@ "statusBar": { "claudeToggleDescription": "활성 워크스페이스에 대한 Claude 토큰 및 비용 사용량을 표시합니다.", "codexToggleDescription": "활성 워크스페이스에 대한 Codex 토큰 및 비용 사용량을 표시합니다.", - "geminiToggleDescription": "활성 워크스페이스에 대한 Gemini 토큰 및 비용 사용량을 표시합니다.", "opencodeGoToggleDescription": "활성 워크스페이스에 대한 OpenCode Go 토큰 및 비용 사용량을 표시합니다.", "kimiToggleDescription": "활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.", "minimaxToggleDescription": "활성 워크스페이스의 MiniMax 구독 사용량을 표시합니다.", @@ -809,7 +808,7 @@ "cd50cec5d7": "Orca를 통해 여러 코딩 agents를 조정합니다.", "58a868e8e4": "오케스트레이션", "7c79d3b7bf": "선택 사항", - "b1c2f8b0ac": "Claude, Codex, Gemini, OpenCode Go, MiniMax, Grok의 선택적 계정 전환 및 사용량 설정.", + "b1c2f8b0ac": "Claude, Codex, OpenCode Go, MiniMax, Grok의 선택적 계정 전환 및 사용량 설정.", "f70ac54d38": "AI 제공자 계정", "4121f7a0a2": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.", "b49abbd2f7": "에이전트", @@ -3244,7 +3243,6 @@ "24ac89df1a": "원격 호스트", "5e59007df4": "Kimi 사용량", "8c86cd77b0": "OpenCode Go 사용량", - "c1df0d67ec": "Gemini 사용량", "c0909c686e": "Codex 사용량", "3885eb74d8": "Claude 사용량", "c8857b40f7": "사용량 데이터 새로 고침", @@ -3272,10 +3270,8 @@ "3325d996cb": "rate-limit 새로고침", "fda8146810": "Kimi 사용 세부정보 열기", "629251f4b6": "OpenCode Go 사용 세부정보 열기", - "d2375976eb": "Gemini 사용 세부정보 열기", "3d79122c3f": "kimi", "d7a0668acc": "opencode-go", - "68efc0345c": "gemini", "76b06d4da5": "claude", "a28a5dd9b1": "오류", "cd9d7b40ff": "{{value0}} 세션 다시 시작", @@ -5266,12 +5262,6 @@ "36223200ac": "OpenCode Go 세션 쿠키", "ea631977b5": "OpenCode Go 공급자 설정을 구성합니다.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "{{value0}}에서 Google에 인증하기 위해 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 추출합니다. 이는 Orca가 아닌 Gemini CLI 앱에 발급된 자격 증명을 사용합니다. Google이 CLI를 업데이트하면 중단될 수 있습니다. 자신의 책임하에 사용하십시오.", - "96f3649526": "Gemini CLI 자격 증명 사용(실험적)", - "d676c41fc6": "Google 인증을 위해 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 추출합니다. 이는 Orca가 아닌 Gemini CLI 앱에 발급된 자격 증명을 사용합니다. Google이 CLI를 업데이트하면 중단될 수 있습니다. 자신의 책임하에 사용하십시오.", - "0c7f915b01": "Gemini CLI 자격 증명 사용", - "973741a871": "Gemini 공급자 설정을 구성합니다.", - "0c64dc2a64": "Gemini", "db209ee572": "제거", "8a0f870153": "재인증", "3d245ef7d9": "Codex에서 이 로그인이 오래되었다고 보고했습니다.", @@ -7581,9 +7571,6 @@ "7118d2f908": "자격 증명", "933deaf732": "OAuth", "8630464352": "CLI", - "e8e1ff3887": "gemini", - "bada4a3218": "Google 인증을 위해 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 추출합니다.", - "d819755b02": "Gemini CLI 자격 증명 사용", "35b461d817": "로그인", "f2d666a886": "선택 사항", "8b06729e0f": "활성", @@ -7798,9 +7785,6 @@ "7f72de7cbe": "상태 표시줄에 OpenCode Go 토큰 및 비용 사용량을 표시합니다.", "bc046e7899": "OpenCode Go 사용량", "51b0ccd6a2": "Google", - "2804a920ad": "gemini", - "9660c5b2f1": "상태 표시줄에 Gemini 토큰과 비용 사용량을 표시합니다.", - "5bfb874d05": "Gemini 사용량", "97957e374e": "OpenAI", "8dfd676c28": "codex", "e9e4412545": "상태 표시줄에 Codex 토큰 및 비용 사용량을 표시합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6b5cae8fc24b..74922882035e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -25,7 +25,6 @@ "statusBar": { "claudeToggleDescription": "显示当前工作区的 Claude Token 与费用消耗情况。", "codexToggleDescription": "显示当前工作区的 Codex Token 与费用消耗情况。", - "geminiToggleDescription": "显示当前工作区的 Gemini Token 与费用消耗情况。", "opencodeGoToggleDescription": "显示当前工作区的 OpenCode Go Token 与费用消耗情况。", "kimiToggleDescription": "显示当前工作区的 Kimi 订阅使用情况。", "minimaxToggleDescription": "显示当前工作区的 MiniMax 订阅使用情况。", @@ -809,7 +808,7 @@ "cd50cec5d7": "通过 Orca 协调多个编码智能体。", "58a868e8e4": "编排", "7c79d3b7bf": "可选", - "b1c2f8b0ac": "Claude、Codex、Gemini、OpenCode Go、MiniMax 和 Grok 的可选账户切换与使用量设置。", + "b1c2f8b0ac": "Claude、Codex、OpenCode Go、MiniMax 和 Grok 的可选账户切换与使用量设置。", "f70ac54d38": "AI 提供商账户", "4121f7a0a2": "管理 AI 智能体、设置默认值并自定义命令。", "b49abbd2f7": "智能体", @@ -3254,7 +3253,6 @@ "24ac89df1a": "远程主机", "5e59007df4": "Kimi 使用情况", "8c86cd77b0": "OpenCode Go 使用情况", - "c1df0d67ec": "Gemini 使用情况", "c0909c686e": "Codex 使用情况", "3885eb74d8": "Claude 使用情况", "c8857b40f7": "刷新使用数据", @@ -3282,10 +3280,8 @@ "3325d996cb": "刷新速率限制", "fda8146810": "打开 Kimi 使用详情", "629251f4b6": "打开 OpenCode Go 使用详情", - "d2375976eb": "打开 Gemini 使用详情", "3d79122c3f": "kimi", "d7a0668acc": "opencode-go", - "68efc0345c": "gemini", "76b06d4da5": "claude", "a28a5dd9b1": "错误", "cd9d7b40ff": "重新启动 {{value0}} 会话", @@ -5301,12 +5297,6 @@ "36223200ac": "OpenCode Go 会话 Cookie", "ea631977b5": "配置 OpenCode Go 提供商设置。", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "从本地 Gemini CLI 安装中提取 OAuth 凭据,用于在 {{value0}} 通过 Google 进行身份验证。这将使用颁发给 Gemini CLI 应用的凭据,而非 Orca。若 Google 更新 CLI,可能会失效。请自行承担风险。", - "96f3649526": "使用 Gemini CLI 凭据(实验)", - "d676c41fc6": "从本地 Gemini CLI 安装中提取 OAuth 凭据以通过 Google 认证。这将使用颁发给 Gemini CLI 应用的凭据,而非 Orca。若 Google 更新 CLI,可能会失效。请自行承担风险。", - "0c7f915b01": "使用 Gemini CLI 凭据", - "973741a871": "配置 Gemini 提供商设置。", - "0c64dc2a64": "Gemini", "db209ee572": "移除", "8a0f870153": "重新验证", "3d245ef7d9": "Codex 报告此登录已过时", @@ -7616,9 +7606,6 @@ "7118d2f908": "证书", "933deaf732": "OAuth", "8630464352": "CLI", - "e8e1ff3887": "gemini", - "bada4a3218": "从本地 Gemini CLI 安装中提取 OAuth 凭据以通过 Google 进行身份验证。", - "d819755b02": "使用 Gemini CLI 凭据", "35b461d817": "登入", "f2d666a886": "可选", "8b06729e0f": "活跃", @@ -7833,9 +7820,6 @@ "7f72de7cbe": "在状态栏中显示 OpenCode Go Token 和成本使用情况。", "bc046e7899": "OpenCode Go 使用情况", "51b0ccd6a2": "谷歌", - "2804a920ad": "gemini", - "9660c5b2f1": "在状态栏中显示 Gemini Token 和成本使用情况。", - "5bfb874d05": "Gemini 使用情况", "97957e374e": "开放伊", "8dfd676c28": "codex", "e9e4412545": "在状态栏中显示 Codex Token 和成本使用情况。", diff --git a/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts b/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts index feea4e56a623..802410066ce1 100644 --- a/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts +++ b/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts @@ -71,7 +71,6 @@ describe('zh provider usage wording (#12881)', () => { // status bar item menu ['3885eb74d8', 'Claude'], ['c0909c686e', 'Codex'], - ['c1df0d67ec', 'Gemini'], ['antigravityUsage', 'Antigravity'], ['8c86cd77b0', 'OpenCode Go'], ['5e59007df4', 'Kimi'], @@ -80,7 +79,6 @@ describe('zh provider usage wording (#12881)', () => { // Settings > Appearance search index — mirrors the menu labels ['9dc15020d7', 'Claude'], ['54b1acf24f', 'Codex'], - ['5bfb874d05', 'Gemini'], ['antigravityUsageTitle', 'Antigravity'], ['bc046e7899', 'OpenCode Go'], ['3a6c028ea8', 'Kimi'], @@ -116,7 +114,6 @@ describe('zh provider usage wording (#12881)', () => { for (const [key, brand] of [ ['fda8146810', 'Kimi'], ['629251f4b6', 'OpenCode Go'], - ['d2375976eb', 'Gemini'], ['06741a2f3d', 'MiniMax'], ['antigravityUsageDetails', 'Antigravity'], ['grokUsageAria', 'Grok'] diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index 3df495cf32d6..d81c90cf85cb 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -19,7 +19,6 @@ export const createRateLimitSlice: StateCreator { expect(setUI).not.toHaveBeenCalled() }) + it('removes and persists the deprecated Gemini usage item', () => { + const setUI = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + statusBarItems: [ + 'claude', + 'gemini', + 'antigravity' + ] as unknown as PersistedUIState['statusBarItems'], + _portsStatusBarDefaultAdded: true, + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true + }) + ) + + expect(store.getState().statusBarItems).toEqual(['claude', 'antigravity']) + expect(setUI).toHaveBeenCalledWith( + expect.objectContaining({ statusBarItems: ['claude', 'antigravity'] }) + ) + }) + it('persists and hydrates the usage percentage display preference', () => { const setUI = vi.fn().mockResolvedValue(undefined) vi.stubGlobal('window', { api: { ui: { set: setUI } } }) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 466e3db9724e..3d9f5d72a414 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -275,11 +275,16 @@ function presetToQuery(presetId: TaskViewPresetId | null): string { } } +const DEPRECATED_STATUS_BAR_ITEMS: ReadonlySet = new Set(['gemini']) + // Why: migrate legacy memory+sessions ids → resource-usage; keep unknown ids so downgrade→upgrade can't strip a newer build's ids. function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarItem[] { const source = items ?? DEFAULT_STATUS_BAR_ITEMS const out: string[] = [] for (const id of source) { + if (DEPRECATED_STATUS_BAR_ITEMS.has(id)) { + continue + } const mapped = id === 'memory' || id === 'sessions' ? 'resource-usage' : id if (!out.includes(mapped)) { out.push(mapped) @@ -2487,8 +2492,13 @@ export const createUISlice: StateCreator = (set, get) ui._grokStatusBarDefaultAdded || statusBarItemsWithAntigravity.includes('grok') ? statusBarItemsWithAntigravity : [...statusBarItemsWithAntigravity, DEFAULT_ON_GROK_STATUS_BAR_ITEM] + const statusBarItemsMigrated = + !Array.isArray(ui.statusBarItems) || + ui.statusBarItems.length !== migratedStatusBarItems.length || + ui.statusBarItems.some((item, index) => item !== migratedStatusBarItems[index]) if ( - (!ui._portsStatusBarDefaultAdded || + (statusBarItemsMigrated || + !ui._portsStatusBarDefaultAdded || !ui._kimiStatusBarDefaultAdded || !ui._minimaxStatusBarDefaultAdded || !ui._antigravityStatusBarDefaultAdded || diff --git a/src/renderer/src/web/preload-api/web-rate-limits-api.ts b/src/renderer/src/web/preload-api/web-rate-limits-api.ts index 7587a8386606..3d7733f42031 100644 --- a/src/renderer/src/web/preload-api/web-rate-limits-api.ts +++ b/src/renderer/src/web/preload-api/web-rate-limits-api.ts @@ -6,7 +6,6 @@ export function createRateLimitsApi(): NonNullable['rateLimi const empty: RateLimitState = { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 03e06eb374f4..4e7043ec9c44 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -121,7 +121,6 @@ src/main/pty/posix-pty-foreground-group.ts src/main/pty/posix-pty-process-groups.ts src/main/pty/windows-environment-path.ts src/main/rate-limits/codex-fetcher.ts -src/main/rate-limits/gemini-cli-oauth-extractor.ts src/main/runtime/orca-runtime-files.ts src/main/runtime/tls-certificate.ts src/main/runtime/windows-default-route-interfaces.ts diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 928e774eaa3b..8ed519a8345a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -340,7 +340,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings { opencodeWorkspaceId: '', minimaxGroupId: '', minimaxUsageModels: 'general', - geminiCliOAuthEnabled: false, agentCmdOverrides: {}, agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV }, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 0ed2c72154ca..4532c037c5e6 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -358,7 +358,6 @@ export type GlobalSettings = { /** Comma-separated MiniMax model names to show in the status bar usage window. */ minimaxUsageModels: string /** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */ - geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> /** Custom CODEX_HOME for Codex session-history discovery (defaults to ~/.codex). diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index 6d35c2d22fc8..e7665593fc45 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -10,7 +10,6 @@ describe('RateLimitState', () => { const state: RateLimitState = { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 83210fba2ccb..5a96afc9b8af 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -46,15 +46,7 @@ export type UsageRateLimitMetadata = { } export type ProviderRateLimits = { - provider: - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'kimi' - | 'minimax' - | 'grok' - | 'antigravity' + provider: 'claude' | 'codex' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' | 'antigravity' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -63,7 +55,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 per-model buckets (providers that expose quota by model). */ buckets?: RateLimitBucket[] /** Available earned Codex rate-limit reset credits, if reported. */ rateLimitResetCredits?: { @@ -118,7 +110,6 @@ export type GrokAccountStatus = { export type RateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null - gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null antigravity: ProviderRateLimits | null diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 73d72a70ab2a..9d4e34a48dba 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -3,7 +3,6 @@ import type { StatusBarItem } from './ui-chrome-types' export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'claude', 'codex', - 'gemini', 'antigravity', 'opencode-go', 'kimi', diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 0466ca79430c..26e44d02684e 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -251,7 +251,6 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'experimentalTerminalAttention', 'experimentalAgentHibernation', 'experimentalEphemeralVms', - 'geminiCliOAuthEnabled', 'openAgentTabsInChatByDefault' ] as const satisfies readonly BooleanGlobalSettingsKey[] export const settingsChangedKeySchema = z.enum(SETTINGS_CHANGED_WHITELIST) diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts index 90a12cf5b026..da2a1da94265 100644 --- a/src/shared/ui-chrome-types.ts +++ b/src/shared/ui-chrome-types.ts @@ -52,7 +52,6 @@ export type AgentActivityDisplayMode = 'compact' | 'full' export type StatusBarItem = | 'claude' | 'codex' - | 'gemini' | 'antigravity' | 'opencode-go' | 'kimi'