diff --git a/src/main/rate-limits/antigravity-loopback-quota.ts b/src/main/rate-limits/antigravity-loopback-quota.ts new file mode 100644 index 000000000000..3dfc9a40976a --- /dev/null +++ b/src/main/rate-limits/antigravity-loopback-quota.ts @@ -0,0 +1,79 @@ +import http from 'node:http' +import https from 'node:https' + +export const ANTIGRAVITY_LOOPBACK_HOST = '127.0.0.1' +const REQUEST_TIMEOUT_MS = 2_500 +const MAX_RESPONSE_BYTES = 1_048_576 + +export function postAntigravityLoopbackQuota( + url: string, + timeoutMs = REQUEST_TIMEOUT_MS, + maxBytes = MAX_RESPONSE_BYTES +): Promise { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return Promise.resolve(null) + } + if ( + (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || + parsed.hostname !== ANTIGRAVITY_LOOPBACK_HOST + ) { + return Promise.resolve(null) + } + const isHttps = parsed.protocol === 'https:' + const requestOptions: https.RequestOptions = { + protocol: parsed.protocol, + hostname: ANTIGRAVITY_LOOPBACK_HOST, + port: parsed.port, + path: `${parsed.pathname}${parsed.search}`, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' } + } + // Why: Agy presents a self-signed cert on 127.0.0.1 only. + if (isHttps) { + requestOptions.rejectUnauthorized = false + } + return new Promise((resolve) => { + let settled = false + let request: http.ClientRequest | undefined + const chunks: Buffer[] = [] + let bytes = 0 + const finish = (value: unknown) => { + if (!settled) { + settled = true + clearTimeout(deadline) + request?.destroy() + resolve(value) + } + } + const deadline = setTimeout(() => finish(null), timeoutMs) + request = (isHttps ? https : http).request(requestOptions, (response) => { + response.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + bytes += buffer.length + if (bytes > maxBytes) { + finish(null) + return + } + chunks.push(buffer) + }) + response.on('end', () => { + if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300) { + finish(null) + return + } + try { + finish(JSON.parse(Buffer.concat(chunks).toString('utf8'))) + } catch { + finish(null) + } + }) + response.on('error', () => finish(null)) + response.on('aborted', () => finish(null)) + }) + request.on('error', () => finish(null)) + request.end('{"forceRefresh":true}') + }) +} 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..d7b2f0adf85c --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -0,0 +1,302 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { once } from 'node:events' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + ANTIGRAVITY_USAGE_PROBE_FAILED, + ANTIGRAVITY_USAGE_UNAVAILABLE, + fetchAntigravityRateLimits, + mapAntigravityQuotaSummary, + parseAntigravityLanguageServerLog +} from './antigravity-usage-fetcher' + +const GROUPED_SUMMARY = { + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-weekly', + displayName: 'Weekly Limit Remaining', + remainingFraction: 0.8, + resetTime: '2026-09-20T12:00:00Z' + }, + { + bucketId: 'gemini-5h', + displayName: 'Five Hour Limit Remaining', + remainingFraction: 0.4, + resetTime: '2026-09-16T01:00:00Z' + } + ] + }, + { + displayName: 'Claude and GPT models', + buckets: [ + { + bucketId: 'claude-gpt-weekly', + displayName: 'Weekly Limit Remaining', + remainingFraction: 0.95, + resetTime: '2026-09-20T12:00:00Z' + }, + { + bucketId: 'claude-gpt-5h', + displayName: 'Five Hour Limit Remaining', + remainingFraction: 0.7, + resetTime: '2026-09-16T01:00:00Z' + } + ] + } + ] + } +} + +const LIVE_LOG = [ + 'Starting language server process with pid 4242', + 'listening on random port at 4312 for HTTP', + 'listening on random port at 4313 for HTTPS (gRPC)' +].join('\n') + +const QUOTA_25_PERCENT_USED = { + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-5h', + displayName: 'Five Hour Limit Remaining', + remainingFraction: 0.75, + resetTime: '2026-09-16T01:00:00Z' + } + ] + } + ] + } +} + +/** Above pid_max on every supported POSIX host, so kill(pid, 0) is a real ESRCH. */ +const EXITED_SESSION_PID = 2_147_483_647 + +function languageServerLog(pid: number, httpPort: number): string { + return [ + `Starting language server process with pid ${pid}`, + `listening on random port at ${httpPort} for HTTP` + ].join('\n') +} + +describe('parseAntigravityLanguageServerLog', () => { + it('reads pid and both loopback ports from an Agy startup banner', () => { + expect(parseAntigravityLanguageServerLog(LIVE_LOG)).toEqual({ + pid: 4242, + httpPort: 4312, + httpsPort: 4313 + }) + }) +}) + +describe('mapAntigravityQuotaSummary', () => { + it('maps native grouped remainingFraction buckets onto session and weekly', () => { + const limits = mapAntigravityQuotaSummary(GROUPED_SUMMARY) + expect(limits?.provider).toBe('antigravity') + expect(limits?.status).toBe('ok') + expect(limits?.session?.usedPercent).toBe(60) + expect(limits?.session?.windowMinutes).toBe(300) + expect(limits?.weekly?.usedPercent).toBe(20) + expect(limits?.weekly?.windowMinutes).toBe(10_080) + }) + + it('ignores disabled buckets', () => { + const limits = mapAntigravityQuotaSummary({ + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-5h', + displayName: 'Five Hour Limit Remaining', + remainingFraction: 0.1, + disabled: true + }, + { + bucketId: 'gemini-weekly', + displayName: 'Weekly Limit Remaining', + remainingFraction: 0.5 + } + ] + } + ] + } + }) + expect(limits?.session).toBeNull() + expect(limits?.weekly?.usedPercent).toBe(50) + }) +}) + +describe('fetchAntigravityRateLimits', () => { + const endpoint = { + pid: 4242, + httpPort: 4312, + httpsPort: 4313 + } + + it('POSTs the quota RPC to the current live loopback endpoint and maps the summary', async () => { + const posted: string[] = [] + const limits = await fetchAntigravityRateLimits({ + endpoint, + postQuota: async (url) => { + posted.push(url) + expect(url.startsWith('http://127.0.0.1:')).toBe(true) + return GROUPED_SUMMARY + } + }) + expect(posted).toEqual([ + 'http://127.0.0.1:4312/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' + ]) + expect(limits.status).toBe('ok') + expect(limits.session?.usedPercent).toBe(60) + }) + + it('reports unavailable when no local LanguageServer is running', async () => { + const limits = await fetchAntigravityRateLimits({ + endpoint: null, + postQuota: async () => { + throw new Error('must not probe loopback when no LanguageServer is running') + } + }) + expect(limits.status).toBe('unavailable') + expect(limits.provider).toBe('antigravity') + expect(limits.error).toBe(ANTIGRAVITY_USAGE_UNAVAILABLE) + expect(limits.usageMetadata?.failureKind).toBe('cli-unavailable') + expect(limits.session).toBeNull() + expect(limits.weekly).toBeNull() + }) + + it('reports a probe error when a LanguageServer is found but quota is not returned', async () => { + const limits = await fetchAntigravityRateLimits({ + endpoint, + postQuota: async () => null + }) + expect(limits.status).toBe('error') + expect(limits.error).toBe(ANTIGRAVITY_USAGE_PROBE_FAILED) + expect(limits.usageMetadata?.failureKind).toBe('network') + expect(limits.session).toBeNull() + }) + + it('falls back to the HTTPS loopback port when HTTP does not answer', async () => { + const posted: string[] = [] + const limits = await fetchAntigravityRateLimits({ + endpoint, + postQuota: async (url) => { + posted.push(url) + if (url.startsWith('http://')) { + return null + } + return GROUPED_SUMMARY + } + }) + expect(posted).toEqual([ + 'http://127.0.0.1:4312/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary', + 'https://127.0.0.1:4313/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' + ]) + expect(limits.status).toBe('ok') + }) +}) + +async function listen( + handler: (request: IncomingMessage, response: ServerResponse) => void +): Promise<{ port: number; close: () => Promise }> { + const server = createServer(handler) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('expected a TCP listen address') + } + return { + port: address.port, + close: async () => { + server.closeAllConnections() + server.close() + await once(server, 'close') + } + } +} + +describe('Antigravity loopback quota request limits', () => { + it('aborts an oversized quota response and reports a probe failure', async () => { + const { port, close } = await listen((_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end('{"response":{"groups":[]}}'.padEnd(64, '0')) + }) + try { + const limits = await fetchAntigravityRateLimits({ + endpoint: { pid: 4242, httpPort: port, httpsPort: null }, + maxResponseBytes: 16 + }) + expect(limits.status).toBe('error') + expect(limits.error).toBe(ANTIGRAVITY_USAGE_PROBE_FAILED) + } finally { + await close() + } + }) + + it('aborts a continuously streaming quota response on the wall-clock deadline', async () => { + const { port, close } = await listen((_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }) + const timer = setInterval(() => { + response.write(' ') + }, 10) + response.on('close', () => clearInterval(timer)) + }) + try { + const started = Date.now() + const limits = await fetchAntigravityRateLimits({ + endpoint: { pid: 4242, httpPort: port, httpsPort: null }, + requestTimeoutMs: 80 + }) + expect(Date.now() - started).toBeLessThan(1_000) + expect(limits.status).toBe('error') + expect(limits.error).toBe(ANTIGRAVITY_USAGE_PROBE_FAILED) + } finally { + await close() + } + }) +}) + +describe('Antigravity LanguageServer log discovery', () => { + it('still finds an older live session after 12 newer exited session logs', async () => { + expect(() => process.kill(EXITED_SESSION_PID, 0)).toThrow() + const home = mkdtempSync(join(tmpdir(), 'orca-antigravity-usage-')) + const logDir = join(home, '.gemini', 'antigravity-cli', 'log') + mkdirSync(logDir, { recursive: true }) + let probeCount = 0 + const { port, close } = await listen((_request, response) => { + probeCount += 1 + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(QUOTA_25_PERCENT_USED)) + }) + try { + writeFileSync( + join(logDir, 'cli-2026-01-01T00-00-00.log'), + languageServerLog(process.pid, port) + ) + for (let index = 0; index < 12; index += 1) { + writeFileSync( + join(logDir, `cli-2026-09-19T00-00-${String(index).padStart(2, '0')}.log`), + languageServerLog(EXITED_SESSION_PID, 65_000) + ) + } + const limits = await fetchAntigravityRateLimits({ homedir: () => home }) + expect(limits.status).toBe('ok') + expect(limits.session?.usedPercent).toBe(25) + expect(probeCount).toBe(1) + } finally { + await close() + rmSync(home, { recursive: true, force: true }) + } + }) +}) 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..80e04968afce --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -0,0 +1,257 @@ +import { closeSync, openSync, readSync, readdirSync } from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { + ANTIGRAVITY_LOOPBACK_HOST, + postAntigravityLoopbackQuota +} from './antigravity-loopback-quota' + +const QUOTA_PATH = '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' + +export const ANTIGRAVITY_USAGE_UNAVAILABLE = + 'Antigravity usage is not available. Start agy so Orca can read its quota.' +export const ANTIGRAVITY_USAGE_PROBE_FAILED = + 'Antigravity usage is not available. The local Agy LanguageServer did not return quota.' + +export type AntigravityLanguageServerEndpoint = { + pid: number + httpPort: number | null + httpsPort: number | null +} + +export type AntigravityUsageFetchDeps = { + endpoint?: AntigravityLanguageServerEndpoint | null + homedir?: () => string + postQuota?: (url: string) => Promise + now?: () => number + requestTimeoutMs?: number + maxResponseBytes?: number +} + +type CadenceWindow = RateLimitWindow & { cadence: 'session' | 'weekly' } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function failedLimits( + now: number, + error: string, + status: 'unavailable' | 'error', + failureKind: 'cli-unavailable' | 'network' +): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: now, + error, + status, + usageMetadata: { source: 'cli', failureKind } + } +} + +export function parseAntigravityLanguageServerLog( + logHead: string +): AntigravityLanguageServerEndpoint | null { + const pid = Number(/Starting language server process with pid (\d+)/.exec(logHead)?.[1]) + if (!Number.isInteger(pid) || pid <= 0) { + return null + } + let httpPort: number | null = null + let httpsPort: number | null = null + const portPattern = /listening on random port at (\d+) for (HTTPS|HTTP)\b/g + for (const match of logHead.matchAll(portPattern)) { + const port = Number(match[1]) + if (!Number.isInteger(port) || port <= 0 || port > 65_535) { + continue + } + if (match[2] === 'HTTP') { + httpPort ??= port + } else { + httpsPort ??= port + } + } + if (httpPort === null && httpsPort === null) { + return null + } + return { pid, httpPort, httpsPort } +} + +function windowFromBucket(raw: unknown): CadenceWindow | null { + if (!isRecord(raw) || raw.disabled === true) { + return null + } + const remaining = raw.remainingFraction + if (typeof remaining !== 'number' || !Number.isFinite(remaining)) { + return null + } + const id = typeof raw.bucketId === 'string' ? raw.bucketId : '' + const name = typeof raw.displayName === 'string' ? raw.displayName : id + const text = `${id} ${name}`.toLowerCase() + const cadence: CadenceWindow['cadence'] | null = /weekly|7d|7-day/.test(text) + ? 'weekly' + : /5h|five[- ]hour/.test(text) + ? 'session' + : null + if (!cadence) { + return null + } + const resetTime = typeof raw.resetTime === 'string' ? Date.parse(raw.resetTime) : Number.NaN + return { + cadence, + usedPercent: Math.min(100, Math.max(0, Math.round((1 - remaining) * 100))), + windowMinutes: cadence === 'weekly' ? 10_080 : 300, + resetsAt: Number.isNaN(resetTime) ? null : resetTime, + resetDescription: null + } +} + +function tightest( + windows: CadenceWindow[], + cadence: CadenceWindow['cadence'] +): RateLimitWindow | null { + let chosen: CadenceWindow | null = null + for (const window of windows) { + if (window.cadence === cadence && (!chosen || window.usedPercent > chosen.usedPercent)) { + chosen = window + } + } + return chosen + ? { + usedPercent: chosen.usedPercent, + windowMinutes: chosen.windowMinutes, + resetsAt: chosen.resetsAt, + resetDescription: chosen.resetDescription + } + : null +} + +export function mapAntigravityQuotaSummary(data: unknown): ProviderRateLimits | null { + if (!isRecord(data)) { + return null + } + const response = isRecord(data.response) ? data.response : null + const groups = Array.isArray(response?.groups) ? response.groups : null + if (!groups) { + return null + } + const windows: CadenceWindow[] = [] + for (const group of groups) { + if (!isRecord(group) || !Array.isArray(group.buckets)) { + continue + } + for (const raw of group.buckets) { + const window = windowFromBucket(raw) + if (window) { + windows.push(window) + } + } + } + const session = tightest(windows, 'session') + const weekly = tightest(windows, 'weekly') + if (!session && !weekly) { + return null + } + return { + provider: 'antigravity', + session, + weekly, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { source: 'cli', lastSuccessfulSource: 'cli' } + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'EPERM') + } +} + +function discoverCurrentEndpoint( + resolveHome: () => string = homedir +): AntigravityLanguageServerEndpoint | null { + const logDir = path.join(resolveHome(), '.gemini', 'antigravity-cli', 'log') + let names: string[] + try { + names = readdirSync(logDir) + } catch { + return null + } + // Why: recency is only search order; a live LanguageServer can outlive newer exited logs. + names = names + .filter((name) => name.startsWith('cli-') && name.endsWith('.log')) + .sort() + .toReversed() + for (const name of names) { + try { + const fd = openSync(path.join(logDir, name), 'r') + try { + const buffer = Buffer.alloc(8_192) + const bytesRead = readSync(fd, buffer, 0, 8_192, 0) + const endpoint = parseAntigravityLanguageServerLog( + buffer.subarray(0, bytesRead).toString('utf8') + ) + if (endpoint && isProcessAlive(endpoint.pid)) { + return endpoint + } + } finally { + closeSync(fd) + } + } catch { + continue + } + } + return null +} + +async function probeQuota( + endpoint: AntigravityLanguageServerEndpoint, + postQuota: (url: string) => Promise, + now: () => number +): Promise { + const urls = [ + ...(endpoint.httpPort === null + ? [] + : [`http://${ANTIGRAVITY_LOOPBACK_HOST}:${endpoint.httpPort}${QUOTA_PATH}`]), + ...(endpoint.httpsPort === null + ? [] + : [`https://${ANTIGRAVITY_LOOPBACK_HOST}:${endpoint.httpsPort}${QUOTA_PATH}`]) + ] + for (const url of urls) { + try { + const mapped = mapAntigravityQuotaSummary(await postQuota(url)) + if (mapped) { + return mapped + } + } catch { + continue + } + } + return failedLimits(now(), ANTIGRAVITY_USAGE_PROBE_FAILED, 'error', 'network') +} + +export function fetchAntigravityRateLimits( + deps: AntigravityUsageFetchDeps = {} +): Promise { + const now = deps.now ?? Date.now + const endpoint = + deps.endpoint !== undefined ? deps.endpoint : discoverCurrentEndpoint(deps.homedir) + if (!endpoint) { + return Promise.resolve( + failedLimits(now(), ANTIGRAVITY_USAGE_UNAVAILABLE, 'unavailable', 'cli-unavailable') + ) + } + return probeQuota( + endpoint, + deps.postQuota ?? + ((url) => postAntigravityLoopbackQuota(url, deps.requestTimeoutMs, deps.maxResponseBytes)), + now + ) +} 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/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index 041ede99b628..bc0cf109f2ce 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -2,6 +2,7 @@ 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' @@ -89,6 +90,9 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) + vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () => + unavailableProvider('antigravity') + ) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -106,6 +110,7 @@ export function resetRateLimitProviderMocks(): void { error: null, status: 'unavailable' }) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) } 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 9241831bdf6a..7e7a45e476fc 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -24,6 +24,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index e0975af0db07..e3a0a16687f2 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -1,12 +1,17 @@ +import fs from 'node:fs' +import http from 'node:http' +import https from 'node:https' 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 { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { errorProvider, okProvider, - resetRateLimitProviderMocks + resetRateLimitProviderMocks, + unavailableProvider } from './rate-limit-service-test-harness' vi.mock('./claude-fetcher', () => ({ @@ -23,6 +28,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) @@ -54,47 +63,53 @@ describe('RateLimitService Antigravity usage', () => { vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) }) - it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { + it('does not copy a Gemini failure onto Antigravity', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValue( errorProvider('gemini', 'Gemini project ID not found') ) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 18)) 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.antigravity?.status).toBe('ok') + expect(state.antigravity?.session?.usedPercent).toBe(18) 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 () => { + it('keeps Antigravity unavailable when the local LanguageServer is not running', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + unavailableProvider('antigravity', 'Antigravity usage is not available') + ) const service = new RateLimitService() await service.refresh() const state = service.getState() - expect(state.antigravity?.status).toBe('ok') - expect(state.antigravity?.provider).toBe('antigravity') - expect(state.antigravity?.session?.usedPercent).toBe(42) + expect(state.antigravity?.status).toBe('unavailable') + expect(state.antigravity?.session).toBeNull() + expect(state.gemini?.status).toBe('ok') + expect(state.gemini?.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() + it('shared harness stub prevents LanguageServer discovery and loopback probes', async () => { + const readdir = vi.spyOn(fs, 'readdirSync') + const httpRequest = vi.spyOn(http, 'request') + const httpsRequest = vi.spyOn(https, 'request') + try { + const service = new RateLimitService() + await service.refresh() + expect(vi.mocked(fetchAntigravityRateLimits)).toHaveBeenCalled() + expect(readdir).not.toHaveBeenCalled() + expect(httpRequest).not.toHaveBeenCalled() + expect(httpsRequest).not.toHaveBeenCalled() + } finally { + readdir.mockRestore() + httpRequest.mockRestore() + httpsRequest.mockRestore() + } }) }) 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 3c6293513826..517ece536dbe 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -31,6 +31,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) 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 668cfc113b9a..fdff98df95f1 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -28,6 +28,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 819b4c93f5d2..4ff0eace4c38 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -25,6 +25,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 44f4b05b3bac..4aa03e8b2f6e 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -32,6 +32,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index ec813a53d94a..8be43130df15 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -33,6 +33,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 7104face38fa..dd5f1ebe9ec5 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,5 +1,4 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' -import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' import type { ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { @@ -32,7 +31,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ geminiResult, opencodeGoResult, kimiResult, - miniMaxResult + miniMaxResult, + antigravityResult ], grokResultPromise } = prepared @@ -79,8 +79,20 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. - const antigravity = deriveAntigravityRateLimits(gemini) + const antigravity = + antigravityResult.status === 'fulfilled' + ? antigravityResult.value + : ({ + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + antigravityResult.reason instanceof Error + ? antigravityResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) const opencodeGo = opencodeGoResult.status === 'fulfilled' diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 7551652bca1d..62ffc5fa01fe 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -1,5 +1,6 @@ import { fetchClaudeRateLimits } from '../claude-fetcher' import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher' import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' @@ -36,6 +37,7 @@ export type FetchAllCyclePrepared = { PromiseSettledResult, PromiseSettledResult, PromiseSettledResult, + PromiseSettledResult, PromiseSettledResult ] grokResultPromise: Promise< @@ -138,42 +140,50 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ const claudeFetchGated = !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = - await Promise.allSettled([ - claudeFetchGated - ? Promise.resolve(previousState.claude as ProviderRateLimits) - : fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), - codexFetchGated - ? Promise.resolve(previousState.codex as ProviderRateLimits) - : (missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - })), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( - cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), - this.fetchKimiWithResolvedHome(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels, - endpointMode: miniMaxEndpoint, - apiKey: miniMaxApiKey - }) - ]) + const [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult, + antigravityResult + ] = await Promise.allSettled([ + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + this.fetchKimiWithResolvedHome(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels, + endpointMode: miniMaxEndpoint, + apiKey: miniMaxApiKey + }), + fetchAntigravityRateLimits() + ]) if (signal.aborted) { return null @@ -200,7 +210,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ geminiResult, opencodeGoResult, kimiResult, - miniMaxResult + miniMaxResult, + antigravityResult ], grokResultPromise } 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 ab2a0eca23f1..f186e37601b2 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 @@ -123,11 +123,7 @@ describe('hasUsageProviderSettings', () => { 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({ minimaxApiKeyConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) @@ -163,21 +159,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('treats a checked Antigravity item as the durable Antigravity signal', () => { expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(false) + ).toBe(true) expect( hasUsageProviderSettingsForProvider( 'antigravity', @@ -345,11 +333,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 the snapshot is pending when its status item is checked', () => { const visible = getVisibleUsageProvider( 'antigravity', null, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true }) ) expect(visible).toMatchObject({ provider: 'antigravity', @@ -359,24 +347,13 @@ 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. + it('hides Antigravity when its status item is not checked', () => { + expect(getVisibleUsageProvider('antigravity', null, usageSettings())).toBe(null) 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 }) + provider('unavailable', { provider: 'antigravity' }), + usageSettings() ) ).toBe(null) }) @@ -475,26 +452,6 @@ describe('isUsageEmptyState', () => { }) it('does not show the setup CTA while checked Antigravity usage is awaiting a snapshot', () => { - 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, - grok: provider('unavailable', { provider: 'grok' }), - minimax: provider('unavailable', { provider: 'minimax' }) - }, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: 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. expect( isUsageEmptyState( { @@ -509,6 +466,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 19258592f1fd..51c95c37d914 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 @@ -10,9 +10,7 @@ export type UsageProviderSettings = Pick< > & { // 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. + // this after PATH detection says the agent is available. antigravityUsageConfigured: boolean // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean @@ -75,8 +73,7 @@ export function hasUsageProviderSettings( (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?.minimaxApiKeyConfigured === true || settings?.grokAuthConfigured === true @@ -103,10 +100,7 @@ export function hasUsageProviderSettingsForProvider( 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 || settings.minimaxApiKeyConfigured === true 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..2f20c8a41f6b 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 @@ -6,7 +6,7 @@ describe('getUsageProviderAccountsSectionId', () => { 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..48b47e060876 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 @@ -9,8 +9,6 @@ export function getUsageProviderAccountsSectionId( case 'codex': return 'accounts-codex' case 'gemini': - case 'antigravity': - // Why: Antigravity usage currently shares Gemini's OAuth configuration. return 'accounts-gemini' case 'opencode-go': return 'accounts-opencode-go' @@ -18,8 +16,9 @@ export function getUsageProviderAccountsSectionId( return 'accounts-minimax' case 'grok': return 'accounts-grok' + case 'antigravity': case 'kimi': - // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. + // Why: Agy and Kimi own their CLI credential lifecycle outside Orca Accounts. return null } }