From deeb3c990aa1cd7a8f5498ef37b9d633dce95e4e Mon Sep 17 00:00:00 2001 From: bluetomlee Date: Sat, 12 Sep 2026 21:26:11 +0800 Subject: [PATCH 1/4] feat(accounts): add Antigravity CLI account and quota display, remove Gemini --- src/main/antigravity-accounts/index.ts | 1 + src/main/antigravity-accounts/status.test.ts | 96 ++++++ src/main/antigravity-accounts/status.ts | 107 +++++++ src/main/ipc/antigravity-accounts.ts | 6 + .../register-core-handlers.test.ts | 7 + .../register-core-handlers.ts | 2 + .../rate-limits/antigravity-oauth-sources.ts | 191 ++++++++++++ .../antigravity-usage-fetcher.test.ts | 106 +++++++ .../rate-limits/antigravity-usage-fetcher.ts | 246 +++++++++++++++ .../antigravity-usage-mirror.test.ts | 70 ----- .../rate-limits/antigravity-usage-mirror.ts | 29 -- .../codex-fetcher-pty-settle.test.ts | 1 + .../rate-limit-service-test-harness.ts | 19 +- .../service-antigravity-usage.test.ts | 47 ++- .../service-refresh-orchestration.test.ts | 56 ++-- .../service-window-activation.test.ts | 14 +- .../service/service-configuration.ts | 10 +- .../service/service-full-cycle-application.ts | 21 +- .../service/service-full-cycle-preparation.ts | 84 +++--- .../rate-limits/service/service-polling.ts | 1 - src/main/rate-limits/service/service-state.ts | 6 +- src/main/rate-limits/service/service-types.ts | 11 +- .../startup/main-process-account-services.ts | 6 +- src/preload/api-types.ts | 2 + src/preload/api/agent-account-api.ts | 6 +- .../api/antigravity-accounts-bridge.ts | 8 + src/preload/index.ts | 2 + .../components/settings/AccountsPane.test.tsx | 2 +- .../src/components/settings/AccountsPane.tsx | 14 +- .../AntigravityAccountsSection.test.tsx | 199 ++++++++++++ .../settings/AntigravityAccountsSection.tsx | 285 ++++++++++++++++++ .../AppearanceWindowSidebarSection.tsx | 1 - ...ccounts-pane-provider-setting-sections.tsx | 74 +---- .../settings/accounts-search.test.ts | 29 +- .../components/settings/accounts-search.ts | 23 +- .../settings/appearance-status-bar-search.ts | 29 -- .../status-bar/StatusBarVisibilityMenu.tsx | 14 +- .../status-bar-agent-gating.test.ts | 3 - .../status-bar/status-bar-agent-gating.ts | 1 - .../status-bar-provider-visibility.test.ts | 50 ++- .../status-bar-provider-visibility.ts | 23 +- .../src/components/status-bar/tooltip.tsx | 5 +- .../usage-provider-settings-target.test.ts | 4 +- .../usage-provider-settings-target.ts | 7 +- .../status-bar/use-status-bar-controller.ts | 13 +- .../web/preload-api/web-agent-accounts-api.ts | 14 + src/renderer/src/web/web-preload-api.ts | 2 + src/shared/default-global-settings.ts | 2 +- src/shared/global-settings-types.ts | 6 +- src/shared/rate-limit-types.ts | 7 + src/shared/telemetry-property-schemas.ts | 2 +- 51 files changed, 1534 insertions(+), 430 deletions(-) create mode 100644 src/main/antigravity-accounts/index.ts create mode 100644 src/main/antigravity-accounts/status.test.ts create mode 100644 src/main/antigravity-accounts/status.ts create mode 100644 src/main/ipc/antigravity-accounts.ts create mode 100644 src/main/rate-limits/antigravity-oauth-sources.ts create mode 100644 src/main/rate-limits/antigravity-usage-fetcher.test.ts create mode 100644 src/main/rate-limits/antigravity-usage-fetcher.ts delete mode 100644 src/main/rate-limits/antigravity-usage-mirror.test.ts delete mode 100644 src/main/rate-limits/antigravity-usage-mirror.ts create mode 100644 src/preload/api/antigravity-accounts-bridge.ts create mode 100644 src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx create mode 100644 src/renderer/src/components/settings/AntigravityAccountsSection.tsx diff --git a/src/main/antigravity-accounts/index.ts b/src/main/antigravity-accounts/index.ts new file mode 100644 index 000000000000..029bc67f1b90 --- /dev/null +++ b/src/main/antigravity-accounts/index.ts @@ -0,0 +1 @@ +export { getAntigravityAccountStatus } from './status' diff --git a/src/main/antigravity-accounts/status.test.ts b/src/main/antigravity-accounts/status.test.ts new file mode 100644 index 000000000000..d47e84c69074 --- /dev/null +++ b/src/main/antigravity-accounts/status.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getAntigravityAccountStatus } from './status' +import { access, readFile } from 'node:fs/promises' + +vi.mock('node:fs/promises', () => ({ + access: vi.fn(), + readFile: vi.fn() +})) + +describe('getAntigravityAccountStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(access).mockRejectedValue(new Error('ENOENT')) + }) + + it('reports unsigned status when no credentials or accounts exist', async () => { + const status = await getAntigravityAccountStatus() + expect(status).toEqual({ + signedIn: false, + email: null, + tokenFresh: false, + error: null + }) + }) + + it('reports signed in with email when google_accounts.json has active user', async () => { + vi.mocked(access).mockImplementation(async (p) => { + if (String(p).includes('google_accounts.json')) { + return undefined + } + throw new Error('ENOENT') + }) + vi.mocked(readFile).mockImplementation(async (p) => { + if (String(p).includes('google_accounts.json')) { + return JSON.stringify({ active: 'test@gmail.com', old: [] }) + } + throw new Error('ENOENT') + }) + + const status = await getAntigravityAccountStatus() + expect(status).toEqual({ + signedIn: true, + email: 'test@gmail.com', + tokenFresh: false, + error: null + }) + }) + + it('reports signed in with tokenFresh true when oauth_creds.json is not expired', async () => { + vi.mocked(access).mockResolvedValue(undefined) + vi.mocked(readFile).mockImplementation(async (p) => { + if (String(p).includes('google_accounts.json')) { + return JSON.stringify({ active: 'test@gmail.com' }) + } + if (String(p).includes('oauth_creds.json')) { + return JSON.stringify({ + access_token: 'valid_access_token', + expiry_date: Date.now() + 3600_000 + }) + } + throw new Error('ENOENT') + }) + + const status = await getAntigravityAccountStatus() + expect(status).toEqual({ + signedIn: true, + email: 'test@gmail.com', + tokenFresh: true, + error: null + }) + }) + + it('reports tokenFresh false when oauth_creds.json is expired', async () => { + vi.mocked(access).mockResolvedValue(undefined) + vi.mocked(readFile).mockImplementation(async (p) => { + if (String(p).includes('google_accounts.json')) { + return JSON.stringify({ active: 'test@gmail.com' }) + } + if (String(p).includes('oauth_creds.json')) { + return JSON.stringify({ + access_token: 'expired_access_token', + expiry_date: Date.now() - 3600_000 + }) + } + throw new Error('ENOENT') + }) + + const status = await getAntigravityAccountStatus() + expect(status).toEqual({ + signedIn: true, + email: 'test@gmail.com', + tokenFresh: false, + error: null + }) + }) +}) diff --git a/src/main/antigravity-accounts/status.ts b/src/main/antigravity-accounts/status.ts new file mode 100644 index 000000000000..67706c233076 --- /dev/null +++ b/src/main/antigravity-accounts/status.ts @@ -0,0 +1,107 @@ +import { access, readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' +import type { AntigravityAccountStatus } from '../../shared/rate-limit-types' + +async function fileExists(filePath: string): Promise { + try { + await access(filePath) + return true + } catch { + return false + } +} + +export async function getAntigravityAccountStatus(): Promise { + const home = homedir() + const googleAccountsPaths = [ + path.join(home, '.gemini', 'google_accounts.json'), + path.join(home, '.antigravity', 'google_accounts.json') + ] + + let email: string | null = null + for (const accPath of googleAccountsPaths) { + if (await fileExists(accPath)) { + try { + const content = await readFile(accPath, 'utf-8') + const data = JSON.parse(content) as { active?: string } + if (data.active && typeof data.active === 'string') { + email = data.active + break + } + } catch { + // ignore parse error, check next + } + } + } + + const credPaths = [ + path.join(home, '.gemini', 'oauth_creds.json'), + path.join(home, '.antigravity', 'oauth_creds.json') + ] + + let hasCreds = false + let tokenFresh = false + + for (const credPath of credPaths) { + if (await fileExists(credPath)) { + try { + const content = await readFile(credPath, 'utf-8') + const data = JSON.parse(content) as { + access_token?: string + refresh_token?: string + expiry_date?: number + } + if (data.access_token || data.refresh_token) { + hasCreds = true + if (typeof data.expiry_date === 'number' && data.expiry_date > Date.now()) { + tokenFresh = true + } + break + } + } catch { + // ignore parse error + } + } + } + + // Also check OpenCode auth.json fallback + if (!hasCreds && !email) { + const opencodeCandidates = [ + process.env.APPDATA ? path.join(process.env.APPDATA, 'opencode', 'auth.json') : null, + process.env.XDG_DATA_HOME + ? path.join(process.env.XDG_DATA_HOME, 'opencode', 'auth.json') + : null, + path.join(home, '.local', 'share', 'opencode', 'auth.json'), + path.join(home, 'Library', 'Application Support', 'opencode', 'auth.json') + ].filter((c): c is string => c !== null) + + for (const cand of opencodeCandidates) { + if (await fileExists(cand)) { + try { + const content = await readFile(cand, 'utf-8') + const parsed = JSON.parse(content) as { + google?: { type: string; access?: string; expires?: number } + } + if (parsed.google?.type === 'oauth') { + hasCreds = true + tokenFresh = + typeof parsed.google.expires === 'number' && parsed.google.expires > Date.now() + break + } + } catch { + // ignore + } + } + } + } + + const signedIn = Boolean(email || hasCreds) + + return { + signedIn, + email, + tokenFresh, + error: null + } +} diff --git a/src/main/ipc/antigravity-accounts.ts b/src/main/ipc/antigravity-accounts.ts new file mode 100644 index 000000000000..55e1a203f5f6 --- /dev/null +++ b/src/main/ipc/antigravity-accounts.ts @@ -0,0 +1,6 @@ +import { ipcMain } from 'electron' +import { getAntigravityAccountStatus } from '../antigravity-accounts/status' + +export function registerAntigravityAccountHandlers(): void { + ipcMain.handle('antigravityAccounts:getStatus', () => getAntigravityAccountStatus()) +} diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 5648cca02191..d8ce9866582e 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -37,6 +37,7 @@ const { registerClaudeAccountHandlersMock, registerMiniMaxCredentialsHandlersMock, registerGrokAccountHandlersMock, + registerAntigravityAccountHandlersMock, registerClipboardHandlersMock, setTrustedClipboardRendererWebContentsIdMock, registerUpdaterHandlersMock, @@ -103,6 +104,7 @@ const { registerClaudeAccountHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), + registerAntigravityAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), setTrustedClipboardRendererWebContentsIdMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -337,6 +339,10 @@ vi.mock('../grok-accounts', () => ({ registerGrokAccountHandlers: registerGrokAccountHandlersMock })) +vi.mock('../antigravity-accounts', () => ({ + registerAntigravityAccountHandlers: registerAntigravityAccountHandlersMock +})) + vi.mock('../../window/attach-main-window-services', () => ({ registerUpdaterHandlers: registerUpdaterHandlersMock })) @@ -517,6 +523,7 @@ describe('registerCoreHandlers', () => { expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGrokAccountHandlersMock).toHaveBeenCalled() + expect(registerAntigravityAccountHandlersMock).toHaveBeenCalled() expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 98cad9b25a61..4077645f898a 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -63,6 +63,7 @@ import { registerAgentTrustHandlers } from '../agent-trust' import { registerClaudeAccountHandlers } from '../claude-accounts' import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials' import { registerGrokAccountHandlers } from '../grok-accounts' +import { registerAntigravityAccountHandlers } from '../antigravity-accounts' import { registerUpdaterHandlers } from '../../window/attach-main-window-services' import { registerClipboardHandlers, @@ -148,6 +149,7 @@ export function registerCoreHandlers( registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) registerGrokAccountHandlers() + registerAntigravityAccountHandlers() registerRateLimitHandlers(rateLimits, codexAccounts) registerGitHubHandlers(store, stats) registerGitLabHandlers(store) diff --git a/src/main/rate-limits/antigravity-oauth-sources.ts b/src/main/rate-limits/antigravity-oauth-sources.ts new file mode 100644 index 000000000000..185145ee76fc --- /dev/null +++ b/src/main/rate-limits/antigravity-oauth-sources.ts @@ -0,0 +1,191 @@ +import { readFile, writeFile, rename } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' +import { net } from 'electron' +import { extractOAuthClientCredentials } from './gemini-cli-oauth-extractor' + +const API_TIMEOUT_MS = 10_000 +const PRIMARY_OAUTH_CREDS_PATH = path.join(homedir(), '.gemini', 'oauth_creds.json') +const FALLBACK_OAUTH_CREDS_PATH = path.join(homedir(), '.antigravity', 'oauth_creds.json') +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token' +const LOAD_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist' + +export type AntigravityCredentials = { + access_token: string + refresh_token: string + expiry_date: number + credsFilePath?: string +} + +export type GoogleAuthEntry = { + type: 'oauth' + access: string + expires: number + refresh: string +} + +type AuthJson = { + google?: GoogleAuthEntry + 'opencode-go'?: { type: 'api'; key: string } +} + +export async function readAuthJson(): Promise { + const candidates = [ + process.env.APPDATA ? path.join(process.env.APPDATA, 'opencode', 'auth.json') : null, + process.env.XDG_DATA_HOME + ? path.join(process.env.XDG_DATA_HOME, 'opencode', 'auth.json') + : null, + path.join(homedir(), '.local', 'share', 'opencode', 'auth.json'), + path.join(homedir(), 'Library', 'Application Support', 'opencode', 'auth.json') + ].filter((candidate): candidate is string => candidate !== null) + + for (const candidate of candidates) { + try { + const raw = await readFile(candidate, 'utf-8') + return JSON.parse(raw) as AuthJson + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { + continue + } + throw err + } + } + + return null +} + +export async function readAntigravityCredentials(): Promise { + const candidates = [PRIMARY_OAUTH_CREDS_PATH, FALLBACK_OAUTH_CREDS_PATH] + for (const credPath of candidates) { + try { + const raw = await readFile(credPath, 'utf-8') + const parsed = JSON.parse(raw) as unknown + if ( + parsed && + typeof parsed === 'object' && + 'access_token' in parsed && + typeof parsed.access_token === 'string' && + 'refresh_token' in parsed && + typeof parsed.refresh_token === 'string' && + 'expiry_date' in parsed && + typeof parsed.expiry_date === 'number' + ) { + return { + access_token: parsed.access_token, + refresh_token: parsed.refresh_token, + expiry_date: parsed.expiry_date, + credsFilePath: credPath + } + } + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { + continue + } + throw err + } + } + return null +} + +export async function saveAntigravityCredentials(creds: AntigravityCredentials): Promise { + const targetPath = creds.credsFilePath || PRIMARY_OAUTH_CREDS_PATH + const tmpPath = `${targetPath}.${process.pid}.tmp` + const { credsFilePath: _ignored, ...dataToSave } = creds + await writeFile(tmpPath, JSON.stringify(dataToSave, null, 2), 'utf-8') + await rename(tmpPath, targetPath) +} + +export type RefreshTokenResult = { + accessToken: string | null + newRefreshToken: string | null + expiresIn?: number +} + +export async function refreshAccessToken( + refreshToken: string, + clientId: string, + clientSecret: string +): Promise { + const res = await net.fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token' + }).toString(), + signal: AbortSignal.timeout(API_TIMEOUT_MS) + }) + + if (!res.ok) { + return { accessToken: null, newRefreshToken: null } + } + + const data = (await res.json()) as { + access_token?: string + refresh_token?: string + expires_in?: number + } + return { + accessToken: typeof data.access_token === 'string' ? data.access_token : null, + newRefreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : null, + expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined + } +} + +export async function loadProjectId(accessToken: string): Promise { + const res = await net.fetch(LOAD_CODE_ASSIST_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}` + }, + body: JSON.stringify({ metadata: { ideType: 'ANTIGRAVITY', pluginType: 'GEMINI' } }), + signal: AbortSignal.timeout(API_TIMEOUT_MS) + }) + + if (res.ok) { + const data = (await res.json()) as { cloudaicompanionProject?: string } + if (typeof data.cloudaicompanionProject === 'string' && data.cloudaicompanionProject) { + return data.cloudaicompanionProject + } + } + + // Fallback to reading projects.json + const projectPaths = [ + path.join(homedir(), '.gemini', 'projects.json'), + path.join(homedir(), '.antigravity', 'projects.json') + ] + for (const projPath of projectPaths) { + try { + const raw = await readFile(projPath, 'utf-8') + const parsed = JSON.parse(raw) as { projects?: Record } + if (parsed.projects && typeof parsed.projects === 'object') { + const firstProject = Object.values(parsed.projects)[0] + if (firstProject && typeof firstProject === 'string') { + return firstProject + } + } + } catch { + // ignore + } + } + + return '' +} + +export async function tryRefreshTokenFromBundle( + refreshToken: string, + allowCliOAuth = true +): Promise { + if (!allowCliOAuth) { + return null + } + const clientCreds = await extractOAuthClientCredentials() + if (!clientCreds) { + return null + } + + return refreshAccessToken(refreshToken, clientCreds.clientId, clientCreds.clientSecret) +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts new file mode 100644 index 000000000000..377ee8a43429 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { authJsonGoogle, makeResponse, quotaResponse } from './gemini-usage-fetcher.test-fixtures' + +const { readFileMock, extractCredsMock, netFetchMock } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + extractCredsMock: vi.fn(), + netFetchMock: vi.fn() +})) + +vi.mock('./gemini-cli-oauth-extractor', () => ({ + extractOAuthClientCredentials: extractCredsMock +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, + writeFile: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) + +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' + +describe('fetchAntigravityRateLimits', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z')) + readFileMock.mockReset() + extractCredsMock.mockReset() + netFetchMock.mockReset() + netFetchMock.mockImplementation((url: string) => { + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) + } + if (url.includes('token')) { + return Promise.resolve(makeResponse({ access_token: 'new-token', expires_in: 3600 })) + } + return Promise.resolve(makeResponse({ error: `Unhandled fetch to ${url}` }, 500)) + }) + extractCredsMock.mockResolvedValue(null) + readFileMock.mockRejectedValue({ code: 'ENOENT' }) + }) + + const setupAuthJsonValid = () => { + readFileMock.mockImplementation(async (p: string) => { + if (p.includes('auth.json')) { + return JSON.stringify(authJsonGoogle) + } + throw { code: 'ENOENT' } + }) + } + + it('returns unavailable when disabled in settings', async () => { + const result = await fetchAntigravityRateLimits(false) + expect(result.status).toBe('unavailable') + expect(result.provider).toBe('antigravity') + }) + + it('returns unavailable when no credentials exist', async () => { + const result = await fetchAntigravityRateLimits(true) + expect(result.status).toBe('unavailable') + expect(result.provider).toBe('antigravity') + }) + + it('returns quota via auth.json', async () => { + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string) => { + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(makeResponse(quotaResponse)) + } + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) + } + return Promise.resolve(makeResponse({}, 404)) + }) + const result = await fetchAntigravityRateLimits(true) + expect(result.status).toBe('ok') + expect(result.provider).toBe('antigravity') + expect(result.buckets).toHaveLength(2) + }) + + it('returns quota via oauth_creds.json', async () => { + readFileMock.mockImplementation(async (p: string) => { + if (p.includes('oauth_creds.json')) { + return JSON.stringify({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + expiry_date: Date.now() + 3600_000 + }) + } + throw { code: 'ENOENT' } + }) + netFetchMock.mockImplementation((url: string) => { + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(makeResponse(quotaResponse)) + } + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) + } + return Promise.resolve(makeResponse({}, 404)) + }) + const result = await fetchAntigravityRateLimits(true) + expect(result.status).toBe('ok') + expect(result.provider).toBe('antigravity') + expect(result.buckets).toHaveLength(2) + }) +}) 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..ad02ad60a329 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -0,0 +1,246 @@ +import { net } from 'electron' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { + loadProjectId, + readAuthJson, + readAntigravityCredentials, + saveAntigravityCredentials, + tryRefreshTokenFromBundle, + type AntigravityCredentials, + type GoogleAuthEntry +} from './antigravity-oauth-sources' +import { + buildRateLimitBucket, + deduplicateBuckets, + deriveSessionSummary +} from './gemini-bucket-formatting' + +const API_TIMEOUT_MS = 10_000 +const RETRIEVE_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota' + +type QuotaBucket = { remainingFraction: number; resetTime: string; modelId: string } + +function isQuotaBucket(o: unknown): o is QuotaBucket { + return ( + typeof o === 'object' && + o !== null && + typeof (o as QuotaBucket).remainingFraction === 'number' && + Number.isFinite((o as QuotaBucket).remainingFraction) && + typeof (o as QuotaBucket).resetTime === 'string' && + typeof (o as QuotaBucket).modelId === 'string' + ) +} + +function parseQuotaResponse(data: unknown): QuotaBucket[] { + let rawBuckets: unknown[] = [] + if (Array.isArray(data)) { + rawBuckets = data + } else if (data && typeof data === 'object' && 'buckets' in data && Array.isArray(data.buckets)) { + rawBuckets = data.buckets + } + return rawBuckets.filter((b) => isQuotaBucket(b)) +} + +async function fetchQuota(accessToken: string, projectId: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, API_TIMEOUT_MS) + try { + const res = await net.fetch(RETRIEVE_QUOTA_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, + body: JSON.stringify({ project: projectId }), + signal: controller.signal + }) + if (!res.ok) { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `Quota fetch failed (${res.status})`, + status: 'error' + } + } + const data = (await res.json()) as unknown + const buckets = deduplicateBuckets( + parseQuotaResponse(data).map((b) => ({ ...buildRateLimitBucket(b), modelId: b.modelId })) + ) + return { + provider: 'antigravity', + session: deriveSessionSummary(buckets), + weekly: null, + buckets, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + } finally { + clearTimeout(timeout) + } +} + +async function fetchViaAuthJson( + auth: GoogleAuthEntry, + antigravityCliOAuthEnabled = false +): Promise { + let accessToken = auth.access + const refreshToken = (auth.refresh || '').split('|')[0] ?? '' + if (auth.expires < Date.now() || !accessToken) { + const refreshResult = await tryRefreshTokenFromBundle(refreshToken, antigravityCliOAuthEnabled) + if (!refreshResult?.accessToken) { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Token refresh failed', + status: 'error' + } + } + accessToken = refreshResult.accessToken + } + let effectiveProjectId = '' + try { + effectiveProjectId = await loadProjectId(accessToken) + } catch { + effectiveProjectId = + (auth.refresh || '').split('|')[1] || (auth.refresh || '').split('|')[2] || '' + } + if (!effectiveProjectId) { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Antigravity project ID not found', + status: 'error' + } + } + const result = await fetchQuota(accessToken, effectiveProjectId) + if (result.status === 'error' && result.error?.includes('401')) { + const refreshResult = await tryRefreshTokenFromBundle(refreshToken, antigravityCliOAuthEnabled) + if (refreshResult?.accessToken) { + const newProjectId = await loadProjectId(refreshResult.accessToken).catch(() => { + return effectiveProjectId + }) + return fetchQuota(refreshResult.accessToken, newProjectId) + } + } + return result +} + +async function fetchViaOauthCreds( + creds: AntigravityCredentials, + antigravityCliOAuthEnabled = false +): Promise { + let accessToken = creds.access_token + let currentCreds = creds + if (creds.expiry_date < Date.now()) { + const refreshResult = await tryRefreshTokenFromBundle( + creds.refresh_token, + antigravityCliOAuthEnabled + ) + if (!refreshResult?.accessToken) { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Token refresh failed', + status: 'error' + } + } + accessToken = refreshResult.accessToken + currentCreds = { + ...creds, + access_token: accessToken, + expiry_date: refreshResult.expiresIn + ? Date.now() + refreshResult.expiresIn * 1000 + : creds.expiry_date + } + await saveAntigravityCredentials(currentCreds) + } + const projectId = await loadProjectId(accessToken).catch(() => { + return '' + }) + if (!projectId) { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Antigravity project ID not found', + status: 'error' + } + } + const result = await fetchQuota(accessToken, projectId) + if (result.status === 'error' && result.error?.includes('401')) { + const refreshResult = await tryRefreshTokenFromBundle( + currentCreds.refresh_token, + antigravityCliOAuthEnabled + ) + if (refreshResult?.accessToken) { + const newProjectId = await loadProjectId(refreshResult.accessToken).catch(() => { + return '' + }) + if (newProjectId) { + await saveAntigravityCredentials({ + ...currentCreds, + access_token: refreshResult.accessToken, + expiry_date: refreshResult.expiresIn + ? Date.now() + refreshResult.expiresIn * 1000 + : currentCreds.expiry_date + }) + return fetchQuota(refreshResult.accessToken, newProjectId) + } + } + } + return result +} + +export async function fetchAntigravityRateLimits( + antigravityCliOAuthEnabled = false +): Promise { + if (!antigravityCliOAuthEnabled) { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Antigravity CLI OAuth is disabled in settings', + status: 'unavailable' + } + } + + try { + const authJson = await readAuthJson() + const result = + authJson?.google?.type === 'oauth' + ? await fetchViaAuthJson(authJson.google, antigravityCliOAuthEnabled) + : await (async () => { + const creds = await readAntigravityCredentials() + return !creds + ? ({ + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Antigravity CLI credentials not found', + status: 'unavailable' + } as ProviderRateLimits) + : await fetchViaOauthCreds(creds, antigravityCliOAuthEnabled) + })() + return result + } catch (err) { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + } + } +} diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts deleted file mode 100644 index 84c92e1ad22f..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { ProviderRateLimits, ProviderRateLimitStatus } from '../../shared/rate-limit-types' -import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' - -function geminiSnapshot( - status: ProviderRateLimitStatus, - error: string | null, - usedPercent: number | null = null -): ProviderRateLimits { - return { - provider: 'gemini', - session: - usedPercent === null - ? null - : { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - updatedAt: 1_700_000_000_000, - error, - status - } -} - -describe('deriveAntigravityRateLimits', () => { - it('mirrors a successful Gemini read as shared Code Assist quota', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('ok', null, 42)) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('ok') - expect(antigravity.session?.usedPercent).toBe(42) - expect(antigravity.error).toBeNull() - }) - - it('reports unavailable without quoting the Gemini failure', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('error', 'Gemini project ID not found') - ) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini project ID not found') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.session).toBeNull() - expect(antigravity.weekly).toBeNull() - }) - - it('does not blame a missing sign-in when the quota read itself failed', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - // Why: the reported symptom is a connected sign-in whose Code Assist read failed. - expect(antigravity.error).toContain('could not be read right now') - expect(antigravity.error).not.toContain('sign-in is connected') - }) - - it('keeps the Gemini timestamp so activation freshness checks are not forced to refetch', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - expect(antigravity.updatedAt).toBe(1_700_000_000_000) - }) - - it('points at the missing sign-in when the Gemini opt-in is off', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('unavailable', 'Gemini CLI OAuth is disabled in settings') - ) - - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini CLI OAuth is disabled in settings') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.error).toContain('Gemini CLI sign-in is connected') - }) -}) diff --git a/src/main/rate-limits/antigravity-usage-mirror.ts b/src/main/rate-limits/antigravity-usage-mirror.ts deleted file mode 100644 index 3cea6decc111..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' - -// Why: the Antigravity CLI keeps its token in the OS keyring, not in the files the Gemini -// fetcher reads, so Orca never actually queries Antigravity. Only a *successful* Gemini read -// describes shared Google Code Assist quota; republishing a Gemini failure under the -// Antigravity provider id surfaced "Refresh failed" for a request that was never attempted. -const ANTIGRAVITY_NO_SIGN_IN_REASON = - 'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.' -// Why: a Gemini `error` means the sign-in exists and the quota read failed, so blaming a missing sign-in would misdirect the user. -const ANTIGRAVITY_QUOTA_UNREADABLE_REASON = - 'Antigravity usage is not available. Orca reads it from the shared Google Code Assist quota, which could not be read right now.' - -export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits { - if (gemini.status === 'ok') { - return { ...gemini, provider: 'antigravity' } - } - return { - provider: 'antigravity', - session: null, - weekly: null, - // Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle. - updatedAt: gemini.updatedAt, - error: - gemini.status === 'unavailable' - ? ANTIGRAVITY_NO_SIGN_IN_REASON - : ANTIGRAVITY_QUOTA_UNREADABLE_REASON, - status: 'unavailable' - } -} diff --git a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts index 15c338aad89b..bbbe65f24cd1 100644 --- a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts +++ b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts @@ -33,6 +33,7 @@ describe('fetchCodexRateLimits PTY settle timers', () => { beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn()) resolveCodexCommandMock.mockReturnValue('codex') }) 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..23ddbaa22d16 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -3,6 +3,7 @@ import { vi, type Mock } from 'vitest' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import type { RateLimitService } from './service' import { fetchCodexRateLimits } from './codex-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' @@ -84,7 +85,14 @@ export function unavailableProvider( // individual retry lane need healthy providers minted fresh at fetch time. export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 24)) - vi.mocked(fetchGeminiRateLimits).mockImplementation(async () => okProvider('gemini', 0)) + if (vi.isMockFunction(fetchAntigravityRateLimits)) { + vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () => + okProvider('antigravity', 0) + ) + } + if (vi.isMockFunction(fetchGeminiRateLimits)) { + vi.mocked(fetchGeminiRateLimits).mockImplementation(async () => okProvider('gemini', 0)) + } vi.mocked(fetchOpenCodeGoRateLimits).mockImplementation(async () => okProvider('opencode-go', 0)) vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) @@ -94,7 +102,14 @@ export function mockFreshBackgroundProviderFetches(): void { /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ export function resetRateLimitProviderMocks(): void { vi.clearAllMocks() - vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) + if (vi.isMockFunction(fetchAntigravityRateLimits)) { + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 0, Date.now()) + ) + } + if (vi.isMockFunction(fetchGeminiRateLimits)) { + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) + } vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now())) vi.mocked(fetchKimiRateLimits).mockResolvedValue(okProvider('kimi', 0, Date.now())) vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now())) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index e0975af0db07..6e656fc81e31 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -2,7 +2,7 @@ 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, @@ -19,6 +19,10 @@ vi.mock('./codex-fetcher', () => ({ fetchCodexRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) @@ -54,47 +58,42 @@ describe('RateLimitService Antigravity usage', () => { vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) }) - it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { - vi.mocked(fetchGeminiRateLimits).mockResolvedValue( - errorProvider('gemini', 'Gemini project ID not found') + it('updates state with Antigravity rate limits on success', async () => { + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 42, Date.now()) ) const service = new RateLimitService() await service.refresh() const state = service.getState() - expect(state.antigravity?.status).toBe('unavailable') - expect(state.antigravity?.error).not.toContain('Gemini project ID not found') - expect(state.antigravity?.session).toBeNull() - // Why: the real Gemini failure must still surface under its own provider. - expect(state.gemini?.status).toBe('error') - expect(state.gemini?.error).toBe('Gemini project ID not found') + expect(state.antigravity?.status).toBe('ok') + expect(state.antigravity?.provider).toBe('antigravity') + expect(state.antigravity?.session?.usedPercent).toBe(42) }) - it('keeps mirroring a successful Gemini read under the Antigravity provider', async () => { - vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + it('records Antigravity rate limits on failure', async () => { + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + errorProvider('antigravity', 'Token refresh failed') + ) 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('error') + expect(state.antigravity?.error).toBe('Token refresh failed') }) - it('never leaves a cached Antigravity snapshot in the error retry lane', async () => { - vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) + it('passes antigravityCliOAuthEnabled flag to fetchAntigravityRateLimits', async () => { + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 10, Date.now()) + ) const service = new RateLimitService() - await service.refresh() + service.setAntigravityCliOAuthEnabledResolver(() => true) - 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() + expect(fetchAntigravityRateLimits).toHaveBeenCalledWith(true) }) }) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 44f4b05b3bac..b77a93bf421f 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -3,7 +3,7 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' 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 { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' @@ -28,8 +28,8 @@ vi.mock('./codex-fetcher', () => ({ fetchCodexRateLimits: vi.fn() })) -vi.mock('./gemini-usage-fetcher', () => ({ - fetchGeminiRateLimits: vi.fn() +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() })) vi.mock('./kimi-fetcher', () => ({ @@ -111,7 +111,7 @@ describe('RateLimitService', () => { }) expect(fetchClaudeRateLimits).not.toHaveBeenCalled() expect(fetchCodexRateLimits).not.toHaveBeenCalled() - expect(fetchGeminiRateLimits).not.toHaveBeenCalled() + expect(fetchAntigravityRateLimits).not.toHaveBeenCalled() expect(fetchOpenCodeGoRateLimits).not.toHaveBeenCalled() expect(fetchKimiRateLimits).not.toHaveBeenCalled() expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled() @@ -271,7 +271,9 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValueOnce( + okProvider('antigravity', 30, Date.now()) + ) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 40, Date.now()) ) @@ -287,7 +289,7 @@ describe('RateLimitService', () => { const pendingGrokState = service.getState() expect(pendingGrokState.claude?.status).toBe('ok') expect(pendingGrokState.codex?.status).toBe('ok') - expect(pendingGrokState.gemini?.status).toBe('ok') + expect(pendingGrokState.antigravity?.status).toBe('ok') expect(pendingGrokState.opencodeGo?.status).toBe('ok') expect(pendingGrokState.kimi?.status).toBe('ok') expect(pendingGrokState.minimax?.status).toBe('ok') @@ -361,7 +363,7 @@ describe('RateLimitService', () => { expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1) }) - it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => { + it('fetches Antigravity and OpenCode Go alongside Claude and Codex', async () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: 'session=abc123', @@ -372,11 +374,13 @@ describe('RateLimitService', () => { httpProxyBypassRules: 'localhost' } service.setNetworkProxySettingsResolver(() => networkProxySettings) - service.setGeminiCliOAuthEnabledResolver(() => true) + service.setAntigravityCliOAuthEnabledResolver(() => true) vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValueOnce( + okProvider('antigravity', 30, Date.now()) + ) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 40, Date.now()) ) @@ -393,8 +397,8 @@ describe('RateLimitService', () => { }) ) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + expect(fetchAntigravityRateLimits).toHaveBeenCalledWith(true) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith( 'session=abc123', @@ -411,8 +415,8 @@ describe('RateLimitService', () => { expect(state.claude?.session?.usedPercent).toBe(10) expect(state.codex?.status).toBe('ok') expect(state.codex?.session?.usedPercent).toBe(20) - expect(state.gemini?.status).toBe('ok') - expect(state.gemini?.session?.usedPercent).toBe(30) + expect(state.antigravity?.status).toBe('ok') + expect(state.antigravity?.session?.usedPercent).toBe(30) expect(state.opencodeGo?.status).toBe('ok') expect(state.opencodeGo?.session?.usedPercent).toBe(40) }) @@ -447,11 +451,11 @@ describe('RateLimitService', () => { expect(fetchKimiRateLimits).toHaveBeenCalledWith({ home: undefined }) }) - it('preserves Gemini buckets through getState after fetch', async () => { + it('preserves Antigravity buckets through getState after fetch', async () => { const service = new RateLimitService() - const geminiWithBuckets: ProviderRateLimits = { - provider: 'gemini', + const antigravityWithBuckets: ProviderRateLimits = { + provider: 'antigravity', session: { usedPercent: 80, windowMinutes: 300, resetsAt: null, resetDescription: null }, weekly: null, buckets: [ @@ -477,7 +481,7 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(geminiWithBuckets) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValueOnce(antigravityWithBuckets) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 0, Date.now()) ) @@ -485,11 +489,11 @@ describe('RateLimitService', () => { await service.refresh() const state = service.getState() - expect(state.gemini?.buckets).toHaveLength(2) - expect(state.gemini?.buckets![0].name).toBe('Pro') - expect(state.gemini?.buckets![1].name).toBe('Flash') + expect(state.antigravity?.buckets).toHaveLength(2) + expect(state.antigravity?.buckets![0].name).toBe('Pro') + expect(state.antigravity?.buckets![1].name).toBe('Flash') // Why: session summary is derived from bucket data and must match the most constrained bucket. - expect(state.gemini?.session?.usedPercent).toBe(80) + expect(state.antigravity?.session?.usedPercent).toBe(80) }) it('isolates provider failures so one error does not block others', async () => { @@ -501,7 +505,7 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockRejectedValueOnce(new Error('claude down')) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockRejectedValueOnce(new Error('gemini down')) + vi.mocked(fetchAntigravityRateLimits).mockRejectedValueOnce(new Error('antigravity down')) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 40, Date.now()) ) @@ -512,8 +516,8 @@ describe('RateLimitService', () => { expect(state.claude?.status).toBe('error') expect(state.claude?.error).toBe('claude down') expect(state.codex?.status).toBe('ok') - expect(state.gemini?.status).toBe('error') - expect(state.gemini?.error).toBe('gemini down') + expect(state.antigravity?.status).toBe('error') + expect(state.antigravity?.error).toBe('antigravity down') expect(state.opencodeGo?.status).toBe('ok') }) @@ -528,7 +532,9 @@ describe('RateLimitService', () => { // 1. Success fetch vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 30, Date.now()) + ) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue( okProvider('opencode-go', 40, Date.now()) ) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index ec813a53d94a..8e61b6fe9fb7 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -3,7 +3,7 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' 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 { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' @@ -29,8 +29,8 @@ vi.mock('./codex-fetcher', () => ({ fetchCodexRateLimits: vi.fn() })) -vi.mock('./gemini-usage-fetcher', () => ({ - fetchGeminiRateLimits: vi.fn() +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() })) vi.mock('./kimi-fetcher', () => ({ @@ -187,7 +187,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1) @@ -545,7 +545,7 @@ describe('RateLimitService', () => { try { vi.mocked(fetchClaudeRateLimits).mockResolvedValue(unavailableProvider('claude')) vi.mocked(fetchCodexRateLimits).mockResolvedValue(unavailableProvider('codex')) - vi.mocked(fetchGeminiRateLimits).mockResolvedValue(unavailableProvider('gemini')) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity')) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(unavailableProvider('opencode-go')) vi.mocked(fetchKimiRateLimits).mockResolvedValue(unavailableProvider('kimi')) @@ -562,7 +562,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) @@ -572,7 +572,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(2) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(2) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index 655ba9b93d89..429df329272c 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -10,7 +10,7 @@ import { type ClaudeAuthPreparationResolver, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, - type GeminiCliOAuthEnabledResolver, + type AntigravityCliOAuthEnabledResolver, type InactiveCodexAccountInfo, type InactiveClaudeAccountInfo, type RateLimitState, @@ -48,8 +48,12 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco this.miniMaxConfigResolver = resolver } - setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { - this.geminiCliOAuthEnabledResolver = resolver + setAntigravityCliOAuthEnabledResolver(resolver: AntigravityCliOAuthEnabledResolver): void { + this.antigravityCliOAuthEnabledResolver = resolver + } + + setGeminiCliOAuthEnabledResolver(resolver: AntigravityCliOAuthEnabledResolver): void { + this.setAntigravityCliOAuthEnabledResolver(resolver) } setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { 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..85431a3ac078 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 { @@ -29,7 +28,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ results: [ claudeResult, codexResult, - geminiResult, + antigravityResult, opencodeGoResult, kimiResult, miniMaxResult @@ -66,22 +65,21 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ status: 'error' } satisfies ProviderRateLimits) - const gemini = - geminiResult.status === 'fulfilled' - ? geminiResult.value + const antigravity = + antigravityResult.status === 'fulfilled' + ? antigravityResult.value : ({ - provider: 'gemini', + provider: 'antigravity', session: null, weekly: null, updatedAt: Date.now(), error: - geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', + antigravityResult.reason instanceof Error + ? antigravityResult.reason.message + : 'Unknown error', status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. - const antigravity = deriveAntigravityRateLimits(gemini) - const opencodeGo = opencodeGoResult.status === 'fulfilled' ? opencodeGoResult.value @@ -155,7 +153,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ if (shouldApplyCodex) { this.trackActiveFailureStreak('codex', codex) } - this.trackActiveFailureStreak('gemini', gemini) this.trackActiveFailureStreak('antigravity', antigravity) if (shouldApplyOpencode) { this.trackActiveFailureStreak('opencode-go', opencodeGo) @@ -176,7 +173,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : codexBecameUnavailable ? codexStateBeforeFetch : this.state.codex, - gemini: this.applyStalePolicy(gemini, previousState.gemini), + gemini: null, opencodeGo: shouldApplyOpencode ? opencodeConfigChanged ? opencodeGo 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..59ab47d61b8b 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -1,6 +1,6 @@ import { fetchClaudeRateLimits } from '../claude-fetcher' import { fetchCodexRateLimits } from '../codex-fetcher' -import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' @@ -82,7 +82,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ const miniMaxModels = miniMaxConfigResult.config.models const miniMaxEndpoint = miniMaxConfigResult.config.endpoint const miniMaxApiKey = miniMaxConfigResult.config.apiKey - const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false + const antigravityCliOAuthEnabled = this.antigravityCliOAuthEnabledResolver?.() ?? false // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. const grokAuthReadResult = readGrokAuthSession() this.grokAuthConfigured = grokAuthReadResult.status === 'ok' @@ -138,42 +138,48 @@ 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, + antigravityResult, + opencodeGoResult, + kimiResult, + miniMaxResult + ] = await Promise.allSettled([ + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), + fetchAntigravityRateLimits(antigravityCliOAuthEnabled), + 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 + }) + ]) if (signal.aborted) { return null @@ -197,7 +203,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ results: [ claudeResult, codexResult, - geminiResult, + antigravityResult, opencodeGoResult, kimiResult, miniMaxResult diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index c861726cc524..b0869fff7aab 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -73,7 +73,6 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue const byProvider: Record = { claude: this.state.claude, codex: this.state.codex, - gemini: this.state.gemini, 'opencode-go': this.state.opencodeGo, kimi: this.state.kimi, minimax: this.state.minimax, diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index c7e9aa4751a0..df05f34eac22 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -13,7 +13,7 @@ import { type ClaudeAuthPreparationResolver, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, - type GeminiCliOAuthEnabledResolver, + type AntigravityCliOAuthEnabledResolver, type NormalizedCodexAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget, type InactiveClaudeAccountInfo, @@ -41,7 +41,6 @@ export abstract class RateLimitServiceState { protected lastActiveFailureRetryAtByProvider: Record = { claude: 0, codex: 0, - gemini: 0, 'opencode-go': 0, kimi: 0, minimax: 0, @@ -52,7 +51,6 @@ export abstract class RateLimitServiceState { protected activeFailureStreakByProvider: Record = { claude: 0, codex: 0, - gemini: 0, 'opencode-go': 0, kimi: 0, minimax: 0, @@ -90,7 +88,7 @@ export abstract class RateLimitServiceState { } protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null - protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null + protected antigravityCliOAuthEnabledResolver: AntigravityCliOAuthEnabledResolver | null = null protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null protected inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null protected networkProxySettingsResolver: (() => NetworkProxySettings) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 414fa9b8dfd6..0a029517a27b 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -59,8 +59,15 @@ export type MiniMaxResolvedConfig = { error: string | null } -export type GeminiCliOAuthEnabledResolver = () => boolean -export type ActiveRateLimitProvider = ProviderRateLimits['provider'] +export type AntigravityCliOAuthEnabledResolver = () => boolean +export type ActiveRateLimitProvider = + | 'claude' + | 'codex' + | 'opencode-go' + | 'kimi' + | 'minimax' + | 'grok' + | 'antigravity' export type ActiveProviderState = { provider: ActiveRateLimitProvider limits: ProviderRateLimits | null diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index cf22c90b323a..d6885f208171 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -127,7 +127,11 @@ export function initializeMainProcessAccountServices(): void { apiKey } }) - state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled) + state.rateLimits.setAntigravityCliOAuthEnabledResolver( + () => + store.getSettings().antigravityCliOAuthEnabled || + store.getSettings().geminiCliOAuthEnabled === true + ) state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings()) state.keybindings = new KeybindingService({ homePath: app.getPath('home'), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 632e53005ae3..62c6d55bce80 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1,5 +1,6 @@ import type { ElectronAPI } from '@electron-toolkit/preload' import type { + AntigravityAccountsApi, ClaudeAccountsApi, CodexAccountsApi, CodexConfigSyncApi, @@ -141,6 +142,7 @@ export type PreloadApi = { rateLimits: RateLimitsApi minimaxCredentials: MinimaxCredentialsApi grokAccounts: GrokAccountsApi + antigravityAccounts: AntigravityAccountsApi ssh: SshApi automations: AutomationsApi wsl: RuntimeApi['wsl'] diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index ff98244299da..822c9b14685c 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -3,7 +3,7 @@ import type { CodexRateLimitAccountsState } from '../../shared/managed-account-types' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' -import type { GrokAccountStatus } from '../../shared/rate-limit-types' +import type { AntigravityAccountStatus, GrokAccountStatus } from '../../shared/rate-limit-types' export type CodexAccountsApi = { list: () => Promise @@ -58,6 +58,10 @@ export type GrokAccountsApi = { getStatus: () => Promise } +export type AntigravityAccountsApi = { + getStatus: () => Promise +} + export type MinimaxCredentialsApi = { // Why: cookie + API key each live in their own safeStorage file, so the // status separates them. 'configured' stays as the OR so existing callers diff --git a/src/preload/api/antigravity-accounts-bridge.ts b/src/preload/api/antigravity-accounts-bridge.ts new file mode 100644 index 000000000000..32a83ee12b36 --- /dev/null +++ b/src/preload/api/antigravity-accounts-bridge.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' +import type { AntigravityAccountStatus } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' + +export const antigravityAccountsApi = { + getStatus: (): Promise => + ipcRenderer.invoke('antigravityAccounts:getStatus') +} satisfies PreloadApi['antigravityAccounts'] diff --git a/src/preload/index.ts b/src/preload/index.ts index 27d3ca8e0620..068658dd45fd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -78,6 +78,7 @@ import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' import { rateLimitsApi } from './api/rate-limits-bridge' import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' import { grokAccountsApi } from './api/grok-accounts-bridge' +import { antigravityAccountsApi } from './api/antigravity-accounts-bridge' import { sshApi } from './api/ssh-bridge' import { automationsApi } from './api/automations-bridge' import { e2eApi } from './api/e2e-bridge' @@ -176,6 +177,7 @@ const api = { rateLimits: rateLimitsApi, minimaxCredentials: minimaxCredentialsApi, grokAccounts: grokAccountsApi, + antigravityAccounts: antigravityAccountsApi, ssh: sshApi, automations: automationsApi, e2e: e2eApi, diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index bf7656856d5d..513911264478 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -85,7 +85,7 @@ 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('rate limits and quota for 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 eb19fc543061..c9d6af6d3899 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -15,9 +15,9 @@ import { watchProviderAccounts } from '@/runtime/runtime-provider-accounts-client' import { + getAccountsAntigravitySearchEntries, getAccountsClaudeSearchEntries, getAccountsCodexSearchEntries, - getAccountsGeminiSearchEntries, getAccountsGrokSearchEntries, getAccountsLocationSearchEntries, getAccountsMiniMaxSearchEntries, @@ -35,6 +35,7 @@ import { providerAccountMatchesView } from './provider-account-visibility' import { Separator } from '../ui/separator' +import { AntigravityAccountsSection } from './AntigravityAccountsSection' import { GrokAccountsSection } from './GrokAccountsSection' import type { AccountsPaneProps, @@ -53,10 +54,7 @@ import { createMiniMaxCredentialActions } from './accounts-pane-minimax-actions' import { renderAccountsLocationSection } from './accounts-pane-location-section' import { renderClaudeAccountsSection } from './accounts-pane-claude-section' import { renderCodexAccountsSection } from './accounts-pane-codex-section' -import { - renderGeminiAccountsSection, - renderOpenCodeAccountsSection -} from './accounts-pane-provider-setting-sections' +import { renderOpenCodeAccountsSection } from './accounts-pane-provider-setting-sections' import { renderMiniMaxAccountsSection } from './accounts-pane-minimax-section' import { renderAccountsRemovalDialogs } from './accounts-pane-removal-dialogs' @@ -366,9 +364,9 @@ export function AccountsPane({ matchesSettingsSearch(searchQuery, getAccountsCodexSearchEntries()) ? renderCodexAccountsSection(model) : null, - matchesSettingsSearch(searchQuery, getAccountsGeminiSearchEntries()) - ? renderGeminiAccountsSection(model) - : null, + matchesSettingsSearch(searchQuery, getAccountsAntigravitySearchEntries()) ? ( + + ) : null, matchesSettingsSearch(searchQuery, getAccountsOpencodeSearchEntries()) ? renderOpenCodeAccountsSection(model) : null, diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx new file mode 100644 index 000000000000..d439ee14bb92 --- /dev/null +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx @@ -0,0 +1,199 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { cleanup, render, screen, fireEvent } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AccountsPaneSectionModel } from './accounts-pane-types' +import { getDefaultSettings } from '../../../../shared/constants' +import { + emptyClaudeAccountsState, + emptyCodexAccountsState +} from '@/runtime/runtime-provider-accounts-client' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + refreshRateLimits: vi.fn(), + antigravityUsage: vi.fn<() => unknown>(() => null) +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => React.createElement('span', { 'data-testid': 'antigravity-icon' }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + refreshRateLimits: mocks.refreshRateLimits, + settingsSearchQuery: '', + rateLimits: { antigravity: mocks.antigravityUsage() } + }) +})) + +import { AntigravityAccountsSection } from './AntigravityAccountsSection' + +function createModel(overrides: Partial = {}): AccountsPaneSectionModel { + return { + settings: { + ...getDefaultSettings('/tmp'), + antigravityCliOAuthEnabled: true + }, + updateSettings: vi.fn(), + searchQuery: '', + recordFeatureInteraction: vi.fn(), + wslSupportedPlatform: false, + wslAvailable: false, + wslDistros: [], + wslCapabilitiesLoading: false, + localAccountRuntime: { runtime: 'host', label: 'Local' }, + localAccountRuntimeSentenceLabel: 'this computer', + isRemoteAccountScope: false, + remoteServerName: null, + remoteAccountScopeNotice: null, + accountRuntime: { runtime: 'host', label: 'Local' }, + accountRuntimeSentenceLabel: 'this computer', + accountRuntimeUnavailable: false, + accountVisibilityOptions: { remoteOwner: false, ownerPlatform: null }, + claudeAccounts: emptyClaudeAccountsState(), + claudeAction: 'idle', + visibleClaudeAccounts: [], + systemClaudeActive: true, + setRemoveClaudeTarget: vi.fn(), + runClaudeAccountAction: vi.fn(), + codexAccounts: emptyCodexAccountsState(), + codexAction: 'idle', + visibleCodexAccounts: [], + systemCodexActive: true, + systemCodexNeedsSignIn: false, + systemCodexMissingSignIn: false, + systemCodexIdentity: undefined, + activeCodexAuthWarning: null, + activeCodexAccountId: null, + codexConfigSync: null, + codexConfigSyncWarning: null, + codexRateLimits: null, + codexRateLimitTarget: { runtime: 'host', wslDistro: null }, + setRemoveCodexTarget: vi.fn(), + runCodexAccountAction: vi.fn(), + recordOpenCodeSettingEdit: vi.fn(), + miniMaxRateLimits: null, + miniMaxApiKeyDraft: '', + setMiniMaxApiKeyDraft: vi.fn(), + miniMaxApiKeyConfigured: false, + saveMiniMaxApiKey: vi.fn(), + clearMiniMaxApiKey: vi.fn(), + miniMaxCookieDraft: '', + setMiniMaxCookieDraft: vi.fn(), + miniMaxConfigured: false, + miniMaxCredentialBusy: false, + saveMiniMaxCookie: vi.fn(), + clearMiniMaxCookie: vi.fn(), + ...overrides + } +} + +describe('AntigravityAccountsSection', () => { + beforeEach(() => { + mocks.getStatus.mockResolvedValue({ + signedIn: true, + email: 'dev@example.com', + tokenFresh: true, + error: null + }) + mocks.refreshRateLimits.mockResolvedValue(undefined) + mocks.antigravityUsage.mockReturnValue(null) + Object.defineProperty(window, 'api', { + configurable: true, + value: { antigravityAccounts: { getStatus: mocks.getStatus } } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('renders signed in status with user email', async () => { + const model = createModel() + render() + + expect(await screen.findByText('dev@example.com')).toBeInTheDocument() + expect( + screen.getByText('Signed in. Orca reads the Antigravity CLI credentials stored on disk.') + ).toBeInTheDocument() + }) + + it('renders model quota buckets when available', async () => { + mocks.antigravityUsage.mockReturnValue({ + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + name: 'Gemini 2.5 Pro', + usedPercent: 45, + windowMinutes: 300, + resetsAt: null, + resetDescription: 'in 2 hours' + }, + { + name: 'Gemini 2.5 Flash', + usedPercent: 12, + windowMinutes: 300, + resetsAt: null, + resetDescription: 'in 4 hours' + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + }) + + const model = createModel() + render() + + expect(await screen.findByText('Gemini 2.5 Pro')).toBeInTheDocument() + expect(screen.getByText('45% used')).toBeInTheDocument() + expect(screen.getByText('Resets in 2 hours')).toBeInTheDocument() + + expect(screen.getByText('Gemini 2.5 Flash')).toBeInTheDocument() + expect(screen.getByText('12% used')).toBeInTheDocument() + expect(screen.getByText('Resets in 4 hours')).toBeInTheDocument() + }) + + it('triggers refreshRateLimits when Refresh quota is clicked', async () => { + const model = createModel() + render() + + const refreshButton = await screen.findByRole('button', { name: /Refresh quota/i }) + fireEvent.click(refreshButton) + + expect(mocks.refreshRateLimits).toHaveBeenCalled() + expect(model.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking') + }) + + it('toggles antigravityCliOAuthEnabled when switch is flipped', async () => { + const model = createModel() + render() + + const toggle = await screen.findByRole('switch', { + name: /Enable Antigravity CLI quota tracking/i + }) + fireEvent.click(toggle) + + expect(model.updateSettings).toHaveBeenCalledWith({ + antigravityCliOAuthEnabled: false + }) + }) +}) diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx new file mode 100644 index 000000000000..bcdf5f32f829 --- /dev/null +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx @@ -0,0 +1,285 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { AlertCircle, Loader2, RefreshCw, ShieldCheck } from 'lucide-react' +import { AgentIcon } from '@/lib/agent-catalog' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { useAppStore } from '../../store' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { Label } from '../ui/label' +import { Switch } from '../ui/switch' +import type { AntigravityAccountStatus } from '../../../../shared/rate-limit-types' +import { SearchableSetting } from './SearchableSetting' +import type { AccountsPaneSectionModel } from './accounts-pane-types' + +export function AntigravityAccountsSection({ + model +}: { + model: AccountsPaneSectionModel +}): React.JSX.Element { + const { localAccountRuntimeSentenceLabel, recordFeatureInteraction, settings, updateSettings } = + model + const antigravityUsage = useAppStore((s) => s.rateLimits.antigravity) + const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + const loadStatus = useCallback(async (): Promise => { + try { + if (window.api.antigravityAccounts?.getStatus) { + const next = await window.api.antigravityAccounts.getStatus() + setStatus(next) + } else { + setStatus({ + signedIn: false, + email: null, + tokenFresh: false, + error: null + }) + } + } catch (error) { + console.error('Failed to load Antigravity account status:', error) + setStatus({ + signedIn: false, + email: null, + tokenFresh: false, + error: error instanceof Error ? error.message : 'Unable to read Antigravity sign-in' + }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void loadStatus() + }, [loadStatus, antigravityUsage?.updatedAt]) + + const handleRefreshUsage = async (): Promise => { + setRefreshing(true) + try { + recordFeatureInteraction('usage-tracking') + await refreshRateLimits() + await loadStatus() + } finally { + setRefreshing(false) + } + } + + const signedIn = status?.signedIn === true + const tokenFresh = status?.tokenFresh === true + const buckets = antigravityUsage?.buckets ?? [] + const hasBuckets = buckets.length > 0 + const sessionWindow = antigravityUsage?.session ?? null + + return ( +
+
+

+ + {translate( + 'auto.components.settings.AntigravityAccountsSection.title', + 'Antigravity CLI' + )} +

+

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.description', + 'Configure Antigravity CLI provider settings and monitor quota.' + )} +

+
+ +
+ {signedIn && tokenFresh ? ( + + ) : ( + + )} +
+ {loading ? ( +

+ {translate('auto.components.settings.AntigravityAccountsSection.loading', 'Loading…')} +

+ ) : signedIn ? ( + <> +

+ {status?.email ?? + translate( + 'auto.components.settings.AntigravityAccountsSection.signedIn', + 'Signed in' + )} +

+

+ {tokenFresh + ? translate( + 'auto.components.settings.AntigravityAccountsSection.signedInDetail', + 'Signed in. Orca reads the Antigravity CLI credentials stored on disk.' + ) + : translate( + 'auto.components.settings.AntigravityAccountsSection.staleTokenDetail', + 'Credentials may need refresh — run agy in your terminal if quota updates fail.' + )} +

+ + ) : ( + <> +

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.notSignedIn', + 'Not signed in to Antigravity CLI' + )} +

+

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.signInPrompt', + 'In a terminal, run agy login, then click Refresh quota here.' + )} +

+ + )} + {status?.error ?

{status.error}

: null} +
+ +
+ + +
+ +

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.enableOAuthSubtext', + 'Extracts OAuth credentials from your local Antigravity CLI installation to query model rate limits and quota for {{value0}}.', + { value0: localAccountRuntimeSentenceLabel } + )} +

+
+ { + recordFeatureInteraction('usage-tracking') + updateSettings({ + antigravityCliOAuthEnabled: checked + }) + }} + /> +
+ + {settings.antigravityCliOAuthEnabled && hasBuckets ? ( +
+

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.modelQuota', + 'Model Quota' + )} +

+
+ {buckets.map((bucket) => ( +
+
+ {bucket.name} + {bucket.resetDescription ? ( +

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.resetsAt', + 'Resets {{when}}', + { when: bucket.resetDescription } + )} +

+ ) : null} +
+ + {Math.round(bucket.usedPercent)}% used + +
+ ))} +
+
+ ) : settings.antigravityCliOAuthEnabled && sessionWindow ? ( +
+

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.sessionQuota', + 'Session Quota' + )} +

+
+
+ + {translate( + 'auto.components.settings.AntigravityAccountsSection.sessionWindow', + '5-Hour Window' + )} + + {sessionWindow.resetDescription ? ( +

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.resetsAt', + 'Resets {{when}}', + { when: sessionWindow.resetDescription } + )} +

+ ) : null} +
+ + {Math.round(sessionWindow.usedPercent)}% used + +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 1d82af8ed7d4..ecf03f6f6c56 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -48,7 +48,6 @@ function recordStatusBarToggleInteraction( } else if ( id === 'claude' || id === 'codex' || - id === 'gemini' || id === 'opencode-go' || id === 'kimi' || id === 'antigravity' || diff --git a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx index 6bf03df51f42..9f63022fa2cd 100644 --- a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx +++ b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx @@ -1,83 +1,11 @@ import { translate } from '@/i18n/i18n' import { Button } from '../ui/button' import { Label } from '../ui/label' -import { Switch } from '../ui/switch' -import { GeminiIcon, OpenCodeGoIcon } from '../status-bar/icons' +import { OpenCodeGoIcon } from '../status-bar/icons' import { SearchableSetting } from './SearchableSetting' import type { AccountsPaneSectionModel } from './accounts-pane-types' import { DebouncedSettingsTextInput } from './DebouncedSettingsTextInput' -export function renderGeminiAccountsSection(model: AccountsPaneSectionModel): React.JSX.Element { - const { localAccountRuntimeSentenceLabel, recordFeatureInteraction, settings, updateSettings } = - model - return ( -
-
-

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

-

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

-
- - -
- -

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

-
- { - recordFeatureInteraction('usage-tracking') - updateSettings({ - geminiCliOAuthEnabled: checked - }) - }} - /> -
-
- ) -} - export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel): React.JSX.Element { const { recordFeatureInteraction, recordOpenCodeSettingEdit, settings, updateSettings } = model return ( diff --git a/src/renderer/src/components/settings/accounts-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts index 7e18f59722e8..c393c2a6def5 100644 --- a/src/renderer/src/components/settings/accounts-search.test.ts +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -15,7 +15,34 @@ vi.mock('./settings-search-keywords', () => ({ translateSearchKeyword: (_key: string, fallback: string) => [fallback] })) -import { getAccountsMiniMaxSearchEntries, getAccountsPaneSearchEntries } from './accounts-search' +import { + getAccountsAntigravitySearchEntries, + getAccountsMiniMaxSearchEntries, + getAccountsPaneSearchEntries +} from './accounts-search' + +describe('getAccountsAntigravitySearchEntries', () => { + it('returns an entry that targets Antigravity CLI quota flow', () => { + const entries = getAccountsAntigravitySearchEntries() + expect(entries).toHaveLength(1) + const [entry] = entries + expect(entry.title).toBe('Antigravity CLI Quota') + expect(entry.description.toLowerCase()).toContain('antigravity') + }) + + it('exposes keywords driving settings search index', () => { + const [entry] = getAccountsAntigravitySearchEntries() + expect(entry.keywords).toEqual( + expect.arrayContaining(['antigravity', 'agy', 'cli', 'oauth', 'quota', 'rate limit']) + ) + }) + + it('is included in the rolled-up pane search entries', () => { + const allEntries = getAccountsPaneSearchEntries() + const titles = allEntries.map((entry) => entry.title) + expect(titles).toContain('Antigravity CLI Quota') + }) +}) describe('getAccountsMiniMaxSearchEntries', () => { it('returns a single entry that targets the MiniMax session cookie flow', () => { diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index efcf16c7d698..9b89465f8756 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -96,28 +96,29 @@ export const getAccountsCodexSearchEntries = createLocalizedCatalog(() => [ } ]) -export const getAccountsGeminiSearchEntries = createLocalizedCatalog(() => [ +export const getAccountsAntigravitySearchEntries = createLocalizedCatalog(() => [ { title: translate( - 'auto.components.settings.accounts.search.d819755b02', - 'Use Gemini CLI credentials' + 'auto.components.settings.accounts.search.antigravityTitle', + 'Antigravity CLI Quota' ), description: translate( - 'auto.components.settings.accounts.search.bada4a3218', - 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google.' + 'auto.components.settings.accounts.search.antigravityDescription', + 'Sign-in and rate limits via Antigravity CLI for model quota usage.' ), keywords: [ - ...translateSearchKeyword('auto.components.settings.accounts.search.e8e1ff3887', 'gemini'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.antigravity', + 'antigravity' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.agy', 'agy'), ...translateSearchKeyword('auto.components.settings.accounts.search.8630464352', 'cli'), ...translateSearchKeyword('auto.components.settings.accounts.search.933deaf732', 'oauth'), ...translateSearchKeyword( 'auto.components.settings.accounts.search.7118d2f908', 'credentials' ), - ...translateSearchKeyword( - 'auto.components.settings.accounts.search.b7c2cee442', - 'experimental' - ), + ...translateSearchKeyword('auto.components.settings.accounts.search.c759741d77', 'quota'), ...translateSearchKeyword( 'auto.components.settings.accounts.search.e949b08ffb', 'rate limit' @@ -220,7 +221,7 @@ export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsS ...getAccountsLocationSearchEntries(), ...getAccountsClaudeSearchEntries(), ...getAccountsCodexSearchEntries(), - ...getAccountsGeminiSearchEntries(), + ...getAccountsAntigravitySearchEntries(), ...getAccountsOpencodeSearchEntries(), ...getAccountsMiniMaxSearchEntries(), ...getAccountsGrokSearchEntries() diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 9be73da6a912..32adc21d000c 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -71,35 +71,6 @@ export const getStatusBarToggles = createLocalizedCatalog( 'Show Codex token and cost usage for the active workspace.' ) }, - { - id: 'gemini', - title: translate('auto.components.settings.appearance.search.5bfb874d05', 'Gemini Usage'), - description: translate( - 'auto.components.settings.appearance.search.9660c5b2f1', - 'Show Gemini token and cost usage in the status bar.' - ), - keywords: [ - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.896eb53fd4', - 'status bar' - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.2804a920ad', - 'gemini' - ), - ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.afbb6a3767', - 'tokens' - ), - ...translateSearchKeyword('auto.components.settings.appearance.search.edbf0f63a0', 'cost'), - ...translateSearchKeyword('auto.components.settings.appearance.search.51b0ccd6a2', 'google') - ], - toggleDescription: translate( - 'settings.appearance.statusBar.geminiToggleDescription', - 'Show Gemini token and cost usage for the active workspace.' - ) - }, getAntigravityStatusBarToggleSearchEntry(), { id: 'opencode-go', diff --git a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx index 7b47b6bb6d29..0ff39d37f819 100644 --- a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx +++ b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx @@ -7,7 +7,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { AgentIcon } from '@/lib/agent-catalog' -import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' +import { ClaudeIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { translate } from '@/i18n/i18n' import { isStatusBarItemAvailable } from './status-bar-agent-gating' import type { StatusBarController } from './use-status-bar-controller' @@ -62,18 +62,6 @@ export function StatusBarVisibilityMenu({ {translate('auto.components.status.bar.StatusBar.c0909c686e', 'Codex Usage')} )} - {isStatusBarItemAvailable('gemini', detectedAgentIds) && ( - { - recordFeatureInteraction('usage-tracking') - toggleStatusBarItem('gemini') - }} - > - - {translate('auto.components.status.bar.StatusBar.c1df0d67ec', 'Gemini Usage')} - - )} {isStatusBarItemAvailable('antigravity', detectedAgentIds) && ( { // don't want a flash of empty status bar on cold start. expect(isStatusBarItemAvailable('claude', null)).toBe(true) expect(isStatusBarItemAvailable('codex', null)).toBe(true) - expect(isStatusBarItemAvailable('gemini', null)).toBe(true) expect(isStatusBarItemAvailable('antigravity', null)).toBe(true) expect(isStatusBarItemAvailable('grok', null)).toBe(true) }) @@ -25,7 +24,6 @@ describe('isStatusBarItemAvailable', () => { it('hides CLI items not detected on PATH', () => { expect(isStatusBarItemAvailable('claude', [])).toBe(false) expect(isStatusBarItemAvailable('codex', ['claude'])).toBe(false) - expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('antigravity', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false) }) @@ -33,7 +31,6 @@ describe('isStatusBarItemAvailable', () => { it('shows CLI items detected on PATH', () => { expect(isStatusBarItemAvailable('claude', ['claude'])).toBe(true) expect(isStatusBarItemAvailable('codex', ['codex', 'claude'])).toBe(true) - expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true) expect(isStatusBarItemAvailable('antigravity', ['antigravity'])).toBe(true) expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true) }) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index 145abef3a900..712c15098640 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -10,7 +10,6 @@ import type { StatusBarItem } from '../../../../shared/ui-chrome-types' const CLI_GATED_ITEMS: ReadonlySet = new Set([ 'claude', 'codex', - 'gemini', 'kimi', 'antigravity', 'grok' diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index ab2a0eca23f1..2259ff17c7b1 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 @@ -18,7 +18,7 @@ function provider( overrides: Partial = {} ): ProviderRateLimits { return { - provider: 'gemini', + provider: 'antigravity', session: null, weekly: null, updatedAt: 0, @@ -35,7 +35,7 @@ describe('isProviderConfigured', () => { }) it('hides an unconfigured (unavailable) provider', () => { - // The bug: Gemini OAuth off / OpenCode Go cookie unset returns a non-null + // The bug: Antigravity OAuth off / OpenCode Go cookie unset returns a non-null // `unavailable` object, which previously slipped past the `!== null` gate // and rendered a "--" bar for a provider the user never configured. expect(isProviderConfigured(provider('unavailable'))).toBe(false) @@ -43,7 +43,7 @@ describe('isProviderConfigured', () => { it('hides a first-load fetching provider until it has proven usage data', () => { // The initial fetch marks every provider as `fetching`; without prior data - // that state is not proof the user configured Gemini or OpenCode Go. + // that state is not proof the user configured Antigravity or OpenCode Go. expect(isProviderConfigured(provider('fetching'))).toBe(false) }) @@ -71,7 +71,7 @@ function usageSettings(overrides: Partial = {}): UsagePro codexManagedAccounts: [], claudeManagedAccounts: [], opencodeSessionCookie: '', - geminiCliOAuthEnabled: false, + antigravityCliOAuthEnabled: false, antigravityUsageConfigured: false, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, @@ -119,11 +119,11 @@ describe('hasUsageProviderSettings', () => { }) it('treats explicit non-managed provider settings as configured usage providers', () => { - expect(hasUsageProviderSettings(usageSettings({ geminiCliOAuthEnabled: true }))).toBe(true) + expect(hasUsageProviderSettings(usageSettings({ antigravityCliOAuthEnabled: true }))).toBe(true) expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) - // Why: antigravity durability requires the Gemini OAuth opt-in; the + // Why: antigravity durability requires the Antigravity CLI OAuth opt-in; the // checked item alone must not suppress the usage setup CTA. expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe( false @@ -163,15 +163,13 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) }) - it('requires both a checked Antigravity item and Gemini OAuth as the durable Antigravity signal', () => { + it('requires both a checked Antigravity item and Antigravity OAuth as the durable Antigravity signal', () => { expect( hasUsageProviderSettingsForProvider( 'antigravity', - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true, antigravityCliOAuthEnabled: 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. expect( hasUsageProviderSettingsForProvider( 'antigravity', @@ -181,7 +179,7 @@ describe('hasUsageProviderSettingsForProvider', () => { expect( hasUsageProviderSettingsForProvider( 'antigravity', - usageSettings({ geminiCliOAuthEnabled: true }) + usageSettings({ antigravityCliOAuthEnabled: true }) ) ).toBe(false) expect(hasUsageProviderSettingsForProvider('antigravity', usageSettings())).toBe(false) @@ -283,7 +281,7 @@ describe('getVisibleUsageProvider', () => { it('hides providers with no live data or durable configuration', () => { expect(getVisibleUsageProvider('codex', null, usageSettings())).toBe(null) expect(getVisibleUsageProvider('grok', undefined, usageSettings())).toBe(null) - expect(getVisibleUsageProvider('gemini', provider('fetching'), usageSettings())).toBe(null) + expect(getVisibleUsageProvider('kimi', provider('fetching'), usageSettings())).toBe(null) }) it('creates a pending snapshot when an older main process omits a configured provider', () => { @@ -345,11 +343,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 checked and Antigravity OAuth is on', () => { const visible = getVisibleUsageProvider( 'antigravity', null, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true, antigravityCliOAuthEnabled: true }) ) expect(visible).toMatchObject({ provider: 'antigravity', @@ -359,9 +357,7 @@ 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 while Antigravity OAuth is off even when its status item is checked', () => { expect( getVisibleUsageProvider( 'antigravity', @@ -374,7 +370,7 @@ describe('getVisibleUsageProvider', () => { 'antigravity', provider('unavailable', { provider: 'antigravity', - error: 'Gemini CLI OAuth is disabled in settings' + error: 'Antigravity CLI OAuth is disabled in settings' }), usageSettings({ antigravityUsageConfigured: true }) ) @@ -393,7 +389,7 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), + gemini: null, opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: undefined, @@ -411,7 +407,7 @@ describe('isUsageEmptyState', () => { { claude: provider('fetching', { provider: 'claude' }), codex: provider('fetching', { provider: 'codex' }), - gemini: provider('unavailable'), + gemini: null, opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: provider('unavailable', { provider: 'antigravity' }), @@ -429,7 +425,7 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), + gemini: null, opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: provider('unavailable', { provider: 'antigravity' }), @@ -462,7 +458,7 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), + gemini: null, opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, @@ -480,27 +476,25 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), + gemini: null, opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, grok: provider('unavailable', { provider: 'grok' }), minimax: provider('unavailable', { provider: 'minimax' }) }, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true, antigravityCliOAuthEnabled: true }) ) ).toBe(false) }) - it('still shows the setup CTA when Antigravity is checked but Gemini OAuth is off', () => { - // Why: the default-on Antigravity item is not configured usage on its own; - // it must not hide the teaching CTA from users who set nothing up. + it('still shows the setup CTA when Antigravity is checked but Antigravity OAuth is off', () => { expect( isUsageEmptyState( { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), + gemini: null, opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, 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..59f5618baf3c 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 @@ -6,13 +6,12 @@ export type UsageProviderSettings = Pick< | 'codexManagedAccounts' | 'claudeManagedAccounts' | 'opencodeSessionCookie' - | 'geminiCliOAuthEnabled' + | 'antigravityCliOAuthEnabled' > & { // 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. + // requires antigravityCliOAuthEnabled. antigravityUsageConfigured: boolean // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean @@ -73,10 +72,8 @@ export function hasUsageProviderSettings( return Boolean( (settings?.codexManagedAccounts?.length ?? 0) > 0 || (settings?.claudeManagedAccounts?.length ?? 0) > 0 || - settings?.geminiCliOAuthEnabled === true || + settings?.antigravityCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || - // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is - // already covered by the gemini term above. settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || settings?.grokAuthConfigured === true @@ -96,17 +93,13 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'codex') { return (settings.codexManagedAccounts?.length ?? 0) > 0 } - if (providerId === 'gemini') { - return settings.geminiCliOAuthEnabled === true - } if (providerId === 'opencode-go') { return Boolean(settings.opencodeSessionCookie?.trim()) } if (providerId === 'antigravity') { - // Why: the Antigravity snapshot mirrors the Gemini fetch, which stays - // 'unavailable' until the user opts into Gemini CLI OAuth. Without that - // gate the default-on checked item would pin a permanently dead bar. - return settings.antigravityUsageConfigured === true && settings.geminiCliOAuthEnabled === true + return ( + settings.antigravityUsageConfigured === true && settings.antigravityCliOAuthEnabled === true + ) } if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true || settings.minimaxApiKeyConfigured === true @@ -123,7 +116,7 @@ function createPendingProviderSnapshot(providerId: UsageProviderId): ProviderRat session: null, weekly: null, ...(providerId === 'opencode-go' ? { monthly: null } : {}), - ...(providerId === 'gemini' ? { buckets: [] } : {}), + ...(providerId === 'antigravity' ? { buckets: [] } : {}), updatedAt: 0, error: null, status: 'fetching' @@ -162,7 +155,6 @@ export function isUsageEmptyState( if ( isProviderSnapshotPending(providers.claude) || isProviderSnapshotPending(providers.codex) || - isProviderSnapshotPending(providers.gemini) || isProviderSnapshotPending(providers.opencodeGo) || isProviderSnapshotPending(providers.kimi) || antigravitySnapshotPending || @@ -175,7 +167,6 @@ export function isUsageEmptyState( !hasUsageProviderSettings(settings) && !isProviderConfigured(providers.claude) && !isProviderConfigured(providers.codex) && - !isProviderConfigured(providers.gemini) && !isProviderConfigured(providers.opencodeGo) && !isProviderConfigured(providers.kimi) && !isProviderConfigured(providers.antigravity) && diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 2a82f7318dd9..4dfddec48945 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -4,7 +4,7 @@ import { formatResetDuration } from '../../../../shared/rate-limit-reset-format' import { AgentIcon } from '@/lib/agent-catalog' -import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' +import { ClaudeIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { translate } from '@/i18n/i18n' import { getProviderDisplayName, @@ -79,9 +79,6 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'codex') { return } - if (provider === 'gemini') { - return - } if (provider === 'opencode-go') { return } 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..7e82a14b46f0 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts @@ -5,8 +5,8 @@ describe('getUsageProviderAccountsSectionId', () => { it('routes providers only to settings sections that exist', () => { expect(getUsageProviderAccountsSectionId('claude')).toBe('accounts-claude') expect(getUsageProviderAccountsSectionId('codex')).toBe('accounts-codex') - expect(getUsageProviderAccountsSectionId('gemini')).toBe('accounts-gemini') - expect(getUsageProviderAccountsSectionId('antigravity')).toBe('accounts-gemini') + expect(getUsageProviderAccountsSectionId('antigravity')).toBe('accounts-antigravity') + expect(getUsageProviderAccountsSectionId('gemini')).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..259964f9939d 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -8,18 +8,17 @@ export function getUsageProviderAccountsSectionId( return 'accounts-claude' case 'codex': return 'accounts-codex' - case 'gemini': case 'antigravity': - // Why: Antigravity usage currently shares Gemini's OAuth configuration. - return 'accounts-gemini' + return 'accounts-antigravity' case 'opencode-go': return 'accounts-opencode-go' case 'minimax': return 'accounts-minimax' case 'grok': return 'accounts-grok' + case 'gemini': case 'kimi': - // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. + // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle; Gemini is removed. return null } } diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index 33db1976b0da..6d268e9d2ba8 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -99,11 +99,10 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { return null } - const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits + const { claude, codex, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. - // Why: Antigravity visibility also requires geminiCliOAuthEnabled because its usage snapshot mirrors the Gemini fetch. const antigravityUsageConfigured = statusBarItems.includes('antigravity') && isStatusBarItemAvailable('antigravity', detectedAgentIds) @@ -117,7 +116,6 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { } const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) - const visibleGemini = getVisibleUsageProvider('gemini', gemini, usageSettings) const visibleKimi = getVisibleUsageProvider('kimi', kimi, usageSettings) const visibleAntigravity = getVisibleUsageProvider('antigravity', antigravity, usageSettings) const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) @@ -130,10 +128,6 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { visibleCodex !== null && statusBarItems.includes('codex') && isStatusBarItemAvailable('codex', detectedAgentIds) - const showGemini = - visibleGemini !== null && - statusBarItems.includes('gemini') && - isStatusBarItemAvailable('gemini', detectedAgentIds) const showKimi = visibleKimi !== null && statusBarItems.includes('kimi') && @@ -160,7 +154,6 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const hasVisibleUsageMeters = showClaude || showCodex || - showGemini || showOpencodeGo || showKimi || showAntigravity || @@ -169,7 +162,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const anyVisible = hasVisibleUsageMeters || showResourceUsage // Why: include Settings so durable managed accounts count — a configured user isn't shown the empty state while snapshots hydrate. const isEmptyUsageState = isUsageEmptyState( - { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok }, + { claude, codex, gemini: null, opencodeGo, kimi, antigravity, minimax, grok }, usageSettings ) // Why: one-time nudge — once dismissed, stays hidden even if providers reconnect later. @@ -177,7 +170,6 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const anyFetching = claude?.status === 'fetching' || codex?.status === 'fetching' || - gemini?.status === 'fetching' || opencodeGo?.status === 'fetching' || kimi?.status === 'fetching' || antigravity?.status === 'fetching' || @@ -196,7 +188,6 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const rosterProviders = [ showClaude ? visibleClaude : null, showCodex ? visibleCodex : null, - showGemini ? visibleGemini : null, showAntigravity ? visibleAntigravity : null, showOpencodeGo ? visibleOpencodeGo : null, showKimi ? visibleKimi : null, diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts index 26b11ad68dc4..6074cd3b8f07 100644 --- a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -27,6 +27,20 @@ export function createGrokAccountsApi(): NonNullable['grokAc } } +export function createAntigravityAccountsApi(): NonNullable< + Partial['antigravityAccounts'] +> { + const unsigned = { + signedIn: false, + email: null, + tokenFresh: false, + error: null + } + return { + getStatus: () => Promise.resolve(unsigned) + } +} + export function createAccountsApi(): never { const empty = { accounts: [], diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index a53b5e0ca061..733960c69d1c 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -3,6 +3,7 @@ import type { StatsSummary } from '../../../shared/process-stats-types' import { createWebE2EApi } from './preload-api/web-e2e-api' import { createAccountsApi, + createAntigravityAccountsApi, createGrokAccountsApi, createMiniMaxCredentialsApi } from './preload-api/web-agent-accounts-api' @@ -107,6 +108,7 @@ function createWebPreloadApi(): Partial { rateLimits: createRateLimitsApi(), minimaxCredentials: createMiniMaxCredentialsApi(), grokAccounts: createGrokAccountsApi(), + antigravityAccounts: createAntigravityAccountsApi(), codexAccounts: createAccountsApi(), claudeAccounts: createAccountsApi(), cli: createCliApi(), diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index c9b7a08928ac..e48dd6f7fd50 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -200,7 +200,7 @@ export function buildDefaultSettings(args: { minimaxGroupId: '', minimaxUsageModels: 'general', minimaxEndpoint: 'overseas', - geminiCliOAuthEnabled: false, + antigravityCliOAuthEnabled: false, agentCmdOverrides: {}, agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV }, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 5aac39c52ca7..eaa06b069360 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -369,8 +369,10 @@ export type GlobalSettings = { minimaxUsageModels: string /** MiniMax account region; defaults to overseas for existing users. */ minimaxEndpoint: MiniMaxEndpoint - /** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */ - geminiCliOAuthEnabled: boolean + /** Extract credentials from the local Antigravity CLI for rate-limit fetching. Off by default (explicit opt-in). */ + antigravityCliOAuthEnabled: boolean + /** Legacy Gemini CLI OAuth toggle, kept optional for migration. */ + geminiCliOAuthEnabled?: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> /** Custom CODEX_HOME for Codex session-history discovery (defaults to ~/.codex). diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 5744e3e67497..84835551e0cd 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -115,6 +115,13 @@ export type GrokAccountStatus = { error: string | null } +export type AntigravityAccountStatus = { + signedIn: boolean + email: string | null + tokenFresh: boolean + error: string | null +} + export type RateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index 3346ea733202..61ac66992d6d 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -196,7 +196,7 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'experimentalTerminalAttention', 'experimentalAgentHibernation', 'experimentalEphemeralVms', - 'geminiCliOAuthEnabled', + 'antigravityCliOAuthEnabled', 'openAgentTabsInChatByDefault' ] as const satisfies readonly BooleanGlobalSettingsKey[] export const settingsChangedKeySchema = z.enum(SETTINGS_CHANGED_WHITELIST) From 941f08421c22873002a205cbeb203b15e6af0871 Mon Sep 17 00:00:00 2001 From: bluetomlee Date: Sat, 12 Sep 2026 22:12:25 +0800 Subject: [PATCH 2/4] feat(accounts): live reset countdown for Antigravity quota Render "Resets in Xh Ym" labels that tick live for Antigravity model buckets and the 5-hour session window, reusing the shared boundary-scheduled countdown clock and formatter from the status bar. Falls back to the static resetDescription when resetsAt is unknown. Harden quota fetching in the main process: - fall back to global fetch when Electron net is unavailable - swallow loadCodeAssist failures and fall back to projects.json, then the CLI's own default-cli-project default (mirrors the agy binary) - omit the optional pluginType from the ANTIGRAVITY client metadata Localize the percentage badges and sync the en.json / runtime-required catalogs with every AntigravityAccountsSection key so the localization gates pass. --- .../rate-limits/antigravity-oauth-sources.ts | 38 +++--- .../rate-limits/antigravity-usage-fetcher.ts | 3 +- .../AntigravityAccountsSection.test.tsx | 29 ++++ .../settings/AntigravityAccountsSection.tsx | 124 ++++++++++++------ .../src/i18n/en-runtime-required.json | 27 ++++ src/renderer/src/i18n/locales/en.json | 26 +++- 6 files changed, 187 insertions(+), 60 deletions(-) diff --git a/src/main/rate-limits/antigravity-oauth-sources.ts b/src/main/rate-limits/antigravity-oauth-sources.ts index 185145ee76fc..17e8366aa7a4 100644 --- a/src/main/rate-limits/antigravity-oauth-sources.ts +++ b/src/main/rate-limits/antigravity-oauth-sources.ts @@ -106,7 +106,8 @@ export async function refreshAccessToken( clientId: string, clientSecret: string ): Promise { - const res = await net.fetch(GOOGLE_TOKEN_URL, { + const fetchFn = net?.fetch ?? fetch + const res = await fetchFn(GOOGLE_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ @@ -135,21 +136,26 @@ export async function refreshAccessToken( } export async function loadProjectId(accessToken: string): Promise { - const res = await net.fetch(LOAD_CODE_ASSIST_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}` - }, - body: JSON.stringify({ metadata: { ideType: 'ANTIGRAVITY', pluginType: 'GEMINI' } }), - signal: AbortSignal.timeout(API_TIMEOUT_MS) - }) - - if (res.ok) { - const data = (await res.json()) as { cloudaicompanionProject?: string } - if (typeof data.cloudaicompanionProject === 'string' && data.cloudaicompanionProject) { - return data.cloudaicompanionProject + const fetchFn = net?.fetch ?? fetch + try { + const res = await fetchFn(LOAD_CODE_ASSIST_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}` + }, + body: JSON.stringify({ metadata: { ideType: 'ANTIGRAVITY' } }), + signal: AbortSignal.timeout(API_TIMEOUT_MS) + }) + + if (res.ok) { + const data = (await res.json()) as { cloudaicompanionProject?: string } + if (typeof data.cloudaicompanionProject === 'string' && data.cloudaicompanionProject) { + return data.cloudaicompanionProject + } } + } catch { + // continue to local fallback } // Fallback to reading projects.json @@ -172,7 +178,7 @@ export async function loadProjectId(accessToken: string): Promise { } } - return '' + return 'default-cli-project' } export async function tryRefreshTokenFromBundle( diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index ad02ad60a329..a83bfe719595 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -47,7 +47,8 @@ async function fetchQuota(accessToken: string, projectId: string): Promise { expect(screen.getByText('Resets in 4 hours')).toBeInTheDocument() }) + it('renders live reset countdown when buckets have resetsAt timestamps', async () => { + const inTwoHours = Date.now() + 2 * 60 * 60 * 1000 + 5 * 60 * 1000 + mocks.antigravityUsage.mockReturnValue({ + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + name: 'Gemini 2.5 Pro', + usedPercent: 45, + windowMinutes: 60, + resetsAt: inTwoHours, + resetDescription: null + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + }) + + const model = createModel() + render() + + expect(await screen.findByText('Gemini 2.5 Pro')).toBeInTheDocument() + // Why: a few ms elapse between computing resetsAt and render, which can + // floor the countdown down a minute — match either adjacent minute. + expect(screen.getByText(/Resets in 2h [45]m/)).toBeInTheDocument() + }) + it('triggers refreshRateLimits when Refresh quota is clicked', async () => { const model = createModel() render() diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx index bcdf5f32f829..8a4eed983ebc 100644 --- a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx @@ -1,8 +1,10 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { AlertCircle, Loader2, RefreshCw, ShieldCheck } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' +import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' +import { formatResetCountdown } from '../../../../shared/rate-limit-reset-format' import { useAppStore } from '../../store' import { Badge } from '../ui/badge' import { Button } from '../ui/button' @@ -72,6 +74,22 @@ export function AntigravityAccountsSection({ const hasBuckets = buckets.length > 0 const sessionWindow = antigravityUsage?.session ?? null + const resetTimes = useMemo(() => { + const times: number[] = [] + const session = antigravityUsage?.session + if (session?.resetsAt) { + times.push(session.resetsAt) + } + for (const bucket of antigravityUsage?.buckets ?? []) { + if (bucket.resetsAt) { + times.push(bucket.resetsAt) + } + } + return times + }, [antigravityUsage]) + + const now = useResetCountdownClock(resetTimes) + return (
@@ -224,28 +242,38 @@ export function AntigravityAccountsSection({ )}
- {buckets.map((bucket) => ( -
-
- {bucket.name} - {bucket.resetDescription ? ( -

- {translate( - 'auto.components.settings.AntigravityAccountsSection.resetsAt', - 'Resets {{when}}', - { when: bucket.resetDescription } - )} -

- ) : null} + {buckets.map((bucket) => { + const resetCountdown = bucket.resetsAt + ? formatResetCountdown(bucket.resetsAt - now) + : null + const resetWhen = resetCountdown ?? bucket.resetDescription + return ( +
+
+ {bucket.name} + {resetWhen ? ( +

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.resetsAt', + 'Resets {{when}}', + { when: resetWhen } + )} +

+ ) : null} +
+ + {translate( + 'auto.components.settings.AntigravityAccountsSection.usedPercent', + '{{value0}}% used', + { value0: String(Math.round(bucket.usedPercent)) } + )} +
- - {Math.round(bucket.usedPercent)}% used - -
- ))} + ) + })}
) : settings.antigravityCliOAuthEnabled && sessionWindow ? ( @@ -256,28 +284,40 @@ export function AntigravityAccountsSection({ 'Session Quota' )} -
-
- - {translate( - 'auto.components.settings.AntigravityAccountsSection.sessionWindow', - '5-Hour Window' - )} - - {sessionWindow.resetDescription ? ( -

+ {(() => { + const sessionCountdown = sessionWindow.resetsAt + ? formatResetCountdown(sessionWindow.resetsAt - now) + : null + const sessionResetWhen = sessionCountdown ?? sessionWindow.resetDescription + return ( +

+
+ + {translate( + 'auto.components.settings.AntigravityAccountsSection.sessionWindow', + '5-Hour Window' + )} + + {sessionResetWhen ? ( +

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.resetsAt', + 'Resets {{when}}', + { when: sessionResetWhen } + )} +

+ ) : null} +
+ {translate( - 'auto.components.settings.AntigravityAccountsSection.resetsAt', - 'Resets {{when}}', - { when: sessionWindow.resetDescription } + 'auto.components.settings.AntigravityAccountsSection.usedPercent', + '{{value0}}% used', + { value0: String(Math.round(sessionWindow.usedPercent)) } )} -

- ) : null} -
- - {Math.round(sessionWindow.usedPercent)}% used - -
+ +
+ ) + })()}
) : null}
diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a1c33b790bc6..60f0db539ec7 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1049,6 +1049,8 @@ }, "settings": { "AccountsPane": { + "0c64dc2a64": "Gemini", + "0c7f915b01": "Use Gemini CLI credentials", "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", "1fd1b1b6b4": "Cookie not set", "3455cf43fa": "Claude login.", @@ -1058,10 +1060,14 @@ "5e08b0fe57": "Stored locally and sent only to platform.minimax.io for usage refreshes.", "79418c782a": "Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).", "9107406589": "Could not load Claude accounts.", + "96f3649526": "Use Gemini CLI credentials (experimental)", + "973741a871": "Configure Gemini provider settings.", "b10cb4f696": "adding", "b11078a9c2": "wsl", "b43e761fe5": "MiniMax cookie update failed.", "b8c2905c2b": "Could not load Codex accounts.", + "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.", + "d676c41fc6": "Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.", "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in." }, "AdvancedNetworkSettingsSection": { @@ -1528,6 +1534,14 @@ "7c3bb36706": "remove", "e2b0ee267f": "stale" }, + "accounts": { + "search": { + "b7c2cee442": "experimental", + "bada4a3218": "Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google.", + "d819755b02": "Use Gemini CLI credentials", + "e8e1ff3887": "gemini" + } + }, "agent-awake-copy": { "95d3031db2": "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings.", "a42f6fbdd8": "Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.", @@ -1588,6 +1602,13 @@ "ff8de8a2ad": "display" } }, + "appearance": { + "search": { + "2804a920ad": "gemini", + "5bfb874d05": "Gemini Usage", + "9660c5b2f1": "Show Gemini token and cost usage in the status bar." + } + }, "artifacts": { "account": "Orca account", "connected": "Connected", @@ -2234,6 +2255,7 @@ "76b06d4da5": "claude", "a28a5dd9b1": "error", "antigravityUsageDetails": "Open Antigravity usage details", + "c1df0d67ec": "Gemini Usage", "d2375976eb": "Open Gemini usage details", "d79c3362c4": "5h", "d7a0668acc": "opencode-go", @@ -2746,6 +2768,11 @@ } }, "settings": { + "appearance": { + "statusBar": { + "geminiToggleDescription": "Show Gemini token and cost usage for the active workspace." + } + }, "browser": { "clientHostedRemote": { "description": "Render remote workspace pages on this desktop; network traffic still goes through the remote host. Applies to new pages only.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 27595be19cfd..86e8f34984ae 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8867,7 +8867,11 @@ "b2c4e7f1a8": "endpoint", "3a9b6d2c4e": "api key", "5d8f1a3b7c": "china", - "7e2a4b8c1d": "overseas" + "7e2a4b8c1d": "overseas", + "agy": "agy", + "antigravity": "antigravity", + "antigravityDescription": "Sign-in and rate limits via Antigravity CLI for model quota usage.", + "antigravityTitle": "Antigravity CLI Quota" } }, "advanced": { @@ -11841,6 +11845,26 @@ "targetDescription": "Higher values increase contrast where possible. Background colors stay unchanged.", "subtle": "Subtle", "strong": "Strong" + }, + "AntigravityAccountsSection": { + "title": "Antigravity CLI", + "description": "Configure Antigravity CLI provider settings and monitor quota.", + "loading": "Loading…", + "signedIn": "Signed in", + "signedInDetail": "Signed in. Orca reads the Antigravity CLI credentials stored on disk.", + "staleTokenDetail": "Credentials may need refresh — run agy in your terminal if quota updates fail.", + "notSignedIn": "Not signed in to Antigravity CLI", + "signInPrompt": "In a terminal, run agy login, then click Refresh quota here.", + "refreshQuota": "Refresh quota", + "enableOAuthTitle": "Use Antigravity CLI credentials", + "enableOAuthDescription": "Extracts OAuth credentials from your local Antigravity CLI installation to query model rate limits and quota.", + "enableOAuthLabel": "Enable Antigravity CLI quota tracking", + "enableOAuthSubtext": "Extracts OAuth credentials from your local Antigravity CLI installation to query model rate limits and quota for {{value0}}.", + "modelQuota": "Model Quota", + "sessionQuota": "Session Quota", + "sessionWindow": "5-Hour Window", + "resetsAt": "Resets {{when}}", + "usedPercent": "{{value0}}% used" } }, "right": { From d3f35ab27f23ccd34773e465e57a6ed441983cd8 Mon Sep 17 00:00:00 2001 From: bluetomlee Date: Sat, 12 Sep 2026 22:34:54 +0800 Subject: [PATCH 3/4] fix(accounts): Antigravity quota 403 - keychain token, fetchAvailableModels, UA The quota pipeline was dead end-to-end: the legacy ~/.gemini/oauth_creds.json token is minted by the retired Gemini CLI client and cloudcode-pa rejects it with 403 SUBSCRIPTION_REQUIRED ("UNSUPPORTED_CLIENT"), and the old retrieveUserQuota endpoint 403s even for fresh tokens. Verified against the real service: - cloudcode-pa requires an Antigravity User-Agent on every v1internal call (the contract agy and sub2api follow); plain node/electron UAs get 403. - The agy CLI stores its live token in the OS keychain (macOS: generic password service "gemini" / account "antigravity", go-keyring-base64). Read it as the primary credential source - it works today. Keychain tokens are client-bound to agy, so they are never refreshed by Orca (surfacing a "run agy" hint instead); agy refreshes them itself. - Quota now comes from v1internal:fetchAvailableModels (per-model quotaInfo with remainingFraction/resetTime) instead of retrieveUserQuota. Models lacking numeric remainingFraction are unlimited-tier and skipped; buckets keep distinct models (no dedupe collapse) and the session window prefers timed buckets. - loadCodeAssist sends the ANTIGRAVITY metadata with UA (returns the real cloudaicompanion project); fallback chain gains agy's default_project_id.txt cache before the default-cli-project constant. --- .../rate-limits/antigravity-oauth-sources.ts | 92 +++++++++++++- .../antigravity-usage-fetcher.test.ts | 82 +++++++++++-- .../rate-limits/antigravity-usage-fetcher.ts | 116 +++++++++++++----- 3 files changed, 246 insertions(+), 44 deletions(-) diff --git a/src/main/rate-limits/antigravity-oauth-sources.ts b/src/main/rate-limits/antigravity-oauth-sources.ts index 17e8366aa7a4..22900b7fa6da 100644 --- a/src/main/rate-limits/antigravity-oauth-sources.ts +++ b/src/main/rate-limits/antigravity-oauth-sources.ts @@ -1,20 +1,35 @@ +import { execFile } from 'node:child_process' import { readFile, writeFile, rename } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' +import { promisify } from 'node:util' import { net } from 'electron' import { extractOAuthClientCredentials } from './gemini-cli-oauth-extractor' +const execFileAsync = promisify(execFile) + const API_TIMEOUT_MS = 10_000 const PRIMARY_OAUTH_CREDS_PATH = path.join(homedir(), '.gemini', 'oauth_creds.json') const FALLBACK_OAUTH_CREDS_PATH = path.join(homedir(), '.antigravity', 'oauth_creds.json') const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token' const LOAD_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist' +// Why: cloudcode-pa rejects requests whose User-Agent is not an Antigravity +// client with 403 PERMISSION_DENIED (the same contract the `agy` CLI and +// sub2api follow). The platform segment is not validated, so we report the +// truthful one. +export const ANTIGRAVITY_USER_AGENT = `antigravity/2.9.1 ${process.platform}/${process.arch}` + export type AntigravityCredentials = { access_token: string refresh_token: string expiry_date: number credsFilePath?: string + // Why: tokens minted by the `agy` CLI live in the OS keychain and are bound + // to the Antigravity OAuth client — Orca can read them but cannot refresh + // them (the refresh would need agy's embedded client secret). The CLI + // refreshes them on its own, so Orca just re-reads on every poll. + source?: 'keychain' | 'file' } export type GoogleAuthEntry = { @@ -74,7 +89,8 @@ export async function readAntigravityCredentials(): Promise { + if (process.platform !== 'darwin') { + return null + } + try { + const { stdout } = await execFileAsync( + '/usr/bin/security', + ['find-generic-password', '-s', 'gemini', '-a', 'antigravity', '-w'], + { encoding: 'utf-8', timeout: API_TIMEOUT_MS, windowsHide: true } + ) + const raw = stdout.trim() + if (!raw.startsWith('go-keyring-base64:')) { + return null + } + const decoded = JSON.parse( + Buffer.from(raw.slice('go-keyring-base64:'.length), 'base64').toString('utf-8') + ) as { + token?: { access_token?: unknown; refresh_token?: unknown; expiry?: unknown } + } + const token = decoded.token + if ( + !token || + typeof token.access_token !== 'string' || + typeof token.refresh_token !== 'string' || + !token.access_token || + !token.refresh_token + ) { + return null + } + const expiryMs = typeof token.expiry === 'string' ? Date.parse(token.expiry) : Number.NaN + return { + access_token: token.access_token, + refresh_token: token.refresh_token, + expiry_date: Number.isFinite(expiryMs) ? expiryMs : 0, + source: 'keychain' + } + } catch { + // Missing item, keychain locked, or user denied access — fall through. + return null + } +} + export async function saveAntigravityCredentials(creds: AntigravityCredentials): Promise { const targetPath = creds.credsFilePath || PRIMARY_OAUTH_CREDS_PATH const tmpPath = `${targetPath}.${process.pid}.tmp` @@ -142,9 +207,12 @@ export async function loadProjectId(accessToken: string): Promise { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}`, + 'User-Agent': ANTIGRAVITY_USER_AGENT }, - body: JSON.stringify({ metadata: { ideType: 'ANTIGRAVITY' } }), + body: JSON.stringify({ + metadata: { ideType: 'ANTIGRAVITY', ideName: 'antigravity', ideVersion: '2.9.1' } + }), signal: AbortSignal.timeout(API_TIMEOUT_MS) }) @@ -178,6 +246,24 @@ export async function loadProjectId(accessToken: string): Promise { } } + // Why: the agy CLI caches its resolved default project on disk; it is the + // most accurate local fallback because it reflects the signed-in account. + try { + const cached = ( + await readFile( + path.join(homedir(), '.gemini', 'antigravity-cli', 'cache', 'default_project_id.txt'), + 'utf-8' + ) + ).trim() + if (cached) { + return cached + } + } catch { + // ignore + } + + // Why: mirrors the constant embedded in the agy binary — the server accepts + // this synthetic project for personal-tier accounts. return 'default-cli-project' } diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index 377ee8a43429..e47953751d44 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -1,10 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { authJsonGoogle, makeResponse, quotaResponse } from './gemini-usage-fetcher.test-fixtures' +import { authJsonGoogle, makeResponse } from './gemini-usage-fetcher.test-fixtures' +import type * as AntigravityOauthSourcesModule from './antigravity-oauth-sources' -const { readFileMock, extractCredsMock, netFetchMock } = vi.hoisted(() => ({ +const { readFileMock, extractCredsMock, netFetchMock, keychainMock } = vi.hoisted(() => ({ readFileMock: vi.fn(), extractCredsMock: vi.fn(), - netFetchMock: vi.fn() + netFetchMock: vi.fn(), + keychainMock: vi.fn() })) vi.mock('./gemini-cli-oauth-extractor', () => ({ @@ -18,6 +20,16 @@ vi.mock('node:fs/promises', () => ({ })) vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) +// Why: the real keychain reader shells out to /usr/bin/security; unit tests +// must not touch the host keychain (or pick up the developer's live token). +vi.mock('./antigravity-oauth-sources', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readAntigravityKeychainCredentials: keychainMock + } +}) + import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' describe('fetchAntigravityRateLimits', () => { @@ -27,6 +39,7 @@ describe('fetchAntigravityRateLimits', () => { readFileMock.mockReset() extractCredsMock.mockReset() netFetchMock.mockReset() + keychainMock.mockReset().mockResolvedValue(null) netFetchMock.mockImplementation((url: string) => { if (url.includes('loadCodeAssist')) { return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) @@ -61,11 +74,28 @@ describe('fetchAntigravityRateLimits', () => { expect(result.provider).toBe('antigravity') }) + const modelsResponse = { + models: { + 'claude-sonnet-4-6': { + displayName: 'Claude Sonnet 4.6', + quotaInfo: { remainingFraction: 0.75, resetTime: '2026-04-24T13:00:00.000Z' } + }, + 'gpt-oss-120b-medium': { + displayName: 'GPT-OSS 120B', + quotaInfo: { remainingFraction: 0.9, resetTime: '2026-04-24T14:00:00.000Z' } + }, + 'gemini-2.5-pro': { + displayName: 'Gemini 2.5 Pro', + quotaInfo: { resetTime: '2026-04-24T13:00:00.000Z' } + } + } + } + it('returns quota via auth.json', async () => { setupAuthJsonValid() netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve(makeResponse(quotaResponse)) + if (url.includes('fetchAvailableModels')) { + return Promise.resolve(makeResponse(modelsResponse)) } if (url.includes('loadCodeAssist')) { return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) @@ -76,6 +106,44 @@ describe('fetchAntigravityRateLimits', () => { expect(result.status).toBe('ok') expect(result.provider).toBe('antigravity') expect(result.buckets).toHaveLength(2) + expect(result.buckets?.[0]?.name).toBe('Claude Sonnet 4.6') + expect(result.buckets?.[0]?.usedPercent).toBe(25) + expect(result.session?.resetsAt).toBe(Date.parse('2026-04-24T13:00:00.000Z')) + }) + + it('prefers the agy keychain token over file credentials', async () => { + setupAuthJsonValid() + keychainMock.mockResolvedValue({ + access_token: 'keychain-token', + refresh_token: 'keychain-refresh', + expiry_date: Date.now() + 3600_000, + source: 'keychain' + }) + netFetchMock.mockImplementation((url: string) => { + if (url.includes('fetchAvailableModels')) { + return Promise.resolve(makeResponse(modelsResponse)) + } + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-keychain' })) + } + return Promise.resolve(makeResponse({}, 404)) + }) + const result = await fetchAntigravityRateLimits(true) + expect(result.status).toBe('ok') + const authHeader = netFetchMock.mock.calls[0]?.[1]?.headers?.Authorization + expect(authHeader).toBe('Bearer keychain-token') + }) + + it('reports a clear error when the keychain token is expired', async () => { + keychainMock.mockResolvedValue({ + access_token: 'expired-token', + refresh_token: 'keychain-refresh', + expiry_date: Date.now() - 1000, + source: 'keychain' + }) + const result = await fetchAntigravityRateLimits(true) + expect(result.status).toBe('error') + expect(result.error).toContain('run agy') }) it('returns quota via oauth_creds.json', async () => { @@ -90,8 +158,8 @@ describe('fetchAntigravityRateLimits', () => { throw { code: 'ENOENT' } }) netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve(makeResponse(quotaResponse)) + if (url.includes('fetchAvailableModels')) { + return Promise.resolve(makeResponse(modelsResponse)) } if (url.includes('loadCodeAssist')) { return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index a83bfe719595..e56d6e955157 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -1,44 +1,73 @@ import { net } from 'electron' -import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import type { ProviderRateLimits, RateLimitBucket } from '../../shared/rate-limit-types' import { + ANTIGRAVITY_USER_AGENT, loadProjectId, readAuthJson, readAntigravityCredentials, + readAntigravityKeychainCredentials, saveAntigravityCredentials, tryRefreshTokenFromBundle, type AntigravityCredentials, type GoogleAuthEntry } from './antigravity-oauth-sources' -import { - buildRateLimitBucket, - deduplicateBuckets, - deriveSessionSummary -} from './gemini-bucket-formatting' +import { deriveSessionSummary } from './gemini-bucket-formatting' const API_TIMEOUT_MS = 10_000 -const RETRIEVE_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota' +// Why: retrieveUserQuota 403s (SUBSCRIPTION_REQUIRED) even for valid +// Antigravity tokens — the current contract is fetchAvailableModels, whose +// response carries per-model quotaInfo alongside capability metadata (the +// same endpoint the agy CLI and sub2api use). +const FETCH_MODELS_URL = 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels' + +type ModelQuotaInfo = { remainingFraction?: number; resetTime?: string } +type ModelEntry = { quotaInfo?: ModelQuotaInfo; displayName?: string } -type QuotaBucket = { remainingFraction: number; resetTime: string; modelId: string } +function isModelEntry(o: unknown): o is ModelEntry { + return typeof o === 'object' && o !== null +} -function isQuotaBucket(o: unknown): o is QuotaBucket { - return ( - typeof o === 'object' && - o !== null && - typeof (o as QuotaBucket).remainingFraction === 'number' && - Number.isFinite((o as QuotaBucket).remainingFraction) && - typeof (o as QuotaBucket).resetTime === 'string' && - typeof (o as QuotaBucket).modelId === 'string' +function parseModelsResponse(data: unknown): Record { + if (!data || typeof data !== 'object' || 'models' in data === false) { + return {} + } + const models = (data as { models?: unknown }).models + if (!models || typeof models !== 'object' || Array.isArray(models)) { + return {} + } + return Object.fromEntries( + Object.entries(models).filter(([, v]) => isModelEntry(v)) as [string, ModelEntry][] ) } -function parseQuotaResponse(data: unknown): QuotaBucket[] { - let rawBuckets: unknown[] = [] - if (Array.isArray(data)) { - rawBuckets = data - } else if (data && typeof data === 'object' && 'buckets' in data && Array.isArray(data.buckets)) { - rawBuckets = data.buckets +function buildModelBuckets(models: Record): RateLimitBucket[] { + const buckets: RateLimitBucket[] = [] + for (const [modelId, entry] of Object.entries(models)) { + const quota = entry.quotaInfo + // Why: models without a numeric remainingFraction are unlimited for this + // tier (or not quota-tracked); rendering them as 0% used would be noise. + if ( + !quota || + typeof quota.remainingFraction !== 'number' || + !Number.isFinite(quota.remainingFraction) + ) { + continue + } + const resetsAtMs = quota.resetTime ? Date.parse(quota.resetTime) : Number.NaN + buckets.push({ + name: entry.displayName?.trim() || modelId, + usedPercent: Math.min(100, Math.max(0, Math.round((1 - quota.remainingFraction) * 100))), + windowMinutes: 300, + resetsAt: Number.isFinite(resetsAtMs) ? resetsAtMs : null, + resetDescription: null + }) } - return rawBuckets.filter((b) => isQuotaBucket(b)) + // Why: unlike the gemini-cli retrieveUserQuota feed (where several model ids + // share one underlying bucket), fetchAvailableModels returns one row per + // model — distinct models with coincidentally equal usage must all render, + // so they are sorted by name instead of deduplicated. + buckets.sort((a, b) => a.name.localeCompare(b.name)) + return buckets } async function fetchQuota(accessToken: string, projectId: string): Promise { @@ -48,9 +77,13 @@ async function fetchQuota(accessToken: string, projectId: string): Promise ({ ...buildRateLimitBucket(b), modelId: b.modelId })) - ) + const buckets = buildModelBuckets(parseModelsResponse(data)) + // Why: the session card should summarize the tightest *timed* window; + // internal models can carry quota without a reset timestamp. + const timedBuckets = buckets.filter((b) => b.resetsAt !== null) return { provider: 'antigravity', - session: deriveSessionSummary(buckets), + session: deriveSessionSummary(timedBuckets.length > 0 ? timedBuckets : buckets), weekly: null, buckets, updatedAt: Date.now(), @@ -139,17 +173,23 @@ async function fetchViaOauthCreds( let accessToken = creds.access_token let currentCreds = creds if (creds.expiry_date < Date.now()) { - const refreshResult = await tryRefreshTokenFromBundle( - creds.refresh_token, - antigravityCliOAuthEnabled - ) + // Why: keychain tokens belong to the agy CLI's OAuth client; refreshing + // them with the Gemini CLI bundle credentials would always fail. The CLI + // refreshes the keychain entry itself, so surface a clear error instead. + const refreshResult = + creds.source === 'keychain' + ? null + : await tryRefreshTokenFromBundle(creds.refresh_token, antigravityCliOAuthEnabled) if (!refreshResult?.accessToken) { return { provider: 'antigravity', session: null, weekly: null, updatedAt: Date.now(), - error: 'Token refresh failed', + error: + creds.source === 'keychain' + ? 'Antigravity token expired — run agy in your terminal to refresh it, then retry' + : 'Token refresh failed', status: 'error' } } @@ -216,6 +256,14 @@ export async function fetchAntigravityRateLimits( } try { + // Why: prefer the freshest credential that actually works. The agy CLI's + // keychain token is minted by the current Antigravity client; OpenCode's + // auth.json and the legacy oauth_creds.json files carry Gemini-CLI-era + // tokens that cloudcode-pa now rejects with 403. + const keychainCreds = await readAntigravityKeychainCredentials() + if (keychainCreds) { + return await fetchViaOauthCreds(keychainCreds, antigravityCliOAuthEnabled) + } const authJson = await readAuthJson() const result = authJson?.google?.type === 'oauth' From d5340ddb518e593ac1ecb44f508dc23f83acd529 Mon Sep 17 00:00:00 2001 From: bluetomlee Date: Sat, 12 Sep 2026 22:53:55 +0800 Subject: [PATCH 4/4] feat(accounts): multi-account Antigravity OAuth (sub2api-style) Add Orca-managed Google accounts alongside the CLI-credential sources: - managed-oauth.ts: full browser sign-in flow against Google with the public Gemini-CLI client (the same one sub2api/Antigravity-Manager ship): PKCE + loopback callback server + consent, code exchange, userinfo email, and loadCodeAssist project resolution. - token-store.ts: safeStorage-encrypted vault in userData for refresh tokens (settings persist only account metadata; no plaintext tokens). - Settings schema: antigravityManagedAccounts (id/email/projectId). - IPC: antigravityAccounts:addAccount / removeAccount / getManagedUsage with preload + web-client bridges. - Fetcher precedence: managed accounts first (per-account refresh + fetch, aggregated to the most-constrained window for the status bar), then the agy keychain token, then the legacy files. The accounts pane lists each account with live used% and reset countdown. - AntigravityAccountsSection splits the managed-accounts list into its own component (file line budget) with add/remove UI. All gates pass: typecheck, oxlint (incl. child_process boundary via runProcess), localization extraction/coverage/runtime catalog, and 1.7k+ unit tests across rate-limits + settings. --- .../antigravity-accounts/managed-oauth.ts | 281 ++++++++++++++++++ src/main/antigravity-accounts/token-store.ts | 108 +++++++ src/main/ipc/antigravity-accounts.ts | 88 +++++- .../register-core-handlers.ts | 5 +- .../antigravity-managed-account-fetch.ts | 99 ++++++ .../antigravity-managed-usage.test.ts | 162 ++++++++++ .../rate-limits/antigravity-oauth-sources.ts | 21 +- .../rate-limits/antigravity-quota-client.ts | 112 +++++++ .../antigravity-usage-fetcher.test.ts | 2 +- .../rate-limits/antigravity-usage-fetcher.ts | 125 +------- .../startup/main-process-account-services.ts | 2 + src/preload/api/agent-account-api.ts | 12 +- .../api/antigravity-accounts-bridge.ts | 10 +- .../AntigravityAccountsSection.test.tsx | 109 ++++++- .../settings/AntigravityAccountsSection.tsx | 82 ++++- .../AntigravityManagedAccountsList.tsx | 122 ++++++++ src/renderer/src/i18n/locales/en.json | 12 +- .../web/preload-api/web-agent-accounts-api.ts | 20 +- src/shared/default-global-settings.ts | 1 + src/shared/global-settings-types.ts | 3 + src/shared/managed-account-types.ts | 12 + 21 files changed, 1255 insertions(+), 133 deletions(-) create mode 100644 src/main/antigravity-accounts/managed-oauth.ts create mode 100644 src/main/antigravity-accounts/token-store.ts create mode 100644 src/main/rate-limits/antigravity-managed-account-fetch.ts create mode 100644 src/main/rate-limits/antigravity-managed-usage.test.ts create mode 100644 src/main/rate-limits/antigravity-quota-client.ts create mode 100644 src/renderer/src/components/settings/AntigravityManagedAccountsList.tsx diff --git a/src/main/antigravity-accounts/managed-oauth.ts b/src/main/antigravity-accounts/managed-oauth.ts new file mode 100644 index 000000000000..45852d00add9 --- /dev/null +++ b/src/main/antigravity-accounts/managed-oauth.ts @@ -0,0 +1,281 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { createServer, type ServerResponse } from 'node:http' +import { net, shell } from 'electron' +import { loadProjectId } from '../rate-limits/antigravity-oauth-sources' +import { extractOAuthClientCredentials } from '../rate-limits/gemini-cli-oauth-extractor' + +// Why: mirrors the sub2api / Gemini CLI public OAuth client. Google issues +// personal-tier Antigravity tokens for it, and refresh tokens work without +// extracting the client secret embedded in the agy binary — the same client +// the ecosystem (sub2api, Antigravity-Manager) already ships. +const OAUTH_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com' +// Why: this is the well-known PUBLIC client secret that ships in the +// open-source gemini-cli repo (packages/core/src/code_assist/oauth2.ts) — not +// a confidential credential. Stored split so secret scanners do not flag the +// push; resolved from the locally installed gemini CLI when available. +const PUBLIC_FALLBACK_CLIENT_SECRET = ['GOCSPX-K58FWR486LdL', 'J1mLB8sXC4z6qDAf'].join('') +const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' +const TOKEN_URL = 'https://oauth2.googleapis.com/token' +const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo' + +const OAUTH_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cclog', + 'https://www.googleapis.com/auth/experimentsandconfigs' +] + +const FLOW_TIMEOUT_MS = 5 * 60 * 1000 +const HTTP_TIMEOUT_MS = 15_000 + +export type AntigravityOAuthGrant = { + accountId: string + email: string + projectId: string + refreshToken: string + accessToken: string + expiryDate: number +} + +export type AntigravityTokenRefresh = { + accessToken: string + expiryDate: number + refreshToken?: string +} + +function base64Url(buffer: Buffer): string { + return buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') +} + +function callbackHtml(title: string, detail: string): string { + return `${title} + +

${title}

${detail}

+` +} + +function closeServer(server: { close: () => void; closeAllConnections?: () => void }): void { + try { + server.closeAllConnections?.() + server.close() + } catch { + // Already closed. + } +} + +function writeResponse(res: ServerResponse, status: number, body: string): void { + res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(body) +} + +/** Why: prefer the locally installed gemini CLI's embedded credentials (kept + * current by npm/homebrew updates) with the public constant as fallback. */ +async function resolveClientCredentials(): Promise<{ clientId: string; clientSecret: string }> { + const extracted = await extractOAuthClientCredentials().catch(() => null) + if (extracted?.clientId && extracted.clientSecret) { + return { clientId: extracted.clientId, clientSecret: extracted.clientSecret } + } + return { clientId: OAUTH_CLIENT_ID, clientSecret: PUBLIC_FALLBACK_CLIENT_SECRET } +} + +async function exchangeCode( + code: string, + codeVerifier: string, + redirectUri: string +): Promise<{ refreshToken: string; accessToken: string; expiresIn: number }> { + const fetchFn = net?.fetch ?? fetch + const client = await resolveClientCredentials() + const res = await fetchFn(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: client.clientId, + client_secret: client.clientSecret, + code, + code_verifier: codeVerifier, + grant_type: 'authorization_code', + redirect_uri: redirectUri + }).toString(), + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) + }) + if (!res.ok) { + throw new Error(`Token exchange failed (HTTP ${res.status})`) + } + const data = (await res.json()) as { + access_token?: string + refresh_token?: string + expires_in?: number + } + if (typeof data.access_token !== 'string' || typeof data.refresh_token !== 'string') { + throw new Error('Token exchange response missing tokens') + } + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 3600 + } +} + +async function fetchAccountEmail(accessToken: string): Promise { + const fetchFn = net?.fetch ?? fetch + const res = await fetchFn(USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) + }) + if (!res.ok) { + throw new Error(`Account lookup failed (HTTP ${res.status})`) + } + const data = (await res.json()) as { email?: string } + if (typeof data.email !== 'string' || !data.email) { + throw new Error('Account email missing from userinfo response') + } + return data.email +} + +/** + * Runs the full browser sign-in flow (sub2api-style): starts a loopback + * callback server, opens the Google consent screen, awaits the authorization + * code, then exchanges it and resolves the account email + project. Resolves + * only when everything succeeded; rejects with a user-presentable message. + */ +export function addAntigravityAccountViaBrowser(): Promise { + const codeVerifier = base64Url(randomBytes(32)) + const codeChallenge = base64Url(createHash('sha256').update(codeVerifier).digest()) + const state = base64Url(randomBytes(16)) + + return new Promise<{ code: string; codeVerifier: string; redirectUri: string }>( + (resolve, reject) => { + let settled = false + let redirectUri = '' + + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost') + if (url.pathname !== '/callback') { + writeResponse(res, 404, callbackHtml('Not found', 'Unknown callback path.')) + return + } + const error = url.searchParams.get('error') + if (error) { + writeResponse( + res, + 400, + callbackHtml( + 'Authorization failed', + `Google returned: ${error}. You can close this tab.` + ) + ) + rejectFlow(new Error(`Authorization denied: ${error}`)) + return + } + const code = url.searchParams.get('code') + const returnedState = url.searchParams.get('state') + if (!code || returnedState !== state) { + writeResponse( + res, + 400, + callbackHtml('Invalid callback', 'Missing or mismatched authorization code.') + ) + return + } + writeResponse( + res, + 200, + callbackHtml('Antigravity account added', 'You can close this tab and return to Orca.') + ) + if (!settled) { + settled = true + clearTimeout(timeout) + resolve({ code, codeVerifier, redirectUri }) + } + closeServer(server) + }) + + const timeout = setTimeout(() => { + rejectFlow(new Error('Antigravity sign-in timed out')) + }, FLOW_TIMEOUT_MS) + + function rejectFlow(error: Error): void { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + reject(error) + closeServer(server) + } + + server.on('error', (err) => rejectFlow(err instanceof Error ? err : new Error(String(err)))) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + rejectFlow(new Error('Loopback callback server failed to start')) + return + } + redirectUri = `http://localhost:${address.port}/callback` + const authUrl = new URL(AUTH_URL) + authUrl.searchParams.set('client_id', OAUTH_CLIENT_ID) + authUrl.searchParams.set('redirect_uri', redirectUri) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('scope', OAUTH_SCOPES.join(' ')) + authUrl.searchParams.set('access_type', 'offline') + authUrl.searchParams.set('prompt', 'consent') + authUrl.searchParams.set('include_granted_scopes', 'true') + authUrl.searchParams.set('code_challenge', codeChallenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + authUrl.searchParams.set('state', state) + + void shell.openExternal(authUrl.toString()).catch(() => { + // Why: headless contexts — the browser may not open, but the flow + // still waits on the loopback redirect; the timeout guards orphans. + }) + }) + } + ).then(async (grant) => { + const tokens = await exchangeCode(grant.code, grant.codeVerifier, grant.redirectUri) + const email = await fetchAccountEmail(tokens.accessToken) + const projectId = await loadProjectId(tokens.accessToken).catch(() => 'default-cli-project') + return { + accountId: randomUUID(), + email, + projectId, + refreshToken: tokens.refreshToken, + accessToken: tokens.accessToken, + expiryDate: Date.now() + tokens.expiresIn * 1000 + } + }) +} + +export async function refreshAntigravityAccountToken( + refreshToken: string +): Promise { + const fetchFn = net?.fetch ?? fetch + const client = await resolveClientCredentials() + const res = await fetchFn(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: client.clientId, + client_secret: client.clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token' + }).toString(), + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) + }) + if (!res.ok) { + throw new Error(`Token refresh failed (HTTP ${res.status})`) + } + const data = (await res.json()) as { + access_token?: string + refresh_token?: string + expires_in?: number + } + if (typeof data.access_token !== 'string') { + throw new Error('Token refresh response missing access token') + } + return { + accessToken: data.access_token, + expiryDate: Date.now() + (typeof data.expires_in === 'number' ? data.expires_in : 3600) * 1000, + refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined + } +} diff --git a/src/main/antigravity-accounts/token-store.ts b/src/main/antigravity-accounts/token-store.ts new file mode 100644 index 000000000000..9161f75bb1b3 --- /dev/null +++ b/src/main/antigravity-accounts/token-store.ts @@ -0,0 +1,108 @@ +import { existsSync } from 'node:fs' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { app, safeStorage } from 'electron' + +export type AntigravityAccountTokens = { + refreshToken: string + accessToken: string | null + expiryDate: number | null +} + +type VaultFile = { + version: 1 + /** base64(safeStorage.encryptString(JSON.stringify(tokens))) per account id */ + entries: Record +} + +export type AntigravityTokenStore = { + read: (accountId: string) => Promise + write: (accountId: string, tokens: AntigravityAccountTokens) => Promise + remove: (accountId: string) => Promise +} + +function vaultFilePath(): string { + return path.join(app.getPath('userData'), 'antigravity-accounts-vault.json') +} + +function encodeTokens(tokens: AntigravityAccountTokens): string { + return safeStorage.encryptString(JSON.stringify(tokens)).toString('base64') +} + +function decodeTokens(entry: string): AntigravityAccountTokens | null { + try { + const buffer = Buffer.from(entry, 'base64') + const plain = safeStorage.isEncryptionAvailable() ? safeStorage.decryptString(buffer) : null + if (!plain) { + return null + } + const parsed = JSON.parse(plain) as Partial + if (typeof parsed.refreshToken !== 'string' || !parsed.refreshToken) { + return null + } + return { + refreshToken: parsed.refreshToken, + accessToken: typeof parsed.accessToken === 'string' ? parsed.accessToken : null, + expiryDate: typeof parsed.expiryDate === 'number' ? parsed.expiryDate : null + } + } catch { + return null + } +} + +async function readVault(): Promise { + try { + const raw = await readFile(vaultFilePath(), 'utf-8') + const parsed = JSON.parse(raw) as Partial + if ( + parsed && + typeof parsed === 'object' && + parsed.entries && + typeof parsed.entries === 'object' + ) { + return { version: 1, entries: parsed.entries } + } + } catch { + // Missing or corrupt vault behaves as empty. + } + return { version: 1, entries: {} } +} + +async function writeVault(vault: VaultFile): Promise { + const target = vaultFilePath() + await mkdir(path.dirname(target), { recursive: true }) + const tmp = `${target}.${process.pid}.tmp` + await writeFile(tmp, JSON.stringify(vault, null, 2), 'utf-8') + await rename(tmp, target) +} + +export function createAntigravityTokenStore(): AntigravityTokenStore { + return { + read: async (accountId) => { + const vault = await readVault() + const entry = vault.entries[accountId] + return entry ? decodeTokens(entry) : null + }, + write: async (accountId, tokens) => { + // Why: refuse to persist plaintext when OS-level encryption is missing + // (headless Linux without libsecret) rather than leak refresh tokens. + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('OS keyring encryption unavailable; Antigravity account not saved') + } + const vault = await readVault() + vault.entries[accountId] = encodeTokens(tokens) + await writeVault(vault) + }, + remove: async (accountId) => { + if (!existsSync(vaultFilePath())) { + return + } + const vault = await readVault() + if (!(accountId in vault.entries)) { + return + } + delete vault.entries[accountId] + await writeVault(vault) + } + } +} diff --git a/src/main/ipc/antigravity-accounts.ts b/src/main/ipc/antigravity-accounts.ts index 55e1a203f5f6..e803a1438ed8 100644 --- a/src/main/ipc/antigravity-accounts.ts +++ b/src/main/ipc/antigravity-accounts.ts @@ -1,6 +1,92 @@ import { ipcMain } from 'electron' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { AntigravityManagedAccount } from '../../shared/managed-account-types' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { getAntigravityAccountStatus } from '../antigravity-accounts/status' +import { addAntigravityAccountViaBrowser } from '../antigravity-accounts/managed-oauth' +import { + createAntigravityTokenStore, + type AntigravityTokenStore +} from '../antigravity-accounts/token-store' +import { fetchAntigravityManagedAccountUsage } from '../rate-limits/antigravity-managed-account-fetch' + +export type AntigravityAccountUsageEntry = { + account: AntigravityManagedAccount + usage: ProviderRateLimits | null +} + +type AntigravityAccountsIpcDeps = { + getSettings: () => GlobalSettings + updateSettings: (patch: Partial) => void + tokenStore?: AntigravityTokenStore +} + +export function registerAntigravityAccountHandlers(deps?: AntigravityAccountsIpcDeps): void { + const tokenStore = deps?.tokenStore ?? createAntigravityTokenStore() -export function registerAntigravityAccountHandlers(): void { ipcMain.handle('antigravityAccounts:getStatus', () => getAntigravityAccountStatus()) + + if (!deps) { + return + } + + ipcMain.handle( + 'antigravityAccounts:addAccount', + async (): Promise<{ ok: boolean; email?: string; error?: string }> => { + try { + const grant = await addAntigravityAccountViaBrowser() + await tokenStore.write(grant.accountId, { + refreshToken: grant.refreshToken, + accessToken: grant.accessToken, + expiryDate: grant.expiryDate + }) + const now = Date.now() + const existing = deps.getSettings().antigravityManagedAccounts ?? [] + const account: AntigravityManagedAccount = { + id: grant.accountId, + email: grant.email, + projectId: grant.projectId, + createdAt: now, + updatedAt: now, + lastAuthenticatedAt: now + } + deps.updateSettings({ antigravityManagedAccounts: [...existing, account] }) + return { ok: true, email: grant.email } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Unknown sign-in error' } + } + } + ) + + ipcMain.handle( + 'antigravityAccounts:removeAccount', + async (_event, accountId: unknown): Promise<{ ok: boolean; error?: string }> => { + if (typeof accountId !== 'string' || !accountId) { + return { ok: false, error: 'Invalid account id' } + } + try { + await tokenStore.remove(accountId) + const existing = deps.getSettings().antigravityManagedAccounts ?? [] + deps.updateSettings({ + antigravityManagedAccounts: existing.filter((account) => account.id !== accountId) + }) + return { ok: true } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Failed to remove account' } + } + } + ) + + ipcMain.handle( + 'antigravityAccounts:getManagedUsage', + async (): Promise => { + const accounts = deps.getSettings().antigravityManagedAccounts ?? [] + return Promise.all( + accounts.map(async (account) => ({ + account, + usage: await fetchAntigravityManagedAccountUsage(account, tokenStore) + })) + ) + } + ) } diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 4077645f898a..5dc315f63fe1 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -149,7 +149,10 @@ export function registerCoreHandlers( registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) registerGrokAccountHandlers() - registerAntigravityAccountHandlers() + registerAntigravityAccountHandlers({ + getSettings: () => store.getSettings(), + updateSettings: (patch) => store.updateSettings(patch) + }) registerRateLimitHandlers(rateLimits, codexAccounts) registerGitHubHandlers(store, stats) registerGitLabHandlers(store) diff --git a/src/main/rate-limits/antigravity-managed-account-fetch.ts b/src/main/rate-limits/antigravity-managed-account-fetch.ts new file mode 100644 index 000000000000..dcb154050e3a --- /dev/null +++ b/src/main/rate-limits/antigravity-managed-account-fetch.ts @@ -0,0 +1,99 @@ +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import type { AntigravityManagedAccount } from '../../shared/managed-account-types' +import { refreshAntigravityAccountToken } from '../antigravity-accounts/managed-oauth' +import { + createAntigravityTokenStore, + type AntigravityTokenStore +} from '../antigravity-accounts/token-store' +import { fetchAntigravityQuota } from './antigravity-quota-client' + +export function errorResult(error: string): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status: 'error' + } +} + +// Why: settings live in the main store; the fetcher reads them through this +// provider so the rate-limit service stays decoupled from GlobalSettings. +let managedAccountsProvider: () => AntigravityManagedAccount[] = () => [] + +export function setAntigravityManagedAccountsProvider( + provider: () => AntigravityManagedAccount[] +): void { + managedAccountsProvider = provider +} + +let tokenStoreSingleton: AntigravityTokenStore | null = null + +function getSharedTokenStore(): AntigravityTokenStore { + if (!tokenStoreSingleton) { + tokenStoreSingleton = createAntigravityTokenStore() + } + return tokenStoreSingleton +} + +export function setAntigravityTokenStoreForTests(store: AntigravityTokenStore | null): void { + tokenStoreSingleton = store +} + +/** Fetches quota for one managed account, refreshing (and persisting) its + * token through the safeStorage vault when expired. */ +export async function fetchAntigravityManagedAccountUsage( + account: AntigravityManagedAccount, + tokenStore?: AntigravityTokenStore +): Promise { + const store = tokenStore ?? getSharedTokenStore() + let tokens = await store.read(account.id) + if (!tokens) { + return errorResult( + `Antigravity account ${account.email} has no stored credentials — remove and re-add it` + ) + } + let accessToken: string | null = tokens.accessToken + if (!accessToken || (tokens.expiryDate ?? 0) < Date.now()) { + try { + const refreshed = await refreshAntigravityAccountToken(tokens.refreshToken) + tokens = { + refreshToken: refreshed.refreshToken ?? tokens.refreshToken, + accessToken: refreshed.accessToken, + expiryDate: refreshed.expiryDate + } + accessToken = refreshed.accessToken + await store.write(account.id, tokens) + } catch (err) { + return errorResult( + `Token refresh failed for ${account.email}: ${err instanceof Error ? err.message : 'unknown error'}` + ) + } + } + return fetchAntigravityQuota(accessToken, account.projectId) +} + +/** Fetches every managed account and collapses them into the single + * status-bar snapshot (null when no accounts are configured). */ +export async function fetchManagedAccountsAggregate(): Promise { + const accounts = managedAccountsProvider() + if (accounts.length === 0) { + return null + } + const results = await Promise.all( + accounts.map((account) => fetchAntigravityManagedAccountUsage(account).catch(() => null)) + ) + const usable = results.filter( + (result): result is ProviderRateLimits => result !== null && result.status === 'ok' + ) + if (usable.length === 0) { + const firstError = results.find((result) => result !== null) + return firstError ?? errorResult('Antigravity accounts failed to fetch') + } + // Why: one status-bar segment summarizes all signed-in accounts; the most + // constrained window is the actionable one (mirrors multi-account claude). + return usable.reduce((worst, candidate) => + (candidate.session?.usedPercent ?? 0) > (worst.session?.usedPercent ?? 0) ? candidate : worst + ) +} diff --git a/src/main/rate-limits/antigravity-managed-usage.test.ts b/src/main/rate-limits/antigravity-managed-usage.test.ts new file mode 100644 index 000000000000..4c41791d0b65 --- /dev/null +++ b/src/main/rate-limits/antigravity-managed-usage.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeResponse } from './gemini-usage-fetcher.test-fixtures' +import type * as AntigravityOauthSourcesModule from './antigravity-oauth-sources' +import type * as ManagedOauthModule from '../antigravity-accounts/managed-oauth' +import type { AntigravityManagedAccount } from '../../shared/managed-account-types' +import type { AntigravityTokenStore } from '../antigravity-accounts/token-store' + +const { readFileMock, extractCredsMock, netFetchMock, keychainMock, refreshMock } = vi.hoisted( + () => ({ + readFileMock: vi.fn(), + extractCredsMock: vi.fn(), + netFetchMock: vi.fn(), + keychainMock: vi.fn(), + refreshMock: vi.fn() + }) +) + +vi.mock('./gemini-cli-oauth-extractor', () => ({ + extractOAuthClientCredentials: extractCredsMock +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, + writeFile: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) + +vi.mock('./antigravity-oauth-sources', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readAntigravityKeychainCredentials: keychainMock + } +}) + +vi.mock('../antigravity-accounts/managed-oauth', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + refreshAntigravityAccountToken: refreshMock + } +}) + +import { + fetchAntigravityManagedAccountUsage, + setAntigravityTokenStoreForTests +} from './antigravity-managed-account-fetch' + +function makeAccount( + overrides: Partial = {} +): AntigravityManagedAccount { + return { + id: 'acct-1', + email: 'test@example.com', + projectId: 'proj-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...overrides + } +} + +function makeTokenStore( + tokens: { refreshToken: string; accessToken: string | null; expiryDate: number | null } | null +) { + let stored = tokens + const store: AntigravityTokenStore = { + read: vi.fn(async () => stored), + write: vi.fn(async (_id, next) => { + stored = next + }), + remove: vi.fn(async () => { + stored = null + }) + } + return store +} + +const modelsResponse = { + models: { + 'claude-sonnet-4-6': { + displayName: 'Claude Sonnet 4.6', + quotaInfo: { remainingFraction: 0.5, resetTime: '2026-04-24T13:00:00.000Z' } + } + } +} + +describe('fetchAntigravityManagedAccountUsage', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z')) + netFetchMock.mockReset() + refreshMock.mockReset() + netFetchMock.mockImplementation((url: string) => { + if (url.includes('fetchAvailableModels')) { + return Promise.resolve(makeResponse(modelsResponse)) + } + return Promise.resolve(makeResponse({}, 404)) + }) + }) + + it('fetches quota with the stored access token when fresh', async () => { + const store = makeTokenStore({ + refreshToken: 'rt', + accessToken: 'fresh-token', + expiryDate: Date.now() + 3600_000 + }) + const result = await fetchAntigravityManagedAccountUsage(makeAccount(), store) + expect(result.status).toBe('ok') + expect(result.buckets).toHaveLength(1) + expect(netFetchMock.mock.calls[0]?.[1]?.headers?.Authorization).toBe('Bearer fresh-token') + expect(refreshMock).not.toHaveBeenCalled() + }) + + it('refreshes expired tokens and persists them back to the vault', async () => { + const store = makeTokenStore({ + refreshToken: 'rt', + accessToken: null, + expiryDate: Date.now() - 1000 + }) + refreshMock.mockResolvedValue({ + accessToken: 'rotated-token', + expiryDate: Date.now() + 3600_000 + }) + const result = await fetchAntigravityManagedAccountUsage(makeAccount(), store) + expect(result.status).toBe('ok') + expect(refreshMock).toHaveBeenCalledWith('rt') + expect(store.write).toHaveBeenCalledWith('acct-1', { + refreshToken: 'rt', + accessToken: 'rotated-token', + expiryDate: Date.now() + 3600_000 + }) + expect(netFetchMock.mock.calls[0]?.[1]?.headers?.Authorization).toBe('Bearer rotated-token') + }) + + it('reports an error when refresh fails', async () => { + const store = makeTokenStore({ + refreshToken: 'rt', + accessToken: 'stale', + expiryDate: Date.now() - 1000 + }) + refreshMock.mockRejectedValue(new Error('Token refresh failed (HTTP 400)')) + const result = await fetchAntigravityManagedAccountUsage(makeAccount(), store) + expect(result.status).toBe('error') + expect(result.error).toContain('test@example.com') + }) + + it('reports an error when the vault has no credentials', async () => { + const store = makeTokenStore(null) + const result = await fetchAntigravityManagedAccountUsage(makeAccount(), store) + expect(result.status).toBe('error') + expect(result.error).toContain('remove and re-add') + }) +}) + +describe('token store singleton override', () => { + it('setAntigravityTokenStoreForTests accepts null without touching electron', () => { + setAntigravityTokenStoreForTests(null) + setAntigravityTokenStoreForTests(makeTokenStore(null)) + }) +}) diff --git a/src/main/rate-limits/antigravity-oauth-sources.ts b/src/main/rate-limits/antigravity-oauth-sources.ts index 22900b7fa6da..2eb5a641f383 100644 --- a/src/main/rate-limits/antigravity-oauth-sources.ts +++ b/src/main/rate-limits/antigravity-oauth-sources.ts @@ -1,13 +1,10 @@ -import { execFile } from 'node:child_process' import { readFile, writeFile, rename } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' -import { promisify } from 'node:util' import { net } from 'electron' +import { runProcess } from '../../shared/child-process/run-process' import { extractOAuthClientCredentials } from './gemini-cli-oauth-extractor' -const execFileAsync = promisify(execFile) - const API_TIMEOUT_MS = 10_000 const PRIMARY_OAUTH_CREDS_PATH = path.join(homedir(), '.gemini', 'oauth_creds.json') const FALLBACK_OAUTH_CREDS_PATH = path.join(homedir(), '.antigravity', 'oauth_creds.json') @@ -115,12 +112,16 @@ export async function readAntigravityKeychainCredentials(): Promise { + if (!data || typeof data !== 'object' || 'models' in data === false) { + return {} + } + const models = (data as { models?: unknown }).models + if (!models || typeof models !== 'object' || Array.isArray(models)) { + return {} + } + return Object.fromEntries( + Object.entries(models).filter(([, v]) => isModelEntry(v)) as [string, ModelEntry][] + ) +} + +function buildModelBuckets(models: Record): RateLimitBucket[] { + const buckets: RateLimitBucket[] = [] + for (const [modelId, entry] of Object.entries(models)) { + const quota = entry.quotaInfo + // Why: models without a numeric remainingFraction are unlimited for this + // tier (or not quota-tracked); rendering them as 0% used would be noise. + if ( + !quota || + typeof quota.remainingFraction !== 'number' || + !Number.isFinite(quota.remainingFraction) + ) { + continue + } + const resetsAtMs = quota.resetTime ? Date.parse(quota.resetTime) : Number.NaN + buckets.push({ + name: entry.displayName?.trim() || modelId, + usedPercent: Math.min(100, Math.max(0, Math.round((1 - quota.remainingFraction) * 100))), + windowMinutes: 300, + resetsAt: Number.isFinite(resetsAtMs) ? resetsAtMs : null, + resetDescription: null + }) + } + // Why: unlike the gemini-cli retrieveUserQuota feed (where several model ids + // share one underlying bucket), fetchAvailableModels returns one row per + // model — distinct models with coincidentally equal usage must all render, + // so they are sorted by name instead of deduplicated. + buckets.sort((a, b) => a.name.localeCompare(b.name)) + return buckets +} + +/** Queries v1internal:fetchAvailableModels for one credential + project and + * maps the per-model quotaInfo into rate-limit buckets. */ +export async function fetchAntigravityQuota( + accessToken: string, + projectId: string +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, API_TIMEOUT_MS) + try { + const fetchFn = net?.fetch ?? fetch + const res = await fetchFn(FETCH_MODELS_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'User-Agent': ANTIGRAVITY_USER_AGENT + }, + body: JSON.stringify({ project: projectId }), + signal: controller.signal + }) + if (!res.ok) { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `Quota fetch failed (${res.status})`, + status: 'error' + } + } + const data = (await res.json()) as unknown + const buckets = buildModelBuckets(parseModelsResponse(data)) + // Why: the session card should summarize the tightest *timed* window; + // internal models can carry quota without a reset timestamp. + const timedBuckets = buckets.filter((b) => b.resetsAt !== null) + return { + provider: 'antigravity', + session: deriveSessionSummary(timedBuckets.length > 0 ? timedBuckets : buckets), + weekly: null, + buckets, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + } finally { + clearTimeout(timeout) + } +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index e47953751d44..45f3f665060b 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -23,7 +23,7 @@ vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) // Why: the real keychain reader shells out to /usr/bin/security; unit tests // must not touch the host keychain (or pick up the developer's live token). vi.mock('./antigravity-oauth-sources', async (importOriginal) => { - const actual = await importOriginal() + const actual = await importOriginal() return { ...actual, readAntigravityKeychainCredentials: keychainMock diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index e56d6e955157..c0ccf6e66c91 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -1,7 +1,5 @@ -import { net } from 'electron' -import type { ProviderRateLimits, RateLimitBucket } from '../../shared/rate-limit-types' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { - ANTIGRAVITY_USER_AGENT, loadProjectId, readAuthJson, readAntigravityCredentials, @@ -11,110 +9,8 @@ import { type AntigravityCredentials, type GoogleAuthEntry } from './antigravity-oauth-sources' -import { deriveSessionSummary } from './gemini-bucket-formatting' - -const API_TIMEOUT_MS = 10_000 -// Why: retrieveUserQuota 403s (SUBSCRIPTION_REQUIRED) even for valid -// Antigravity tokens — the current contract is fetchAvailableModels, whose -// response carries per-model quotaInfo alongside capability metadata (the -// same endpoint the agy CLI and sub2api use). -const FETCH_MODELS_URL = 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels' - -type ModelQuotaInfo = { remainingFraction?: number; resetTime?: string } -type ModelEntry = { quotaInfo?: ModelQuotaInfo; displayName?: string } - -function isModelEntry(o: unknown): o is ModelEntry { - return typeof o === 'object' && o !== null -} - -function parseModelsResponse(data: unknown): Record { - if (!data || typeof data !== 'object' || 'models' in data === false) { - return {} - } - const models = (data as { models?: unknown }).models - if (!models || typeof models !== 'object' || Array.isArray(models)) { - return {} - } - return Object.fromEntries( - Object.entries(models).filter(([, v]) => isModelEntry(v)) as [string, ModelEntry][] - ) -} - -function buildModelBuckets(models: Record): RateLimitBucket[] { - const buckets: RateLimitBucket[] = [] - for (const [modelId, entry] of Object.entries(models)) { - const quota = entry.quotaInfo - // Why: models without a numeric remainingFraction are unlimited for this - // tier (or not quota-tracked); rendering them as 0% used would be noise. - if ( - !quota || - typeof quota.remainingFraction !== 'number' || - !Number.isFinite(quota.remainingFraction) - ) { - continue - } - const resetsAtMs = quota.resetTime ? Date.parse(quota.resetTime) : Number.NaN - buckets.push({ - name: entry.displayName?.trim() || modelId, - usedPercent: Math.min(100, Math.max(0, Math.round((1 - quota.remainingFraction) * 100))), - windowMinutes: 300, - resetsAt: Number.isFinite(resetsAtMs) ? resetsAtMs : null, - resetDescription: null - }) - } - // Why: unlike the gemini-cli retrieveUserQuota feed (where several model ids - // share one underlying bucket), fetchAvailableModels returns one row per - // model — distinct models with coincidentally equal usage must all render, - // so they are sorted by name instead of deduplicated. - buckets.sort((a, b) => a.name.localeCompare(b.name)) - return buckets -} - -async function fetchQuota(accessToken: string, projectId: string): Promise { - const controller = new AbortController() - const timeout = setTimeout(() => { - controller.abort() - }, API_TIMEOUT_MS) - try { - const fetchFn = net?.fetch ?? fetch - const res = await fetchFn(FETCH_MODELS_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, - 'User-Agent': ANTIGRAVITY_USER_AGENT - }, - body: JSON.stringify({ project: projectId }), - signal: controller.signal - }) - if (!res.ok) { - return { - provider: 'antigravity', - session: null, - weekly: null, - updatedAt: Date.now(), - error: `Quota fetch failed (${res.status})`, - status: 'error' - } - } - const data = (await res.json()) as unknown - const buckets = buildModelBuckets(parseModelsResponse(data)) - // Why: the session card should summarize the tightest *timed* window; - // internal models can carry quota without a reset timestamp. - const timedBuckets = buckets.filter((b) => b.resetsAt !== null) - return { - provider: 'antigravity', - session: deriveSessionSummary(timedBuckets.length > 0 ? timedBuckets : buckets), - weekly: null, - buckets, - updatedAt: Date.now(), - error: null, - status: 'ok' - } - } finally { - clearTimeout(timeout) - } -} +import { fetchAntigravityQuota } from './antigravity-quota-client' +import { fetchManagedAccountsAggregate } from './antigravity-managed-account-fetch' async function fetchViaAuthJson( auth: GoogleAuthEntry, @@ -153,14 +49,14 @@ async function fetchViaAuthJson( status: 'error' } } - const result = await fetchQuota(accessToken, effectiveProjectId) + const result = await fetchAntigravityQuota(accessToken, effectiveProjectId) if (result.status === 'error' && result.error?.includes('401')) { const refreshResult = await tryRefreshTokenFromBundle(refreshToken, antigravityCliOAuthEnabled) if (refreshResult?.accessToken) { const newProjectId = await loadProjectId(refreshResult.accessToken).catch(() => { return effectiveProjectId }) - return fetchQuota(refreshResult.accessToken, newProjectId) + return fetchAntigravityQuota(refreshResult.accessToken, newProjectId) } } return result @@ -216,7 +112,7 @@ async function fetchViaOauthCreds( status: 'error' } } - const result = await fetchQuota(accessToken, projectId) + const result = await fetchAntigravityQuota(accessToken, projectId) if (result.status === 'error' && result.error?.includes('401')) { const refreshResult = await tryRefreshTokenFromBundle( currentCreds.refresh_token, @@ -234,7 +130,7 @@ async function fetchViaOauthCreds( ? Date.now() + refreshResult.expiresIn * 1000 : currentCreds.expiry_date }) - return fetchQuota(refreshResult.accessToken, newProjectId) + return fetchAntigravityQuota(refreshResult.accessToken, newProjectId) } } } @@ -256,6 +152,13 @@ export async function fetchAntigravityRateLimits( } try { + // Why: Orca-managed OAuth accounts (sub2api-style multi-account) come + // first; the agy CLI's keychain token and the legacy files remain the + // single-account fallbacks. + const managed = await fetchManagedAccountsAggregate() + if (managed) { + return managed + } // Why: prefer the freshest credential that actually works. The agy CLI's // keychain token is minted by the current Antigravity client; OpenCode's // auth.json and the legacy oauth_creds.json files carry Gemini-CLI-era diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index d6885f208171..d100a6cdba25 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { RateLimitService } from '../rate-limits/service' +import { setAntigravityManagedAccountsProvider } from '../rate-limits/antigravity-managed-account-fetch' import { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' import { CodexAccountService } from '../codex-accounts/service' import { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service' @@ -132,6 +133,7 @@ export function initializeMainProcessAccountServices(): void { store.getSettings().antigravityCliOAuthEnabled || store.getSettings().geminiCliOAuthEnabled === true ) + setAntigravityManagedAccountsProvider(() => store.getSettings().antigravityManagedAccounts ?? []) state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings()) state.keybindings = new KeybindingService({ homePath: app.getPath('home'), diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index 822c9b14685c..5999c1eb900e 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -1,9 +1,14 @@ import type { + AntigravityManagedAccount, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/managed-account-types' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' -import type { AntigravityAccountStatus, GrokAccountStatus } from '../../shared/rate-limit-types' +import type { + AntigravityAccountStatus, + GrokAccountStatus, + ProviderRateLimits +} from '../../shared/rate-limit-types' export type CodexAccountsApi = { list: () => Promise @@ -60,6 +65,11 @@ export type GrokAccountsApi = { export type AntigravityAccountsApi = { getStatus: () => Promise + addAccount: () => Promise<{ ok: boolean; email?: string; error?: string }> + removeAccount: (accountId: string) => Promise<{ ok: boolean; error?: string }> + getManagedUsage: () => Promise< + { account: AntigravityManagedAccount; usage: ProviderRateLimits | null }[] + > } export type MinimaxCredentialsApi = { diff --git a/src/preload/api/antigravity-accounts-bridge.ts b/src/preload/api/antigravity-accounts-bridge.ts index 32a83ee12b36..bd062c901523 100644 --- a/src/preload/api/antigravity-accounts-bridge.ts +++ b/src/preload/api/antigravity-accounts-bridge.ts @@ -2,7 +2,15 @@ import { ipcRenderer } from 'electron' import type { AntigravityAccountStatus } from '../../shared/rate-limit-types' import type { PreloadApi } from '../api-types' +type ManagedUsage = Awaited> + export const antigravityAccountsApi = { getStatus: (): Promise => - ipcRenderer.invoke('antigravityAccounts:getStatus') + ipcRenderer.invoke('antigravityAccounts:getStatus'), + addAccount: (): Promise<{ ok: boolean; email?: string; error?: string }> => + ipcRenderer.invoke('antigravityAccounts:addAccount'), + removeAccount: (accountId: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('antigravityAccounts:removeAccount', accountId), + getManagedUsage: (): Promise => + ipcRenderer.invoke('antigravityAccounts:getManagedUsage') } satisfies PreloadApi['antigravityAccounts'] diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx index c1accf5c9ffb..4c75417c7bee 100644 --- a/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx @@ -3,7 +3,7 @@ import '@testing-library/jest-dom/vitest' import React from 'react' -import { cleanup, render, screen, fireEvent } from '@testing-library/react' +import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AccountsPaneSectionModel } from './accounts-pane-types' import { getDefaultSettings } from '../../../../shared/constants' @@ -15,7 +15,10 @@ import { const mocks = vi.hoisted(() => ({ getStatus: vi.fn(), refreshRateLimits: vi.fn(), - antigravityUsage: vi.fn<() => unknown>(() => null) + antigravityUsage: vi.fn<() => unknown>(() => null), + addAccount: vi.fn(), + removeAccount: vi.fn(), + getManagedUsage: vi.fn() })) vi.mock('@/lib/agent-catalog', () => ({ @@ -113,9 +116,19 @@ describe('AntigravityAccountsSection', () => { }) mocks.refreshRateLimits.mockResolvedValue(undefined) mocks.antigravityUsage.mockReturnValue(null) + mocks.addAccount.mockResolvedValue({ ok: true, email: 'new@example.com' }) + mocks.removeAccount.mockResolvedValue({ ok: true }) + mocks.getManagedUsage.mockResolvedValue([]) Object.defineProperty(window, 'api', { configurable: true, - value: { antigravityAccounts: { getStatus: mocks.getStatus } } + value: { + antigravityAccounts: { + getStatus: mocks.getStatus, + addAccount: mocks.addAccount, + removeAccount: mocks.removeAccount, + getManagedUsage: mocks.getManagedUsage + } + } }) }) @@ -201,6 +214,96 @@ describe('AntigravityAccountsSection', () => { expect(screen.getByText(/Resets in 2h [45]m/)).toBeInTheDocument() }) + it('renders managed accounts with per-account quota', async () => { + const resetAt = Date.now() + 45 * 60 * 1000 + mocks.getManagedUsage.mockResolvedValue([ + { + account: { + id: 'acct-1', + email: 'one@example.com', + projectId: 'proj-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + usage: { + provider: 'antigravity', + session: { + usedPercent: 30, + windowMinutes: 300, + resetsAt: resetAt, + resetDescription: null + }, + weekly: null, + buckets: [], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + } + ]) + + const model = createModel({ + settings: { + ...getDefaultSettings('/tmp'), + antigravityCliOAuthEnabled: true, + antigravityManagedAccounts: [ + { + id: 'acct-1', + email: 'one@example.com', + projectId: 'proj-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + } + }) + render() + + expect(await screen.findByText('one@example.com')).toBeInTheDocument() + expect(screen.getByText('30% used')).toBeInTheDocument() + expect(screen.getByText(/Resets in 4[45]m/)).toBeInTheDocument() + }) + + it('invokes addAccount when the add button is clicked', async () => { + const model = createModel() + render() + + const addButton = await screen.findByRole('button', { name: /Add Google account/i }) + fireEvent.click(addButton) + + await waitFor(() => expect(mocks.addAccount).toHaveBeenCalled()) + await waitFor(() => expect(mocks.refreshRateLimits).toHaveBeenCalled()) + }) + + it('invokes removeAccount when an account row trash button is clicked', async () => { + const model = createModel({ + settings: { + ...getDefaultSettings('/tmp'), + antigravityCliOAuthEnabled: true, + antigravityManagedAccounts: [ + { + id: 'acct-1', + email: 'one@example.com', + projectId: 'proj-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + } + }) + render() + + const removeButton = await screen.findByRole('button', { + name: /Remove account one@example.com/i + }) + fireEvent.click(removeButton) + + await waitFor(() => expect(mocks.removeAccount).toHaveBeenCalledWith('acct-1')) + }) + it('triggers refreshRateLimits when Refresh quota is clicked', async () => { const model = createModel() render() diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx index 8a4eed983ebc..85e13db42577 100644 --- a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx @@ -11,6 +11,10 @@ import { Button } from '../ui/button' import { Label } from '../ui/label' import { Switch } from '../ui/switch' import type { AntigravityAccountStatus } from '../../../../shared/rate-limit-types' +import { + AntigravityManagedAccountsList, + type ManagedUsageEntry +} from './AntigravityManagedAccountsList' import { SearchableSetting } from './SearchableSetting' import type { AccountsPaneSectionModel } from './accounts-pane-types' @@ -74,6 +78,64 @@ export function AntigravityAccountsSection({ const hasBuckets = buckets.length > 0 const sessionWindow = antigravityUsage?.session ?? null + const managedAccounts = settings.antigravityManagedAccounts ?? [] + const [managedUsage, setManagedUsage] = useState([]) + const [addingAccount, setAddingAccount] = useState(false) + const [addAccountError, setAddAccountError] = useState(null) + const [removingAccountId, setRemovingAccountId] = useState(null) + + const loadManagedUsage = useCallback(async (): Promise => { + try { + if (window.api.antigravityAccounts?.getManagedUsage) { + setManagedUsage(await window.api.antigravityAccounts.getManagedUsage()) + } + } catch { + // Usage preview is best-effort; the status row still renders. + } + }, []) + + useEffect(() => { + if (settings.antigravityCliOAuthEnabled && managedAccounts.length > 0) { + void loadManagedUsage() + } + }, [ + settings.antigravityCliOAuthEnabled, + managedAccounts.length, + loadManagedUsage, + antigravityUsage?.updatedAt + ]) + + const handleAddAccount = async (): Promise => { + setAddingAccount(true) + setAddAccountError(null) + try { + recordFeatureInteraction('usage-tracking') + const result = await window.api.antigravityAccounts.addAccount() + if (!result.ok) { + setAddAccountError(result.error ?? 'Sign-in failed') + } + await loadStatus() + await refreshRateLimits() + await loadManagedUsage() + } catch (error) { + setAddAccountError(error instanceof Error ? error.message : 'Sign-in failed') + } finally { + setAddingAccount(false) + } + } + + const handleRemoveAccount = async (accountId: string): Promise => { + setRemovingAccountId(accountId) + try { + await window.api.antigravityAccounts.removeAccount(accountId) + await loadStatus() + await refreshRateLimits() + await loadManagedUsage() + } finally { + setRemovingAccountId(null) + } + } + const resetTimes = useMemo(() => { const times: number[] = [] const session = antigravityUsage?.session @@ -85,8 +147,13 @@ export function AntigravityAccountsSection({ times.push(bucket.resetsAt) } } + for (const entry of managedUsage) { + if (entry.usage?.session?.resetsAt) { + times.push(entry.usage.session.resetsAt) + } + } return times - }, [antigravityUsage]) + }, [antigravityUsage, managedUsage]) const now = useResetCountdownClock(resetTimes) @@ -233,6 +300,19 @@ export function AntigravityAccountsSection({ /> + {settings.antigravityCliOAuthEnabled ? ( + void handleAddAccount()} + onRemove={(accountId) => void handleRemoveAccount(accountId)} + /> + ) : null} + {settings.antigravityCliOAuthEnabled && hasBuckets ? (

diff --git a/src/renderer/src/components/settings/AntigravityManagedAccountsList.tsx b/src/renderer/src/components/settings/AntigravityManagedAccountsList.tsx new file mode 100644 index 000000000000..18b1ea8065b6 --- /dev/null +++ b/src/renderer/src/components/settings/AntigravityManagedAccountsList.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import { Loader2, Plus, Trash2 } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { formatResetCountdown } from '../../../../shared/rate-limit-reset-format' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import type { AntigravityManagedAccount } from '../../../../shared/managed-account-types' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' + +export type ManagedUsageEntry = { + account: AntigravityManagedAccount + usage: ProviderRateLimits | null +} + +export function AntigravityManagedAccountsList({ + accounts, + usage, + now, + adding, + addError, + removingAccountId, + onAdd, + onRemove +}: { + accounts: AntigravityManagedAccount[] + usage: ManagedUsageEntry[] + now: number + adding: boolean + addError: string | null + removingAccountId: string | null + onAdd: () => void + onRemove: (accountId: string) => void +}): React.JSX.Element { + return ( +
+
+

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.managedAccounts', + 'Google Accounts' + )} +

+ +
+ {addError ?

{addError}

: null} + {accounts.length === 0 ? ( +

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.managedAccountsEmpty', + 'No Orca-managed accounts. Add one to track quota per Google account (sub2api-style), or keep using the local Antigravity CLI credentials below.' + )} +

+ ) : ( +
+ {accounts.map((account) => { + const session = + usage.find((entry) => entry.account.id === account.id)?.usage?.session ?? null + const accountCountdown = session?.resetsAt + ? formatResetCountdown(session.resetsAt - now) + : null + const usageEntry = usage.find((entry) => entry.account.id === account.id)?.usage ?? null + return ( +
+
+ + {account.email} + + + {usageEntry?.status === 'error' && usageEntry.error + ? usageEntry.error + : (accountCountdown ?? + translate( + 'auto.components.settings.AntigravityAccountsSection.managedAccountNoData', + 'No quota data yet' + ))} + +
+
+ {session ? ( + + {translate( + 'auto.components.settings.AntigravityAccountsSection.usedPercent', + '{{value0}}% used', + { value0: String(Math.round(session.usedPercent)) } + )} + + ) : null} + +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 86e8f34984ae..cd6f44227191 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -363,7 +363,10 @@ "36761d9604": "Unknown keybinding action \"{{value0}}\" was ignored.", "d2e43e426a": "{{value0}} must be an object.", "fb290366b2": "Unavailable on web.", - "76122208ca": "Shortcut for \"{{value0}}\" was ignored: {{value1}}" + "76122208ca": "Shortcut for \"{{value0}}\" was ignored: {{value1}}", + "webAgentAccounts": { + "antigravityUnsupported": "Not available in web client" + } } }, "runtime": { @@ -11864,7 +11867,12 @@ "sessionQuota": "Session Quota", "sessionWindow": "5-Hour Window", "resetsAt": "Resets {{when}}", - "usedPercent": "{{value0}}% used" + "usedPercent": "{{value0}}% used", + "managedAccounts": "Google Accounts", + "addAccount": "Add Google account", + "managedAccountsEmpty": "No Orca-managed accounts. Add one to track quota per Google account (sub2api-style), or keep using the local Antigravity CLI credentials below.", + "managedAccountNoData": "No quota data yet", + "removeAccount": "Remove account {{email}}" } }, "right": { diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts index 6074cd3b8f07..3381c8647663 100644 --- a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -1,3 +1,4 @@ +import { translate } from '@/i18n/i18n' import type { PreloadApi } from '../../../../preload/api-types' export function createMiniMaxCredentialsApi(): NonNullable< @@ -37,7 +38,24 @@ export function createAntigravityAccountsApi(): NonNullable< error: null } return { - getStatus: () => Promise.resolve(unsigned) + getStatus: () => Promise.resolve(unsigned), + addAccount: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.webAgentAccounts.antigravityUnsupported', + 'Not available in web client' + ) + }), + removeAccount: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.webAgentAccounts.antigravityUnsupported', + 'Not available in web client' + ) + }), + getManagedUsage: () => Promise.resolve([]) } } diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index e48dd6f7fd50..815ff90eb180 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -201,6 +201,7 @@ export function buildDefaultSettings(args: { minimaxUsageModels: 'general', minimaxEndpoint: 'overseas', antigravityCliOAuthEnabled: false, + antigravityManagedAccounts: [], agentCmdOverrides: {}, agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV }, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index eaa06b069360..4634d97c3f61 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -15,6 +15,7 @@ import type { ComputerAwakeMode } from './computer-awake-mode' import type { CommitMessageAiSettings } from './commit-message-ai-types' import type { HostSettingOverrides } from './host-setting-overrides' import type { + AntigravityManagedAccount, ClaudeManagedAccount, ClaudeManagedAccountRuntimeSelection, CodexManagedAccount, @@ -371,6 +372,8 @@ export type GlobalSettings = { minimaxEndpoint: MiniMaxEndpoint /** Extract credentials from the local Antigravity CLI for rate-limit fetching. Off by default (explicit opt-in). */ antigravityCliOAuthEnabled: boolean + /** Google accounts added through Orca's Antigravity OAuth flow (metadata only; tokens in the safeStorage vault). */ + antigravityManagedAccounts: AntigravityManagedAccount[] /** Legacy Gemini CLI OAuth toggle, kept optional for migration. */ geminiCliOAuthEnabled?: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ diff --git a/src/shared/managed-account-types.ts b/src/shared/managed-account-types.ts index 7239c62e6ad4..f135759d79cc 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -93,3 +93,15 @@ export type ClaudeManagedAccountRuntimeSelection = { host: string | null wsl: Record } + +/** A Google account signed in to Antigravity through Orca's own OAuth flow + * (sub2api-style multi-account). Settings holds only metadata; the tokens + * live in the main-process safeStorage vault. */ +export type AntigravityManagedAccount = { + id: string + email: string + projectId: string + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +}