diff --git a/src/main/rate-limits/antigravity-local-quota.test.ts b/src/main/rate-limits/antigravity-local-quota.test.ts new file mode 100644 index 00000000000..c232c5de9b2 --- /dev/null +++ b/src/main/rate-limits/antigravity-local-quota.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +vi.mock('electron', () => ({ app: { getPath: vi.fn() } })) + +import { + fetchAntigravityLocalRateLimits, + getAntigravityCliLogDirectory, + getAntigravityLanguageServerLogPath, + parseAntigravityAppConfig, + parseAntigravityCliServerPorts, + parseAntigravityLanguageServerPort +} from './antigravity-local-quota' + +describe('Antigravity language-server discovery', () => { + it('falls past a newer stale AGY log to a live CLI quota service', async () => { + const homePath = await mkdtemp(join(tmpdir(), 'orca-antigravity-cli-')) + const logDirectory = getAntigravityCliLogDirectory(homePath) + let requestBody = '' + const server = createServer((request, response) => { + request.on('data', (chunk: Buffer) => { + requestBody += chunk.toString('utf8') + }) + request.on('end', () => { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end( + JSON.stringify({ + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-weekly', + displayName: 'Weekly Limit', + window: 'weekly', + remainingFraction: 0.92 + }, + { + bucketId: 'gemini-5h', + displayName: 'Five Hour Limit', + window: '5h', + remainingFraction: 1 + } + ] + } + ] + } + }) + ) + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP listener') + } + + try { + await mkdir(logDirectory, { recursive: true }) + await writeFile( + join(logDirectory, 'cli-20260714_123131.log'), + 'Language server listening on random port at 1 for HTTP' + ) + await writeFile( + join(logDirectory, 'cli-20260714_103225.log'), + `Language server listening on random port at ${address.port} for HTTP` + ) + + const result = await fetchAntigravityLocalRateLimits({ + homePath, + appDataPath: join(homePath, 'app-data') + }) + + expect(requestBody).toBe('{"forceRefresh":true}') + expect(result).toMatchObject({ + provider: 'gemini', + session: { usedPercent: 0 }, + weekly: { usedPercent: 8 }, + usageMetadata: { source: 'cli' } + }) + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + await rm(homePath, { recursive: true, force: true }) + } + }) + + it('uses AGY CLI logs beneath the cross-platform home directory', () => { + expect(getAntigravityCliLogDirectory('/home/lee')).toBe('/home/lee/.gemini/antigravity-cli/log') + }) + + it('uses Antigravity application log locations on desktop platforms', () => { + expect(getAntigravityLanguageServerLogPath('darwin', '/Users/lee', '/app-data')).toBe( + '/Users/lee/Library/Logs/Antigravity/language_server.log' + ) + expect(getAntigravityLanguageServerLogPath('linux', '/home/lee', '/home/lee/.config')).toBe( + '/home/lee/.config/Antigravity/logs/language_server.log' + ) + }) + + it('uses the most recent HTTPS port from a restarted language server', () => { + const log = [ + 'Language server listening on random port at 40100 for HTTPS (gRPC)', + 'Language server listening on random port at 40200 for HTTPS (gRPC)' + ].join('\n') + + expect(parseAntigravityLanguageServerPort(log)).toBe(40200) + expect(parseAntigravityLanguageServerPort('no listener')).toBeNull() + }) + + it('discovers both loopback ports exposed by the AGY CLI', () => { + const log = [ + 'Language server listening on random port at 58601 for HTTPS (gRPC)', + 'Language server listening on random port at 58602 for HTTP' + ].join('\n') + + expect(parseAntigravityCliServerPorts(log)).toEqual({ http: 58602, https: 58601 }) + expect(parseAntigravityCliServerPorts('no listener')).toEqual({ http: null, https: null }) + }) + + it('accepts only Antigravity app configuration with a CSRF token', () => { + expect( + parseAntigravityAppConfig( + '' + ) + ).toEqual({ productName: 'antigravity', csrfToken: 'token' }) + expect( + parseAntigravityAppConfig( + '' + ) + ).toBeNull() + }) +}) diff --git a/src/main/rate-limits/antigravity-local-quota.ts b/src/main/rate-limits/antigravity-local-quota.ts new file mode 100644 index 00000000000..eca1e3e2b74 --- /dev/null +++ b/src/main/rate-limits/antigravity-local-quota.ts @@ -0,0 +1,254 @@ +import { readFile, readdir } from 'node:fs/promises' +import { request as httpRequest } from 'node:http' +import { request as httpsRequest } from 'node:https' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { app } from 'electron' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { parseAntigravityQuotaSummary } from './antigravity-quota-summary' + +const LANGUAGE_SERVER_LOG_NAME = 'language_server.log' +const CLI_LOG_LIMIT = 12 +const REQUEST_TIMEOUT_MS = 3_000 +const MAX_RESPONSE_BYTES = 1024 * 1024 +const QUOTA_SUMMARY_PATH = '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' + +type LocalProtocol = 'http:' | 'https:' + +type AntigravityAppConfig = { + csrfToken: string + productName: string +} + +export type AntigravityServerPorts = { + http: number | null + https: number | null +} + +export type AntigravityLocalRateLimitOptions = { + homePath?: string + appDataPath?: string +} + +/** Converts a listener value into a valid TCP port. */ +function validPort(value: string | undefined): number | null { + const port = Number(value) + return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null +} + +/** Resolves AGY's per-user CLI log directory without platform-specific separators. */ +export function getAntigravityCliLogDirectory(homePath: string): string { + return join(homePath, '.gemini', 'antigravity-cli', 'log') +} + +/** Uses the newest listener announcement when a CLI log contains server restarts. */ +export function parseAntigravityCliServerPorts(log: string): AntigravityServerPorts { + const httpsMatches = [ + ...log.matchAll(/language server listening on (?:random port at )?(\d+) for HTTPS/gi) + ] + const httpMatches = [ + ...log.matchAll(/language server listening on (?:random port at )?(\d+) for HTTP(?!S)/gi) + ] + return { + http: validPort(httpMatches.at(-1)?.[1]), + https: validPort(httpsMatches.at(-1)?.[1]) + } +} + +/** Resolves the Antigravity desktop language-server log on supported platforms. */ +export function getAntigravityLanguageServerLogPath( + platform: NodeJS.Platform, + homePath: string, + appDataPath: string +): string { + return platform === 'darwin' + ? join(homePath, 'Library', 'Logs', 'Antigravity', LANGUAGE_SERVER_LOG_NAME) + : join(appDataPath, 'Antigravity', 'logs', LANGUAGE_SERVER_LOG_NAME) +} + +/** Returns the newest desktop HTTPS listener recorded in a language-server log. */ +export function parseAntigravityLanguageServerPort(log: string): number | null { + return parseAntigravityCliServerPorts(log).https +} + +/** Accepts CSRF configuration only from a page identifying itself as Antigravity. */ +export function parseAntigravityAppConfig(html: string): AntigravityAppConfig | null { + const configJson = html.match(/window\.__APP_CONFIG__\s*=\s*(\{.*?\});<\/script>/s)?.[1] + if (!configJson) { + return null + } + try { + const config = JSON.parse(configJson) as Record + return config.productName === 'antigravity' && + typeof config.csrfToken === 'string' && + config.csrfToken.length > 0 + ? { productName: config.productName, csrfToken: config.csrfToken } + : null + } catch { + return null + } +} + +/** Sends a bounded request to an Antigravity service on the loopback interface. */ +function requestLocalAntigravity( + protocol: LocalProtocol, + port: number, + path: string, + options?: { body?: string; csrfToken?: string } +): Promise { + return new Promise((resolve, reject) => { + const body = options?.body + const headers: Record = { Connection: 'close' } + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + headers['Content-Length'] = Buffer.byteLength(body) + headers['Connect-Protocol-Version'] = '1' + } + if (options?.csrfToken) { + headers['x-codeium-csrf-token'] = options.csrfToken + } + + // Why: older Antigravity servers expose only self-signed HTTPS. Restricting + // the exception to a fixed loopback host prevents it from reaching a network. + const request = protocol === 'https:' ? httpsRequest : httpRequest + const req = request( + { + protocol, + hostname: '127.0.0.1', + port, + path, + method: body === undefined ? 'GET' : 'POST', + headers, + rejectUnauthorized: protocol === 'https:' ? false : undefined, + timeout: REQUEST_TIMEOUT_MS + }, + (res) => { + const chunks: Buffer[] = [] + let byteLength = 0 + res.on('data', (chunk: Buffer) => { + byteLength += chunk.length + if (byteLength > MAX_RESPONSE_BYTES) { + req.destroy(new Error('Antigravity quota response exceeded size limit')) + return + } + chunks.push(chunk) + }) + res.on('end', () => { + const responseBody = Buffer.concat(chunks).toString('utf8') + if (res.statusCode !== 200) { + reject(new Error(`Antigravity quota request failed (${res.statusCode ?? 'unknown'})`)) + return + } + resolve(responseBody) + }) + } + ) + req.on('timeout', () => { + req.destroy(new Error('Antigravity quota request timed out')) + }) + req.on('error', reject) + req.end(body) + }) +} + +/** Parses a local quota response into Orca's provider rate-limit shape. */ +function parseQuotaResponse(response: string): ProviderRateLimits | null { + return parseAntigravityQuotaSummary(JSON.parse(response) as unknown) +} + +/** Searches recent CLI logs for the newest reachable Antigravity server. */ +async function fetchFromCliLogs(homePath: string): Promise { + const logDirectory = getAntigravityCliLogDirectory(homePath) + const entries = await readdir(logDirectory, { withFileTypes: true }) + const logNames = entries + .filter((entry) => entry.isFile() && /^cli-.*\.log$/i.test(entry.name)) + .map((entry) => entry.name) + .sort((a, b) => b.localeCompare(a)) + .slice(0, CLI_LOG_LIMIT) + const attemptedEndpoints = new Set() + + for (const logName of logNames) { + let log: string + try { + log = await readFile(join(logDirectory, logName), 'utf8') + } catch { + continue + } + const ports = parseAntigravityCliServerPorts(log) + const endpoints: { protocol: LocalProtocol; port: number | null }[] = [ + { protocol: 'http:', port: ports.http }, + { protocol: 'https:', port: ports.https } + ] + for (const endpoint of endpoints) { + if (!endpoint.port) { + continue + } + const endpointKey = `${endpoint.protocol}//127.0.0.1:${endpoint.port}` + if (attemptedEndpoints.has(endpointKey)) { + continue + } + attemptedEndpoints.add(endpointKey) + try { + const response = await requestLocalAntigravity( + endpoint.protocol, + endpoint.port, + QUOTA_SUMMARY_PATH, + { body: JSON.stringify({ forceRefresh: true }) } + ) + const parsed = parseQuotaResponse(response) + if (parsed) { + return { + ...parsed, + usageMetadata: { source: 'cli', attemptedSources: ['cli'] } + } + } + } catch { + // A newer one-shot AGY command can leave a stale log above a live session. + } + } + } + return null +} + +/** Reads quota from the Antigravity desktop language server. */ +async function fetchFromDesktopApp( + homePath: string, + appDataPath: string +): Promise { + const logPath = getAntigravityLanguageServerLogPath(process.platform, homePath, appDataPath) + const port = parseAntigravityLanguageServerPort(await readFile(logPath, 'utf8')) + if (!port) { + return null + } + const appConfig = parseAntigravityAppConfig(await requestLocalAntigravity('https:', port, '/')) + if (!appConfig) { + return null + } + const response = await requestLocalAntigravity('https:', port, QUOTA_SUMMARY_PATH, { + csrfToken: appConfig.csrfToken, + body: JSON.stringify({ forceRefresh: true }) + }) + return parseQuotaResponse(response) +} + +/** Prefers a live AGY CLI, then degrades through desktop and credential sources. */ +export async function fetchAntigravityLocalRateLimits( + options?: AntigravityLocalRateLimitOptions +): Promise { + const homePath = options?.homePath ?? homedir() + try { + const cliRateLimits = await fetchFromCliLogs(homePath) + if (cliRateLimits) { + return cliRateLimits + } + } catch { + // AGY is optional; the desktop language server may still be available. + } + + try { + return await fetchFromDesktopApp(homePath, options?.appDataPath ?? app.getPath('appData')) + } catch { + // Native keyring and Gemini OAuth sources remain the final fallback. + return null + } +} diff --git a/src/main/rate-limits/antigravity-oauth-keyring-read.test.ts b/src/main/rate-limits/antigravity-oauth-keyring-read.test.ts new file mode 100644 index 00000000000..82e4944e58d --- /dev/null +++ b/src/main/rate-limits/antigravity-oauth-keyring-read.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileAsyncMock, execFileMock, promisifyCustom } = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn(), + execFileMock: vi.fn(), + promisifyCustom: Symbol.for('nodejs.util.promisify.custom') +})) + +vi.mock('node:child_process', () => ({ + execFile: Object.assign(execFileMock, { + [promisifyCustom]: execFileAsyncMock + }) +})) + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +function encodedKeyringValue(): string { + return `go-keyring-base64:${Buffer.from( + JSON.stringify({ + token: { + access_token: 'access-token', + refresh_token: 'refresh-token', + expiry: '2026-07-14T14:00:00.000Z' + } + }) + ).toString('base64')}` +} + +describe('readAntigravityCredentials', () => { + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-14T12:00:00.000Z')) + execFileAsyncMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('reads and parses a native keyring value with bounded process options', async () => { + setPlatform('darwin') + execFileAsyncMock.mockResolvedValue({ stdout: encodedKeyringValue(), stderr: '' }) + const { readAntigravityCredentials } = await import('./antigravity-oauth-keyring') + + await expect(readAntigravityCredentials()).resolves.toEqual({ + access_token: 'access-token', + refresh_token: 'refresh-token', + expiry_date: Date.parse('2026-07-14T14:00:00.000Z') + }) + expect(execFileAsyncMock).toHaveBeenCalledWith( + 'security', + ['find-generic-password', '-s', 'gemini', '-a', 'antigravity', '-w'], + { + encoding: 'utf8', + maxBuffer: 16 * 1024, + timeout: 3_000, + windowsHide: true + } + ) + }) + + it('derives the Windows target and decodes credential-manager output', async () => { + setPlatform('win32') + execFileAsyncMock.mockResolvedValue({ + stdout: Buffer.from(encodedKeyringValue()).toString('base64'), + stderr: '' + }) + const { readAntigravityCredentials } = await import('./antigravity-oauth-keyring') + + await expect(readAntigravityCredentials()).resolves.toMatchObject({ + access_token: 'access-token' + }) + const command = execFileAsyncMock.mock.calls[0]?.[1]?.[3] as string + expect(command).toContain("CredRead('gemini:antigravity'") + expect(command).not.toContain('__ORCA_CREDENTIAL_TARGET__') + }) + + it('caches native keyring failures briefly before retrying', async () => { + setPlatform('darwin') + execFileAsyncMock.mockRejectedValue(new Error('keyring unavailable')) + const { readAntigravityCredentials } = await import('./antigravity-oauth-keyring') + + await expect(readAntigravityCredentials()).resolves.toBeNull() + await expect(readAntigravityCredentials()).resolves.toBeNull() + expect(execFileAsyncMock).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(60_001) + await expect(readAntigravityCredentials()).resolves.toBeNull() + expect(execFileAsyncMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/rate-limits/antigravity-oauth-keyring.test.ts b/src/main/rate-limits/antigravity-oauth-keyring.test.ts new file mode 100644 index 00000000000..79fd6a45c8a --- /dev/null +++ b/src/main/rate-limits/antigravity-oauth-keyring.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + getAntigravityKeyringCommand, + parseAntigravityKeyringCredentials +} from './antigravity-oauth-keyring' + +function encodedKeyringValue(value: unknown): string { + return `go-keyring-base64:${Buffer.from(JSON.stringify(value)).toString('base64')}` +} + +describe('parseAntigravityKeyringCredentials', () => { + it('parses the AGY native-keyring envelope', () => { + const result = parseAntigravityKeyringCredentials( + encodedKeyringValue({ + auth_method: 'consumer', + token: { + access_token: 'access-token', + refresh_token: 'refresh-token', + token_type: 'Bearer', + expiry: '2026-07-14T12:32:24.665308+01:00' + } + }) + ) + + expect(result).toEqual({ + access_token: 'access-token', + refresh_token: 'refresh-token', + expiry_date: Date.parse('2026-07-14T12:32:24.665308+01:00') + }) + }) + + it.each([ + '', + 'plain-text', + 'go-keyring-base64:not-base64-json', + encodedKeyringValue({ token: { access_token: 'access', expiry: '2026-07-14' } }), + encodedKeyringValue({ + token: { access_token: 'access', refresh_token: 'refresh', expiry: 'not-a-date' } + }) + ])('rejects malformed keyring data', (value) => { + expect(parseAntigravityKeyringCredentials(value)).toBeNull() + }) +}) + +describe('getAntigravityKeyringCommand', () => { + it('uses native keyring readers on each supported desktop platform', () => { + expect(getAntigravityKeyringCommand('darwin')).toMatchObject({ + command: 'security', + args: expect.arrayContaining(['gemini', 'antigravity']), + output: 'text' + }) + expect(getAntigravityKeyringCommand('linux')).toMatchObject({ + command: 'secret-tool', + args: ['lookup', 'service', 'gemini', 'username', 'antigravity'], + output: 'text' + }) + expect(getAntigravityKeyringCommand('win32')).toMatchObject({ + command: 'powershell.exe', + output: 'base64' + }) + }) + + it('returns null on unsupported platforms', () => { + expect(getAntigravityKeyringCommand('aix')).toBeNull() + }) +}) diff --git a/src/main/rate-limits/antigravity-oauth-keyring.ts b/src/main/rate-limits/antigravity-oauth-keyring.ts new file mode 100644 index 00000000000..bf34d45fffb --- /dev/null +++ b/src/main/rate-limits/antigravity-oauth-keyring.ts @@ -0,0 +1,186 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import type { GeminiCredentials } from './gemini-oauth-sources' + +const execFileAsync = promisify(execFile) +const KEYRING_SERVICE = 'gemini' +const KEYRING_ACCOUNT = 'antigravity' +const KEYRING_VALUE_PREFIX = 'go-keyring-base64:' +const KEYRING_TIMEOUT_MS = 3_000 +const KEYRING_MISS_CACHE_MS = 60_000 +const WINDOWS_TARGET_PLACEHOLDER = '__ORCA_CREDENTIAL_TARGET__' + +let keyringMissExpiresAt = 0 + +type KeyringCommand = { + command: string + args: string[] + output: 'text' | 'base64' +} + +const WINDOWS_CREDENTIAL_SCRIPT = String.raw` +$signature = @' +using System; +using System.Runtime.InteropServices; +public static class OrcaCredentialReader { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct Credential { + public uint Flags; + public uint Type; + public string TargetName; + public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public uint CredentialBlobSize; + public IntPtr CredentialBlob; + public uint Persist; + public uint AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; + } + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential); + + [DllImport("advapi32.dll")] + public static extern void CredFree(IntPtr credential); +} +'@ +Add-Type -TypeDefinition $signature +$pointer = [IntPtr]::Zero +if ([OrcaCredentialReader]::CredRead('${WINDOWS_TARGET_PLACEHOLDER}', 1, 0, [ref]$pointer)) { + try { + $credential = [Runtime.InteropServices.Marshal]::PtrToStructure( + $pointer, + [type][OrcaCredentialReader+Credential] + ) + $bytes = New-Object byte[] $credential.CredentialBlobSize + [Runtime.InteropServices.Marshal]::Copy( + $credential.CredentialBlob, + $bytes, + 0, + $credential.CredentialBlobSize + ) + [Convert]::ToBase64String($bytes) + } finally { + [OrcaCredentialReader]::CredFree($pointer) + } +} +`.trim() + +/** Builds the Windows credential reader from the same service and account used elsewhere. */ +function buildWindowsCredentialScript(): string { + return WINDOWS_CREDENTIAL_SCRIPT.replace( + WINDOWS_TARGET_PLACEHOLDER, + `${KEYRING_SERVICE}:${KEYRING_ACCOUNT}` + ) +} + +/** Keeps native credential-store access behind fixed, platform-specific commands. */ +export function getAntigravityKeyringCommand(platform: NodeJS.Platform): KeyringCommand | null { + switch (platform) { + case 'darwin': + return { + command: 'security', + args: ['find-generic-password', '-s', KEYRING_SERVICE, '-a', KEYRING_ACCOUNT, '-w'], + output: 'text' + } + case 'linux': + case 'freebsd': + case 'openbsd': + return { + command: 'secret-tool', + args: ['lookup', 'service', KEYRING_SERVICE, 'username', KEYRING_ACCOUNT], + output: 'text' + } + case 'win32': + return { + command: 'powershell.exe', + args: ['-NoProfile', '-NonInteractive', '-Command', buildWindowsCredentialScript()], + output: 'base64' + } + case 'aix': + case 'android': + case 'cygwin': + case 'haiku': + case 'netbsd': + case 'sunos': + return null + } +} + +/** Rejects incomplete or malformed keyring values before they reach Google APIs. */ +export function parseAntigravityKeyringCredentials(rawValue: string): GeminiCredentials | null { + const trimmed = rawValue.trim() + if (!trimmed.startsWith(KEYRING_VALUE_PREFIX)) { + return null + } + + try { + const decoded = Buffer.from(trimmed.slice(KEYRING_VALUE_PREFIX.length), 'base64').toString( + 'utf8' + ) + const parsed = JSON.parse(decoded) as { + token?: { + access_token?: unknown + refresh_token?: unknown + expiry?: unknown + } + } + const accessToken = parsed.token?.access_token + const refreshToken = parsed.token?.refresh_token + const expiry = parsed.token?.expiry + const expiryDate = typeof expiry === 'string' ? Date.parse(expiry) : Number.NaN + if ( + typeof accessToken !== 'string' || + accessToken.length === 0 || + typeof refreshToken !== 'string' || + refreshToken.length === 0 || + !Number.isFinite(expiryDate) + ) { + return null + } + + return { + access_token: accessToken, + refresh_token: refreshToken, + expiry_date: expiryDate + } + } catch { + return null + } +} + +/** Reads Antigravity credentials without making keyring availability a hard dependency. */ +export async function readAntigravityCredentials(): Promise { + const command = getAntigravityKeyringCommand(process.platform) + if (!command) { + return null + } + const now = Date.now() + if (now < keyringMissExpiresAt) { + return null + } + + try { + // Why: AGY moved OAuth state into the OS keyring, so the legacy Gemini file + // can stay expired even while Antigravity has a current session. + const { stdout } = await execFileAsync(command.command, command.args, { + encoding: 'utf8', + maxBuffer: 16 * 1024, + timeout: KEYRING_TIMEOUT_MS, + windowsHide: true + }) + const keyringValue = + command.output === 'base64' ? Buffer.from(stdout.trim(), 'base64').toString('utf8') : stdout + const credentials = parseAntigravityKeyringCredentials(keyringValue) + // Why: background refreshes should not repeatedly spawn native keyring tools + // when Antigravity is absent, locked, or contains an unreadable value. + keyringMissExpiresAt = credentials ? 0 : now + KEYRING_MISS_CACHE_MS + return credentials + } catch { + // A locked or unavailable keyring should fall through to legacy Gemini sources. + keyringMissExpiresAt = now + KEYRING_MISS_CACHE_MS + return null + } +} diff --git a/src/main/rate-limits/antigravity-quota-summary.test.ts b/src/main/rate-limits/antigravity-quota-summary.test.ts new file mode 100644 index 00000000000..ddd1ece8d75 --- /dev/null +++ b/src/main/rate-limits/antigravity-quota-summary.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { parseAntigravityQuotaSummary } from './antigravity-quota-summary' + +const quotaSummary = { + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-weekly', + displayName: 'Weekly Limit', + window: 'weekly', + remainingFraction: 0.916, + resetTime: '2026-07-16T21:59:04Z' + }, + { + bucketId: 'gemini-5h', + displayName: 'Five Hour Limit', + window: '5h', + remainingFraction: 1, + resetTime: '2026-07-14T16:30:11Z' + } + ] + }, + { + displayName: 'Claude and GPT models', + buckets: [ + { + bucketId: '3p-weekly', + displayName: 'Weekly Limit', + window: 'weekly', + remainingFraction: 0.988, + resetTime: '2026-07-21T11:28:50Z' + }, + { + bucketId: '3p-5h', + displayName: 'Five Hour Limit', + window: '5h', + remainingFraction: 0.964, + resetTime: '2026-07-14T16:28:50Z' + } + ] + } + ] + } +} + +describe('parseAntigravityQuotaSummary', () => { + it('preserves model families with their five-hour and weekly limits', () => { + const result = parseAntigravityQuotaSummary(quotaSummary, 1234) + + expect(result).toMatchObject({ + provider: 'gemini', + session: { usedPercent: 4, windowMinutes: 300 }, + weekly: { usedPercent: 8, windowMinutes: 10_080 }, + updatedAt: 1234, + status: 'ok' + }) + expect(result?.groups?.map((group) => group.id)).toEqual(['gemini-models', 'claude-gpt-models']) + expect(result?.groups?.[0]?.windows.map((window) => window.id)).toEqual(['session', 'weekly']) + expect(result?.groups?.[1]?.windows.map((window) => window.window.usedPercent)).toEqual([4, 1]) + }) + + it('rejects responses without usable groups', () => { + expect(parseAntigravityQuotaSummary({ response: { groups: [] } })).toBeNull() + expect(parseAntigravityQuotaSummary({ response: { groups: [{ displayName: 'Empty' }] } })).toBe( + null + ) + expect(parseAntigravityQuotaSummary(null)).toBeNull() + }) + + it('keeps usable windows when AGY omits reset metadata', () => { + const withoutReset = structuredClone(quotaSummary) + delete (withoutReset.response.groups[0].buckets[0] as { resetTime?: string }).resetTime + + expect( + parseAntigravityQuotaSummary(withoutReset)?.groups?.[0]?.windows[1]?.window + ).toMatchObject({ + usedPercent: 8, + resetsAt: null + }) + }) +}) diff --git a/src/main/rate-limits/antigravity-quota-summary.ts b/src/main/rate-limits/antigravity-quota-summary.ts new file mode 100644 index 00000000000..c2997fb86dd --- /dev/null +++ b/src/main/rate-limits/antigravity-quota-summary.ts @@ -0,0 +1,156 @@ +import type { + ProviderRateLimits, + RateLimitGroup, + RateLimitWindow +} from '../../shared/rate-limit-types' + +type QuotaSummaryBucket = { + bucketId: string + displayName: string + window: string + remainingFraction: number + resetTime: string | null +} + +type QuotaSummaryGroup = { + displayName: string + buckets: QuotaSummaryBucket[] +} + +/** Validates one quota-summary bucket from an untrusted local response. */ +function parseBucket(value: unknown): QuotaSummaryBucket | null { + if (!value || typeof value !== 'object') { + return null + } + const bucket = value as Record + if ( + typeof bucket.bucketId !== 'string' || + typeof bucket.displayName !== 'string' || + typeof bucket.window !== 'string' || + typeof bucket.remainingFraction !== 'number' || + !Number.isFinite(bucket.remainingFraction) || + (bucket.resetTime !== undefined && + bucket.resetTime !== null && + typeof bucket.resetTime !== 'string') + ) { + return null + } + return { + bucketId: bucket.bucketId, + displayName: bucket.displayName, + window: bucket.window, + remainingFraction: bucket.remainingFraction, + resetTime: typeof bucket.resetTime === 'string' ? bucket.resetTime : null + } +} + +/** Maps Antigravity bucket families to stable Orca group identifiers. */ +function getGroupId(group: QuotaSummaryGroup): string { + if (group.buckets.some((bucket) => bucket.bucketId.startsWith('gemini-'))) { + return 'gemini-models' + } + if (group.buckets.some((bucket) => bucket.bucketId.startsWith('3p-'))) { + return 'claude-gpt-models' + } + return group.displayName + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-|-$/g, '') +} + +/** Maps Antigravity window labels to Orca's session and weekly identifiers. */ +function getWindowId(bucket: QuotaSummaryBucket): 'session' | 'weekly' | null { + if (bucket.window === '5h' || bucket.bucketId.endsWith('-5h')) { + return 'session' + } + if (bucket.window === 'weekly' || bucket.bucketId.endsWith('-weekly')) { + return 'weekly' + } + return null +} + +/** Converts remaining quota into Orca's consumption-based window model. */ +function toRateLimitWindow(bucket: QuotaSummaryBucket): RateLimitWindow { + const remainingFraction = Math.min(1, Math.max(0, bucket.remainingFraction)) + const resetsAt = bucket.resetTime ? new Date(bucket.resetTime).getTime() : Number.NaN + return { + usedPercent: Math.round((1 - remainingFraction) * 100), + windowMinutes: getWindowId(bucket) === 'weekly' ? 10_080 : 300, + resetsAt: Number.isNaN(resetsAt) ? null : resetsAt, + resetDescription: null + } +} + +/** Parses one display group while discarding unsupported or malformed windows. */ +function parseGroup(value: unknown): RateLimitGroup | null { + if (!value || typeof value !== 'object') { + return null + } + const rawGroup = value as Record + if (typeof rawGroup.displayName !== 'string' || !Array.isArray(rawGroup.buckets)) { + return null + } + const parsedGroup: QuotaSummaryGroup = { + displayName: rawGroup.displayName, + buckets: rawGroup.buckets.map(parseBucket).filter((bucket) => bucket !== null) + } + const windows = parsedGroup.buckets + .map((bucket) => { + const id = getWindowId(bucket) + return id ? { id, name: bucket.displayName, window: toRateLimitWindow(bucket) } : null + }) + .filter((window) => window !== null) + .sort((a, b) => { + const rank = { session: 0, weekly: 1 } + return rank[a.id] - rank[b.id] + }) + if (windows.length === 0) { + return null + } + return { + id: getGroupId(parsedGroup), + name: parsedGroup.displayName, + windows + } +} + +/** Selects the highest-consumption window for the provider-level summary. */ +function mostConstrainedWindow(groups: RateLimitGroup[], id: string): RateLimitWindow | null { + const windows = groups.flatMap((group) => + group.windows.filter((entry) => entry.id === id).map((entry) => entry.window) + ) + return windows.reduce((worst, window) => { + return !worst || window.usedPercent > worst.usedPercent ? window : worst + }, null) +} + +/** Converts AGY's grouped remaining quotas into Orca's consumption-based model. */ +export function parseAntigravityQuotaSummary( + value: unknown, + updatedAt = Date.now() +): ProviderRateLimits | null { + if (!value || typeof value !== 'object') { + return null + } + const response = (value as { response?: unknown }).response + if (!response || typeof response !== 'object') { + return null + } + const rawGroups = (response as { groups?: unknown }).groups + if (!Array.isArray(rawGroups)) { + return null + } + const groups = rawGroups.map(parseGroup).filter((group) => group !== null) + if (groups.length === 0) { + return null + } + return { + provider: 'gemini', + session: mostConstrainedWindow(groups, 'session'), + weekly: mostConstrainedWindow(groups, 'weekly'), + groups, + updatedAt, + error: null, + status: 'ok' + } +} diff --git a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts index fddd0a77d59..7ad1b4b17c6 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts @@ -22,6 +22,14 @@ vi.mock('./gemini-cli-oauth-extractor', () => ({ extractOAuthClientCredentials: extractCredsMock })) +vi.mock('./antigravity-oauth-keyring', () => ({ + readAntigravityCredentials: vi.fn().mockResolvedValue(null) +})) + +vi.mock('./antigravity-local-quota', () => ({ + fetchAntigravityLocalRateLimits: vi.fn().mockResolvedValue(null) +})) + vi.mock('node:fs/promises', () => ({ readFile: readFileMock, // Why: saveGeminiCredentials is exercised on the refresh path. The atomic diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts index e8415c2e07e..99a5c4f876b 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.test.ts @@ -6,10 +6,18 @@ import { quotaResponse } from './gemini-usage-fetcher.test-fixtures' -const { readFileMock, extractCredsMock, netFetchMock } = vi.hoisted(() => ({ +const { + readFileMock, + extractCredsMock, + netFetchMock, + readAntigravityCredentialsMock, + fetchAntigravityLocalRateLimitsMock +} = vi.hoisted(() => ({ readFileMock: vi.fn(), extractCredsMock: vi.fn(), - netFetchMock: vi.fn() + netFetchMock: vi.fn(), + readAntigravityCredentialsMock: vi.fn(), + fetchAntigravityLocalRateLimitsMock: vi.fn() })) // Why: mock the extractor at the module boundary rather than re-routing every @@ -21,6 +29,14 @@ vi.mock('./gemini-cli-oauth-extractor', () => ({ extractOAuthClientCredentials: extractCredsMock })) +vi.mock('./antigravity-oauth-keyring', () => ({ + readAntigravityCredentials: readAntigravityCredentialsMock +})) + +vi.mock('./antigravity-local-quota', () => ({ + fetchAntigravityLocalRateLimits: fetchAntigravityLocalRateLimitsMock +})) + vi.mock('node:fs/promises', () => ({ readFile: readFileMock, writeFile: vi.fn().mockResolvedValue(undefined), @@ -37,6 +53,10 @@ describe('fetchGeminiRateLimits', () => { readFileMock.mockReset() extractCredsMock.mockReset() netFetchMock.mockReset() + readAntigravityCredentialsMock.mockReset() + readAntigravityCredentialsMock.mockResolvedValue(null) + fetchAntigravityLocalRateLimitsMock.mockReset() + fetchAntigravityLocalRateLimitsMock.mockResolvedValue(null) netFetchMock.mockImplementation((url: string) => { if (url.includes('loadCodeAssist')) { return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) @@ -73,6 +93,155 @@ describe('fetchGeminiRateLimits', () => { expect(result.status).toBe('unavailable') }) + it('uses Antigravity grouped quota before credential-based sources', async () => { + const groupedQuota = { + provider: 'gemini' as const, + session: { usedPercent: 4, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 8, windowMinutes: 10_080, resetsAt: null, resetDescription: null }, + groups: [], + updatedAt: Date.now(), + error: null, + status: 'ok' as const + } + fetchAntigravityLocalRateLimitsMock.mockResolvedValue(groupedQuota) + + const result = await fetchGeminiRateLimits(true) + + expect(result).toBe(groupedQuota) + expect(readAntigravityCredentialsMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('uses a current Antigravity keyring token before legacy Gemini files', async () => { + readAntigravityCredentialsMock.mockResolvedValue({ + access_token: 'agy-access-token', + refresh_token: 'agy-refresh-token', + expiry_date: Date.now() + 60_000 + }) + netFetchMock.mockImplementation((url: string) => { + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'agy-project' })) + } + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(makeResponse(quotaResponse)) + } + return Promise.resolve(makeResponse({}, 404)) + }) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('ok') + expect(readFileMock).not.toHaveBeenCalled() + expect(extractCredsMock).not.toHaveBeenCalled() + const quotaRequest = netFetchMock.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].includes('retrieveUserQuota') + ) + expect(quotaRequest).toBeDefined() + expect((quotaRequest![1] as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer agy-access-token' + }) + }) + + it('falls back to legacy Gemini credentials when the Antigravity token is expired', async () => { + readAntigravityCredentialsMock.mockResolvedValue({ + access_token: 'expired-agy-access-token', + refresh_token: 'agy-refresh-token', + expiry_date: Date.now() - 60_000 + }) + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string) => { + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'legacy-project' })) + } + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(makeResponse(quotaResponse)) + } + return Promise.resolve(makeResponse({}, 404)) + }) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('ok') + expect(readFileMock).toHaveBeenCalled() + const quotaRequest = netFetchMock.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].includes('retrieveUserQuota') + ) + expect((quotaRequest![1] as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer auth-json-access-token' + }) + }) + + it('falls back when a current Antigravity token cannot resolve a project', async () => { + readAntigravityCredentialsMock.mockResolvedValue({ + access_token: 'stale-agy-access-token', + refresh_token: 'agy-refresh-token', + expiry_date: Date.now() + 60_000 + }) + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string, options?: RequestInit) => { + const authorization = (options?.headers as Record | undefined)?.Authorization + if (url.includes('loadCodeAssist')) { + return authorization === 'Bearer stale-agy-access-token' + ? Promise.resolve(makeResponse({}, 500)) + : Promise.resolve(makeResponse({ cloudaicompanionProject: 'legacy-project' })) + } + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(makeResponse(quotaResponse)) + } + return Promise.resolve(makeResponse({}, 404)) + }) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('ok') + const quotaRequest = netFetchMock.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].includes('retrieveUserQuota') + ) + expect((quotaRequest![1] as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer auth-json-access-token' + }) + }) + + it('falls back when a current Antigravity token returns a quota error', async () => { + readAntigravityCredentialsMock.mockResolvedValue({ + access_token: 'stale-agy-access-token', + refresh_token: 'agy-refresh-token', + expiry_date: Date.now() + 60_000 + }) + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string, options?: RequestInit) => { + const authorization = (options?.headers as Record | undefined)?.Authorization + if (url.includes('loadCodeAssist')) { + return Promise.resolve( + makeResponse({ + cloudaicompanionProject: + authorization === 'Bearer stale-agy-access-token' + ? 'stale-agy-project' + : 'legacy-project' + }) + ) + } + if (url.includes('retrieveUserQuota')) { + return authorization === 'Bearer stale-agy-access-token' + ? Promise.resolve(makeResponse({}, 500)) + : Promise.resolve(makeResponse(quotaResponse)) + } + return Promise.resolve(makeResponse({}, 404)) + }) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('ok') + const quotaRequests = netFetchMock.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].includes('retrieveUserQuota') + ) + expect(quotaRequests).toHaveLength(2) + expect((quotaRequests[1]![1] as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer auth-json-access-token' + }) + }) + it('returns quota via auth.json', async () => { setupAuthJsonValid() netFetchMock.mockImplementation((url: string) => { diff --git a/src/main/rate-limits/gemini-usage-fetcher.ts b/src/main/rate-limits/gemini-usage-fetcher.ts index 1f214b4b5eb..1818aad6d93 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.ts @@ -9,6 +9,8 @@ import { type GeminiCredentials, type GoogleAuthEntry } from './gemini-oauth-sources' +import { readAntigravityCredentials } from './antigravity-oauth-keyring' +import { fetchAntigravityLocalRateLimits } from './antigravity-local-quota' import { buildRateLimitBucket, deduplicateBuckets, @@ -20,6 +22,7 @@ const RETRIEVE_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retri type QuotaBucket = { remainingFraction: number; resetTime: string; modelId: string } +/** Narrows raw quota entries before they are formatted for display. */ function isQuotaBucket(o: unknown): o is QuotaBucket { return ( typeof o === 'object' && @@ -31,6 +34,7 @@ function isQuotaBucket(o: unknown): o is QuotaBucket { ) } +/** Accepts both current wrapped quota responses and the legacy array shape. */ function parseQuotaResponse(data: unknown): QuotaBucket[] { let rawBuckets: unknown[] = [] if (Array.isArray(data)) { @@ -41,6 +45,7 @@ function parseQuotaResponse(data: unknown): QuotaBucket[] { return rawBuckets.filter((b) => isQuotaBucket(b)) } +/** Retrieves and normalizes quota buckets for one Google access token and project. */ async function fetchQuota(accessToken: string, projectId: string): Promise { const controller = new AbortController() const timeout = setTimeout(() => { @@ -81,6 +86,7 @@ async function fetchQuota(accessToken: string, projectId: string): Promise { + const credentials = await readAntigravityCredentials() + if (!credentials || credentials.expiry_date <= Date.now()) { + return null + } + + try { + // Why: AGY owns refreshing and persisting its native-keyring token. Using + // only a current token avoids writing Antigravity auth into Gemini's file. + const projectId = await loadProjectId(credentials.access_token).catch(() => { + return '' + }) + if (!projectId) { + return null + } + const result = await fetchQuota(credentials.access_token, projectId) + // Why: stale-but-current AGY credentials must not mask usable legacy + // Gemini sources when project or quota discovery fails. + return result.status === 'ok' ? result : null + } catch { + return null + } +} + +/** Discovers Gemini-compatible quotas in preferred-source order. */ export async function fetchGeminiRateLimits( geminiCliOAuthEnabled = false ): Promise { @@ -217,6 +250,16 @@ export async function fetchGeminiRateLimits( } try { + const antigravityRateLimits = await fetchAntigravityLocalRateLimits() + if (antigravityRateLimits) { + return antigravityRateLimits + } + + const antigravityKeyringRateLimits = await fetchViaAntigravityKeyring() + if (antigravityKeyringRateLimits) { + return antigravityKeyringRateLimits + } + const authJson = await readAuthJson() const result = authJson?.google?.type === 'oauth' diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index d694b39ffc0..8d93761c874 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -55,7 +55,8 @@ 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).toContain('Reads quota from a running Antigravity CLI or app for this device') + expect(markup).toContain('Credentials stay on this device') 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 5268a890e36..83fe95611cc 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -1381,13 +1381,14 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.96f3649526', - 'Use Gemini CLI credentials (experimental)' + 'Use Antigravity or Gemini CLI for usage (experimental)' )}

{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.', + 'Reads quota from a running Antigravity CLI or app for {{value0}}, with native keyring and local Gemini CLI credentials as fallbacks. Credentials stay on {{value0}} and are sent only to Google APIs. May break if Google changes these interfaces. Use at your own risk.', { value0: localAccountRuntimeSentenceLabel } )}

diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 6ff07366bc9..72f7aac6a80 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -100,13 +100,14 @@ export const getAccountsGeminiSearchEntries = createLocalizedCatalog(() => [ { title: translate( 'auto.components.settings.accounts.search.d819755b02', - 'Use Gemini CLI credentials' + 'Use Antigravity or Gemini CLI for usage' ), description: translate( 'auto.components.settings.accounts.search.bada4a3218', - 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google.' + 'Reads Antigravity quota locally, with native keyring and Gemini CLI credentials as fallbacks.' ), keywords: [ + ...translateSearchKeyword('auto.lib.agent.catalog.691dd11789', 'antigravity'), ...translateSearchKeyword('auto.components.settings.accounts.search.e8e1ff3887', 'gemini'), ...translateSearchKeyword('auto.components.settings.accounts.search.8630464352', 'cli'), ...translateSearchKeyword('auto.components.settings.accounts.search.933deaf732', 'oauth'), diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index d9387633fa0..9c95f1a92db 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -28,6 +28,7 @@ import { formatResetCountdown, getProviderUsageErrorMessage, getProviderUsageStatusLabel, + getWindowGroups, getWindowSections, ProviderIcon, ProviderPanel @@ -415,6 +416,62 @@ describe('getWindowSections', () => { }) }) +describe('getWindowGroups', () => { + it('localizes Antigravity model families and their quota windows', () => { + const fiveHour = { + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + const weekly = { + usedPercent: 8, + windowMinutes: 10_080, + resetsAt: null, + resetDescription: null + } + const p = provider({ + provider: 'antigravity', + status: 'ok', + session: fiveHour, + weekly, + groups: [ + { + id: 'gemini-models', + name: 'Gemini Models', + windows: [ + { id: 'session', name: 'Five Hour Limit', window: fiveHour }, + { id: 'weekly', name: 'Weekly Limit', window: weekly } + ] + }, + { + id: 'claude-gpt-models', + name: 'Claude and GPT models', + windows: [ + { id: 'session', name: 'Five Hour Limit', window: fiveHour }, + { id: 'weekly', name: 'Weekly Limit', window: weekly } + ] + } + ] + }) + + expect(getWindowGroups(p).map((group) => group.label)).toEqual([ + 'Gemini models', + 'Claude and GPT models' + ]) + expect(getWindowGroups(p)[0]?.windows.map((window) => window.label)).toEqual([ + 'Five-hour', + 'Weekly' + ]) + + const markup = renderToStaticMarkup(ProviderPanel({ p, usagePercentageDisplay: 'remaining' })) + expect(markup).toContain('Gemini models') + expect(markup).toContain('Claude and GPT models') + expect(markup.match(/Five-hour/g)).toHaveLength(2) + expect(markup.match(/92% left/g)).toHaveLength(2) + }) +}) + describe('ProviderPanel reset rendering', () => { it('renders the Fable reset countdown when Claude reports a reset timestamp', () => { vi.useFakeTimers() diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 849dc7beda3..46cf92aa1b8 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -1,4 +1,8 @@ -import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' +import type { + ProviderRateLimits, + RateLimitGroup, + RateLimitWindow +} from '../../../../shared/rate-limit-types' import { formatResetCountdown, formatResetDuration @@ -175,6 +179,53 @@ export function getWindowSections( return sections } +type GroupedWindowSection = { + id: string + label: string + window: RateLimitWindow +} + +type WindowGroupSection = { + id: string + label: string + windows: GroupedWindowSection[] +} + +/** Localizes known model-family labels while preserving server-provided names. */ +function getWindowGroupLabel(group: RateLimitGroup): string { + if (group.id === 'gemini-models') { + return translate('auto.components.status.bar.tooltip.3c639d6830', 'Gemini models') + } + if (group.id === 'claude-gpt-models') { + return translate('auto.components.status.bar.tooltip.8786272ed1', 'Claude and GPT models') + } + return group.name +} + +/** Localizes known window labels while preserving server-provided names. */ +function getGroupedWindowLabel(window: RateLimitGroup['windows'][number]): string { + if (window.id === 'session') { + return translate('auto.components.status.bar.tooltip.6ff013c8af', 'Five-hour') + } + if (window.id === 'weekly') { + return translate('auto.components.status.bar.tooltip.252c096536', 'Weekly') + } + return window.name +} + +/** Keeps provider group identifiers stable while localizing their visible labels. */ +export function getWindowGroups(p: ProviderRateLimits): WindowGroupSection[] { + return (p.groups ?? []).map((group) => ({ + id: group.id, + label: getWindowGroupLabel(group), + windows: group.windows.map((window) => ({ + id: window.id, + label: getGroupedWindowLabel(window), + window: window.window + })) + })) +} + // --------------------------------------------------------------------------- // Tooltip — progress bar section for a single window // --------------------------------------------------------------------------- @@ -267,6 +318,7 @@ export function ProviderPanel({ resetCreditCount != null ? formatResetCreditExpiry(p.rateLimitResetCredits?.nextExpiresAt, resetCreditCount) : null + const windowGroups = getWindowGroups(p) const PanelWindowSection = ({ w, @@ -327,9 +379,22 @@ export function ProviderPanel({
- {getWindowSections(p).map((s) => ( - - ))} + {windowGroups.length > 0 + ? windowGroups.map((group) => ( +
+
+ {group.label} +
+ {group.windows.map((window) => ( + + ))} +
+ )) + : getWindowSections(p).map((s) => ( + + ))} {p.error ? ( >