Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store'
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
import { getKimiRuntimeTarget, resolveKimiHome } from './kimi/kimi-runtime-home'
import { resolveLocalAccountRuntimeTarget } from '../shared/local-account-runtime'
import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync'
import {
attachMainWindowServices,
Expand Down Expand Up @@ -2612,6 +2613,10 @@ void app.whenReady().then(async () => {
// Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the
// usage fetch must read the WSL-side credentials when that's the configured runtime (#12370).
rateLimits.setKimiHomeResolver(() => resolveKimiHome(getKimiRuntimeTarget(store!.getSettings())))
// agy signs in per runtime and keeps the token in that runtime's keyring, so ask the one that owns it.
rateLimits.setAntigravityRuntimeTargetResolver(() =>
resolveLocalAccountRuntimeTarget(store!.getSettings())
)
rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings()))
const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync(
rateLimits,
Expand Down
88 changes: 88 additions & 0 deletions src/main/rate-limits/antigravity-cli-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { parseAntigravityCliUsage } from './antigravity-cli-usage'

// Real `agy -p "/usage"` output: family, window label, remaining percentage, reset instant.
const SAMPLE = [
'Gemini Models\tWeekly Limit Remaining\t100%\t2026-09-02T17:02:52Z',
'Gemini Models\tFive Hour Limit Remaining\t100%\t2026-08-26T22:02:52Z',
'Claude and GPT models\tWeekly Limit Remaining\t100%\t2026-09-02T17:02:52Z',
'Claude and GPT models\tFive Hour Limit Remaining\t100%\t2026-08-26T22:02:52Z'
].join('\n')

const UPDATED_AT = 1_700_000_000_000

describe('parseAntigravityCliUsage', () => {
it('maps the five hour row to the session window', () => {
const limits = parseAntigravityCliUsage(SAMPLE, UPDATED_AT)

expect(limits.provider).toBe('antigravity')
expect(limits.status).toBe('ok')
expect(limits.error).toBeNull()
expect(limits.session?.windowMinutes).toBe(300)
expect(limits.session?.resetsAt).toBe(Date.parse('2026-08-26T22:02:52Z'))
})

it('maps the weekly row to the weekly window', () => {
const limits = parseAntigravityCliUsage(SAMPLE, UPDATED_AT)

expect(limits.weekly?.windowMinutes).toBe(10080)
expect(limits.weekly?.resetsAt).toBe(Date.parse('2026-09-02T17:02:52Z'))
})

// Why: the CLI reports what is left, but every other provider in Orca reports what is spent.
it('converts remaining percentage into used percentage', () => {
const limits = parseAntigravityCliUsage(SAMPLE, UPDATED_AT)

expect(limits.session?.usedPercent).toBe(0)
expect(limits.weekly?.usedPercent).toBe(0)

const partial = parseAntigravityCliUsage(
'Gemini Models\tFive Hour Limit Remaining\t42%\t2026-08-26T22:02:52Z',
UPDATED_AT
)

expect(partial.session?.usedPercent).toBe(58)
})

// Why: the CLI prints both families; Gemini is the provider's own quota and must win.
it('prefers the Gemini family over the Claude and GPT family', () => {
const limits = parseAntigravityCliUsage(
[
'Claude and GPT models\tFive Hour Limit Remaining\t10%\t2026-08-26T22:02:52Z',
'Gemini Models\tFive Hour Limit Remaining\t70%\t2026-08-26T22:02:52Z'
].join('\n'),
UPDATED_AT
)

expect(limits.session?.usedPercent).toBe(30)
})

it('reports unavailable when no usage row can be read', () => {
const limits = parseAntigravityCliUsage('Please sign in to view usage.', UPDATED_AT)

expect(limits.status).toBe('unavailable')
expect(limits.session).toBeNull()
expect(limits.weekly).toBeNull()
expect(limits.error).toBeTruthy()
})

// Why: Antigravity bills two independent pools with their own resets (see #9122),
// so both must survive as named buckets, not just the headline Gemini numbers.
it('exposes both quota pools as named buckets', () => {
const limits = parseAntigravityCliUsage(SAMPLE, UPDATED_AT)

expect(limits.buckets?.map((bucket) => bucket.name)).toEqual([
'Gemini Models · 7d',
'Gemini Models · 5h',
'Claude and GPT models · 7d',
'Claude and GPT models · 5h'
])
})

it('marks the source as cli so the meter can explain itself', () => {
expect(parseAntigravityCliUsage(SAMPLE, UPDATED_AT).usageMetadata?.source).toBe('cli')
expect(parseAntigravityCliUsage('nope', UPDATED_AT).usageMetadata?.failureKind).toBe(
'usage-unavailable'
)
})
})
265 changes: 265 additions & 0 deletions src/main/rate-limits/antigravity-cli-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import { access, constants } from 'node:fs/promises'
import { homedir } from 'node:os'
import path from 'node:path'
import { runProcess } from '../../shared/child-process/run-process'
import { windowsSystem32Binary } from '../../shared/child-process/windows-system-binary'
import type { LocalAccountRuntimeTarget } from '../../shared/local-account-runtime'
import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup'
import type {
ProviderRateLimits,
RateLimitBucket,
RateLimitWindow
} from '../../shared/rate-limit-types'
import {
buildWslCapturedLoginShellCommand,
buildWslExecArgs
} from '../../shared/wsl-login-shell-command'
import { translateMain } from '../i18n/main-i18n'

const SESSION_WINDOW_MINUTES = 300
const WEEKLY_WINDOW_MINUTES = 10080

// `agy` keeps its token in the OS keyring, so only the CLI itself can report the quota.
const USAGE_ARGS = ['-p', '/usage', '--print-timeout', '30s']
// Outlives the CLI's own 30s print timeout so the child reports rather than being killed.
const PROCESS_TIMEOUT_MS = 45_000

const cliMissingReason = (): string =>
translateMain(
'rateLimits.antigravity.cliMissing',
'Antigravity usage is not available. The Antigravity CLI (agy) was not found on this machine.'
)

const quotaUnreadableReason = (): string =>
translateMain(
'rateLimits.antigravity.quotaUnreadable',
'Antigravity usage is not available. The Antigravity CLI did not report a quota; sign in with `agy` and try again.'
)

function unavailable(
reason: string,
failureKind: 'cli-unavailable' | 'usage-unavailable'
): ProviderRateLimits {
return {
provider: 'antigravity',
session: null,
weekly: null,
updatedAt: Date.now(),
error: reason,
status: 'unavailable',
usageMetadata: { source: 'cli', failureKind }
}
}

/** Exists and is executable; X_OK is a no-op on Windows, where the PATH sweep does the work. */
async function isExecutable(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.X_OK)
return true
} catch {
return false
}
}

/** Host `agy` path from PATH, then the installer's fixed prefix. Null when none is executable. */
async function resolveHostBinary(signal?: AbortSignal): Promise<string | null> {
const lookup =
process.platform === 'win32'
? { program: windowsSystem32Binary('where.exe'), args: ['agy'] }
: { program: '/usr/bin/which', args: ['agy'] }
try {
const result = await runProcess({ ...lookup, timeoutMs: 5_000, signal })
// Every hit, not just the first: a stale entry earlier on PATH would hide a working one.
for (const candidate of result.stdout.trim().split(/\r?\n/)) {
if (candidate && (await isExecutable(candidate))) {
return candidate
}
}
} catch {
// ignore where/which failure
}

// The installer writes the User PATH registry entry, which an already-running Orca never sees.
const fallbacks =
process.platform === 'win32'
? [path.join(process.env.LOCALAPPDATA ?? '', 'agy', 'bin', 'agy.exe')]
: [
path.join(homedir(), '.local', 'bin', 'agy'),
'/usr/local/bin/agy',
'/opt/homebrew/bin/agy'
]
for (const candidate of fallbacks) {
if (candidate && (await isExecutable(candidate))) {
return candidate
}
}

return null
}

type ParsedRow = {
family: string
isGeminiFamily: boolean
windowMinutes: number
window: RateLimitWindow
}

/** One `family <tab> window <tab> remaining% <tab> reset` row, or null when it is not one. */
function parseRow(line: string): ParsedRow | null {
// Tabs normally, but padded spaces survive some terminals.
const columns = line
.split(/\t|\s{2,}/)
.map((column) => column.trim())
.filter(Boolean)
if (columns.length < 3) {
return null
}

const [family, label, percent, resetAt] = columns
const remaining = /^(\d+(?:\.\d+)?)%$/.exec(percent)?.[1]
if (remaining === undefined) {
return null
}

const windowMinutes = /five\s*hour/i.test(label)
? SESSION_WINDOW_MINUTES
: /week/i.test(label)
? WEEKLY_WINDOW_MINUTES
: null
if (windowMinutes === null) {
return null
}

// The CLI reports what is left; every other Orca provider reports what is spent.
const usedPercent = Math.min(100, Math.max(0, 100 - Number(remaining)))
const parsedReset = resetAt ? Date.parse(resetAt) : Number.NaN

return {
family,
isGeminiFamily: /gemini/i.test(family),
windowMinutes,
window: {
usedPercent,
windowMinutes,
resetsAt: Number.isNaN(parsedReset) ? null : parsedReset,
resetDescription: null
}
}
}

/**
* Converts `agy -p "/usage"` output into provider rate limits.
*
* Antigravity bills two independent pools with their own resets (#9122), so both survive
* as named buckets; the headline windows come from the Gemini pool.
*/
export function parseAntigravityCliUsage(stdout: string, updatedAt: number): ProviderRateLimits {
const rows = stdout
.split(/\r?\n/)
.map(parseRow)
.filter((row): row is ParsedRow => row !== null)

const pick = (windowMinutes: number): RateLimitWindow | null => {
const candidates = rows.filter((row) => row.windowMinutes === windowMinutes)
const preferred = candidates.find((row) => row.isGeminiFamily) ?? candidates[0]
return preferred?.window ?? null
}

const session = pick(SESSION_WINDOW_MINUTES)
const weekly = pick(WEEKLY_WINDOW_MINUTES)

if (!session && !weekly) {
return { ...unavailable(quotaUnreadableReason(), 'usage-unavailable'), updatedAt }
}

const buckets: RateLimitBucket[] = rows.map((row) => ({
...row.window,
name: `${row.family} · ${row.windowMinutes === SESSION_WINDOW_MINUTES ? '5h' : '7d'}`
}))

return {
provider: 'antigravity',
session,
weekly,
buckets,
updatedAt,
error: null,
status: 'ok',
usageMetadata: { source: 'cli' }
}
}

/** Asks the distro's own `agy`, fenced because the login shell prints its banner to stdout. */
async function fetchFromWsl(distro: string, signal?: AbortSignal): Promise<ProviderRateLimits> {
// skipWindowsMountDirs so the Windows agy on the mounted PATH cannot answer for the guest.
const command = [
buildPosixCommandPathLookupScript(
{ kind: 'literal', value: 'agy' },
{ skipWindowsMountDirs: true }
),
'if [ -z "$resolved" ]; then exit 127; fi',
`exec "$resolved" ${USAGE_ARGS.join(' ')}`
].join('\n')
const captured = buildWslCapturedLoginShellCommand(command)

try {
const result = await runProcess({
program: windowsSystem32Binary('wsl.exe'),
args: buildWslExecArgs(distro, ['sh', '-c', captured.command]),
timeoutMs: PROCESS_TIMEOUT_MS,
signal
})
const payload = captured.readStdout(result.stdout)
if (result.code !== 0 || payload === null) {
return unavailable(
result.code === 127 ? cliMissingReason() : quotaUnreadableReason(),
result.code === 127 ? 'cli-unavailable' : 'usage-unavailable'
)
}
return parseAntigravityCliUsage(payload, Date.now())
} catch {
return unavailable(quotaUnreadableReason(), 'usage-unavailable')
}
}

/**
* Reads Antigravity quota from the CLI on the runtime that owns it.
*
* `agy` signs in per runtime and keeps the token in that runtime's keyring, so a WSL target
* must be asked inside WSL — the host copy would answer for a different account (#12370).
*
* `RateLimitService` models only host and WSL, so SSH targets cannot be honoured here yet;
* that needs execution-host awareness across the service (see ssh-execution-boundary.md).
*/
export async function fetchAntigravityRateLimits(options?: {
target?: LocalAccountRuntimeTarget
signal?: AbortSignal
}): Promise<ProviderRateLimits> {
const { target, signal } = options ?? {}

if (target?.runtime === 'wsl') {
return target.wslDistro
? await fetchFromWsl(target.wslDistro, signal)
: unavailable(cliMissingReason(), 'cli-unavailable')
}

const binary = await resolveHostBinary(signal)
if (!binary) {
return unavailable(cliMissingReason(), 'cli-unavailable')
}

try {
const result = await runProcess({
program: binary,
args: USAGE_ARGS,
timeoutMs: PROCESS_TIMEOUT_MS,
signal
})
// A non-zero exit means signed out or an unreachable backend, not an Orca error.
return result.code === 0
? parseAntigravityCliUsage(result.stdout, Date.now())
: unavailable(quotaUnreadableReason(), 'usage-unavailable')
} catch {
return unavailable(quotaUnreadableReason(), 'usage-unavailable')
}
}
11 changes: 11 additions & 0 deletions src/main/rate-limits/rate-limit-service-test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types'
import type { RateLimitService } from './service'
import { fetchCodexRateLimits } from './codex-fetcher'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchAntigravityRateLimits } from './antigravity-cli-usage'
import { fetchKimiRateLimits } from './kimi-fetcher'
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchGrokRateLimits } from './grok-fetcher'
Expand Down Expand Up @@ -95,6 +96,16 @@ export function mockFreshBackgroundProviderFetches(): void {
export function resetRateLimitProviderMocks(): void {
vi.clearAllMocks()
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now()))
// Default to a machine without the Antigravity CLI, which is the mirror path.
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue({
provider: 'antigravity',
session: null,
weekly: null,
updatedAt: Date.now(),
error: 'Antigravity CLI not found.',
status: 'unavailable',
usageMetadata: { source: 'cli', failureKind: 'cli-unavailable' }
})
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()))
Expand Down
Loading