mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
fix(rate-limits): read real Antigravity quota from the agy CLI, not the Gemini mirror (#24073)
* fix(rate-limits): read real Antigravity quota from the agy CLI Orca published a successful Gemini `retrieveUserQuota` read under the antigravity provider id. That reported Gemini CLI per-model buckets on a 60-minute window, so Antigravity's real pools were never shown, the weekly window was always null, and the segment depended on an installed @google/gemini-cli for token refresh that an Antigravity user has no reason to have. Read the quota from `agy -p "/usage" --output-format json` instead, which is the only caller that can authenticate it — agy keeps its credential in the OS keyring and mints its own token against daily-cloudcode-pa. Fixes #9122 Fixes #22511 * test(rate-limits): stub the Antigravity CLI fetch in every service suite Without the stub, each RateLimitService suite spawned the developer's real `agy` and resolved a login shell, which turned service-window-activation from 214 ms into 14 s and broke its fake-timer fetch counts. * fix(rate-limits): never pass --disable-slash-commands to the agy quota read The flag stops agy expanding `/usage` as a command, so the text goes to the model as an ordinary prompt: the call starts a conversation, spends quota, and on an account near its limit answers RESOURCE_EXHAUSTED (429) instead of a reading. Adds an opt-in real-CLI suite that catches exactly this. * fix(rate-limits): stop polling agy once it answers /usage as a prompt In print mode an unrecognised slash command is not an error — agy sends the text to the model. On a build that does not know `/usage`, polling would start a conversation and spend the user's quota every cycle while Orca reported no quota. The envelope distinguishes the two: a command reply has an empty conversation_id and num_turns 0. A successful parse is checked first, so a real reading can never trip the latch.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
|
||||
/**
|
||||
* The quota read is a slash command run in print mode.
|
||||
*
|
||||
* Why print mode and not an HTTP call: agy keeps its Google credential in the OS keyring and mints
|
||||
* its own access token against `daily-cloudcode-pa`, so nothing outside agy can authenticate the
|
||||
* quota endpoint. Verified on agy 1.2.11: the call spends no quota and starts no conversation
|
||||
* (`num_turns: 0`, every token counter 0, empty `conversation_id`).
|
||||
*
|
||||
* `/usage` over `/quota`: both resolve to the same `usage` command, and `/usage` is the spelling agy
|
||||
* lists in its own help.
|
||||
*/
|
||||
export const ANTIGRAVITY_USAGE_ARGS: readonly string[] = [
|
||||
'-p',
|
||||
'/usage',
|
||||
'--output-format',
|
||||
'json',
|
||||
// Why bound it inside agy too: the process timeout below kills a hung child, but agy's own
|
||||
// deadline lets it exit cleanly and print a diagnostic instead of dying mid-write.
|
||||
'--print-timeout',
|
||||
'20s'
|
||||
// Do NOT add --disable-slash-commands here. It stops agy expanding `/usage` as a command, so the
|
||||
// text is sent to the model as an ordinary prompt: the call then starts a conversation, spends
|
||||
// quota, and on an account near its limit returns RESOURCE_EXHAUSTED (429) instead of a reading.
|
||||
// Verified against agy 1.2.11 — the flag turned a free metadata read into a billed model turn.
|
||||
]
|
||||
|
||||
/**
|
||||
* How long the child may run before Orca kills it.
|
||||
*
|
||||
* Observed cost on a warm macOS install is 2.1–2.6 s (three consecutive runs), which is the CLI
|
||||
* starting its language server and refreshing the quota. The ceiling is generous because a cold
|
||||
* start also pays a binary self-check, and the fetch runs on its own promise so a slow read delays
|
||||
* nothing else in the cycle.
|
||||
*/
|
||||
export const ANTIGRAVITY_USAGE_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Cap on captured output; the envelope is a single JSON line well under a kilobyte. */
|
||||
export const ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES = 512 * 1024
|
||||
|
||||
/** The command name Orca already uses to detect Antigravity, so both agree on the binary. */
|
||||
export function antigravityCommandName(): string {
|
||||
return TUI_AGENT_CONFIG.antigravity.detectCmd
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the args are never appended to a configured launch command: a user's Antigravity launch
|
||||
* command may carry its own flags, a wrapper script, or a shell pipeline, and appending `-p /usage`
|
||||
* to that either runs the wrong program or feeds the slash command to the wrong argv slot. The quota
|
||||
* read resolves the plain executable itself instead.
|
||||
*/
|
||||
export function isPlainAntigravityExecutable(command: string): boolean {
|
||||
const trimmed = command.trim()
|
||||
if (trimmed.length === 0) {
|
||||
return false
|
||||
}
|
||||
return !/[\s"'|&;<>$`()]/.test(trimmed)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
fetchAntigravityRateLimits,
|
||||
resetAntigravityUsageSupportForTests
|
||||
} from './antigravity-usage-fetcher'
|
||||
import { ANTIGRAVITY_USAGE_ARGS } from './antigravity-usage-command'
|
||||
import type { ProcessResult } from '../../shared/child-process/process-spec'
|
||||
|
||||
const USAGE_ENVELOPE = JSON.stringify({
|
||||
conversation_id: '',
|
||||
status: 'SUCCESS',
|
||||
command: {
|
||||
name: 'usage',
|
||||
data: {
|
||||
description: 'Within each group, models share a weekly limit.',
|
||||
groups: [
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
{
|
||||
id: 'gemini-weekly',
|
||||
name: 'Weekly Limit Remaining',
|
||||
window: 'weekly',
|
||||
remaining_fraction: 0.4,
|
||||
reset_time: '2026-10-07T08:08:35Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function processResult(overrides: Partial<ProcessResult> = {}): ProcessResult {
|
||||
return { code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...overrides }
|
||||
}
|
||||
|
||||
function harness(
|
||||
options: {
|
||||
result?: ProcessResult
|
||||
runCommand?: ReturnType<typeof vi.fn>
|
||||
program?: string | null
|
||||
env?: NodeJS.ProcessEnv
|
||||
} = {}
|
||||
) {
|
||||
const runCommand =
|
||||
options.runCommand ?? vi.fn().mockResolvedValue(options.result ?? processResult())
|
||||
// Why the `in` check and not `??`: an explicit `program: null` is the absent-CLI case.
|
||||
const resolveCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValue('program' in options ? options.program : '/Users/x/.local/bin/agy')
|
||||
const resolveEnvironment = vi
|
||||
.fn()
|
||||
.mockResolvedValue(options.env ?? { PATH: '/Users/x/.local/bin:/usr/bin' })
|
||||
return {
|
||||
runCommand,
|
||||
resolveCommand,
|
||||
resolveEnvironment,
|
||||
fetch: () =>
|
||||
fetchAntigravityRateLimits({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock returns a ProcessResult, which is the whole contract runProcess exposes to this fetcher.
|
||||
runCommand: runCommand as never,
|
||||
resolveCommand,
|
||||
resolveEnvironment,
|
||||
platform: 'darwin',
|
||||
now: () => 1_700_000_000_000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('fetchAntigravityRateLimits', () => {
|
||||
beforeEach(() => {
|
||||
resetAntigravityUsageSupportForTests()
|
||||
})
|
||||
|
||||
it('publishes the CLI reading as Antigravity usage', async () => {
|
||||
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.provider).toBe('antigravity')
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.weekly).toMatchObject({ usedPercent: 60, windowMinutes: 10_080 })
|
||||
expect(result.buckets).toEqual([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
usedPercent: 60,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
|
||||
resetDescription: null
|
||||
}
|
||||
])
|
||||
expect(result.usageMetadata).toMatchObject({
|
||||
source: 'cli',
|
||||
credentialSource: 'antigravity-cli'
|
||||
})
|
||||
})
|
||||
|
||||
it('never publishes the agy bucket id to the renderer', async () => {
|
||||
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
|
||||
|
||||
for (const bucket of result.buckets ?? []) {
|
||||
expect(bucket).not.toHaveProperty('id')
|
||||
}
|
||||
})
|
||||
|
||||
it('runs the resolved absolute path with the quota arguments and the login-shell env', async () => {
|
||||
const h = harness({ result: processResult({ stdout: USAGE_ENVELOPE }) })
|
||||
await h.fetch()
|
||||
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(1)
|
||||
const spec = h.runCommand.mock.calls[0]![0]
|
||||
expect(spec.program).toBe('/Users/x/.local/bin/agy')
|
||||
expect(spec.args).toEqual(ANTIGRAVITY_USAGE_ARGS)
|
||||
// Why the login-shell PATH: agy installs to ~/.local/bin, which Electron's inherited PATH omits.
|
||||
expect(spec.env).toEqual({ PATH: '/Users/x/.local/bin:/usr/bin' })
|
||||
expect(spec.timeoutMs).toBeGreaterThan(0)
|
||||
expect(h.resolveCommand).toHaveBeenCalledWith('agy', {
|
||||
platform: 'darwin',
|
||||
env: { PATH: '/Users/x/.local/bin:/usr/bin' }
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an absent CLI as unavailable and never spawns', async () => {
|
||||
const h = harness({ program: null })
|
||||
const result = await h.fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('cli-unavailable')
|
||||
expect(result.error).toContain('was not found on this machine')
|
||||
expect(h.runCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a signed-out account as unavailable, not as a failed refresh', async () => {
|
||||
const result = await harness({
|
||||
// agy exits 0 and prints this rather than an envelope.
|
||||
result: processResult({ stderr: 'You are not logged into Antigravity.' })
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
|
||||
expect(result.error).toContain('Sign in with `agy`')
|
||||
})
|
||||
|
||||
it('reports a timeout as its own failure kind', async () => {
|
||||
const result = await harness({ result: processResult({ timedOut: true }) }).fetch()
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toContain('did not answer in time')
|
||||
})
|
||||
|
||||
it('reports an unreadable payload as a parse failure carrying the exit code', async () => {
|
||||
const result = await harness({
|
||||
result: processResult({ code: 2, stdout: 'unknown command /usage' })
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('parse')
|
||||
expect(result.error).toContain('exit 2')
|
||||
})
|
||||
|
||||
it('does not blame the exit code when agy exited cleanly with no payload', async () => {
|
||||
const result = await harness({ result: processResult({ code: 0, stdout: '' }) }).fetch()
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).not.toContain('exit')
|
||||
})
|
||||
|
||||
it('reports a spawn failure instead of rejecting the cycle', async () => {
|
||||
const runCommand = vi.fn().mockRejectedValue(new Error('EACCES'))
|
||||
const result = await harness({ runCommand }).fetch()
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('cli-unavailable')
|
||||
expect(result.error).toContain('EACCES')
|
||||
})
|
||||
|
||||
it('never reports quota from a successful read as stale session data', async () => {
|
||||
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
|
||||
|
||||
// Why: this tier meters no 5h pool. The Gemini mirror it replaces filled `session` from a
|
||||
// 60-minute per-model window and left `weekly` null — exactly backwards (#22511).
|
||||
expect(result.session).toBeNull()
|
||||
expect(result.weekly).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Captured when agy treated `/usage` as a prompt instead of a command: a conversation was started,
|
||||
* a turn was spent, and the account answered RESOURCE_EXHAUSTED. This is the exact shape the
|
||||
* unsupported latch has to recognise.
|
||||
*/
|
||||
const MODEL_TURN_ENVELOPE = JSON.stringify({
|
||||
conversation_id: '28a5ca91-301f-4050-8efc-9c82c4e64df3',
|
||||
status: 'ERROR',
|
||||
response: '',
|
||||
error: 'Individual quota reached. Please upgrade your subscription to increase your limits.',
|
||||
num_turns: 1
|
||||
})
|
||||
|
||||
describe('agy versions that answer /usage as a prompt', () => {
|
||||
beforeEach(() => {
|
||||
resetAntigravityUsageSupportForTests()
|
||||
})
|
||||
|
||||
it('reports the quota read as unavailable instead of as a parse failure', async () => {
|
||||
const result = await harness({
|
||||
result: processResult({ stdout: MODEL_TURN_ENVELOPE })
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toContain('answers `/usage` as a prompt')
|
||||
})
|
||||
|
||||
it('never spawns agy again once a turn was spent', async () => {
|
||||
const h = harness({ result: processResult({ stdout: MODEL_TURN_ENVELOPE }) })
|
||||
await h.fetch()
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Why: the evidence costs a turn of the user's quota, so rediscovering it on a 15-minute
|
||||
// cadence would keep paying for the same answer.
|
||||
await h.fetch()
|
||||
await h.fetch()
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not latch when the usage payload parsed, whatever else the envelope says', async () => {
|
||||
const h = harness({
|
||||
result: processResult({ stdout: `${USAGE_ENVELOPE}\n${MODEL_TURN_ENVELOPE}` })
|
||||
})
|
||||
const first = await h.fetch()
|
||||
const second = await h.fetch()
|
||||
|
||||
expect(first.status).toBe('ok')
|
||||
expect(second.status).toBe('ok')
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not latch on an empty or unparsable answer', async () => {
|
||||
const h = harness({ result: processResult({ code: 2, stdout: 'unknown flag' }) })
|
||||
const first = await h.fetch()
|
||||
const second = await h.fetch()
|
||||
|
||||
// Why: a transient failure is not evidence that the command is unsupported.
|
||||
expect(first.status).toBe('error')
|
||||
expect(second.status).toBe('error')
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver'
|
||||
import { resolveLoginShellEnvironment } from '../startup/login-shell-environment'
|
||||
import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared/rate-limit-types'
|
||||
import {
|
||||
ANTIGRAVITY_USAGE_ARGS,
|
||||
ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES,
|
||||
ANTIGRAVITY_USAGE_TIMEOUT_MS,
|
||||
antigravityCommandName
|
||||
} from './antigravity-usage-command'
|
||||
import { parseAntigravityUsageStdout, stdoutShowsModelTurn } from './antigravity-usage-response'
|
||||
|
||||
/**
|
||||
* Observed verbatim in agy's own log when the keyring holds no session. agy exits 0 and prints this
|
||||
* instead of a usage envelope, so the text is the only thing that separates "signed out" from
|
||||
* "answered nothing".
|
||||
*/
|
||||
const NOT_SIGNED_IN_MARKER = 'not logged into antigravity'
|
||||
|
||||
const UNSUPPORTED_USAGE_COMMAND_REASON =
|
||||
'Antigravity usage is not available. This version of the Antigravity CLI answers `/usage` as a prompt instead of a command, so Orca stopped asking rather than spend quota on it. Update `agy` and restart Orca.'
|
||||
|
||||
/**
|
||||
* Latched once agy answers the quota read with a model turn.
|
||||
*
|
||||
* Why latch instead of retrying: the evidence that this agy cannot answer `/usage` is the same
|
||||
* event that spends a turn of the user's quota. Retrying on a cadence would keep paying for the
|
||||
* same discovery, so the probe is abandoned for the rest of the process's life.
|
||||
*/
|
||||
let usageCommandUnsupported = false
|
||||
|
||||
/** Clears the unsupported latch. Tests only — a live process has no way back. */
|
||||
export function resetAntigravityUsageSupportForTests(): void {
|
||||
usageCommandUnsupported = false
|
||||
}
|
||||
|
||||
export type AntigravityUsageDependencies = {
|
||||
/** Injected so tests exercise the classification without spawning agy. */
|
||||
runCommand?: typeof runProcess
|
||||
resolveCommand?: typeof resolveCommandOnLocalPath
|
||||
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
|
||||
platform?: NodeJS.Platform
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export type FetchAntigravityRateLimitsOptions = AntigravityUsageDependencies & {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
message: string,
|
||||
failureKind: UsageRateLimitFailureKind,
|
||||
now: number
|
||||
): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'antigravity',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: now,
|
||||
error: message,
|
||||
// Why 'unavailable' and not 'error' for every failure: an absent CLI or a signed-out account is
|
||||
// a state the user can act on, and the status bar renders it as guidance rather than as a
|
||||
// refresh that keeps failing (#7809, #14227).
|
||||
status: 'unavailable',
|
||||
usageMetadata: { source: 'cli', attemptedSources: ['cli'], failureKind }
|
||||
}
|
||||
}
|
||||
|
||||
function failed(
|
||||
message: string,
|
||||
failureKind: UsageRateLimitFailureKind,
|
||||
now: number
|
||||
): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'antigravity',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: now,
|
||||
error: message,
|
||||
status: 'error',
|
||||
usageMetadata: { source: 'cli', attemptedSources: ['cli'], failureKind }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Antigravity quota from the Antigravity CLI itself.
|
||||
*
|
||||
* Why the CLI and not the Gemini mirror it replaces: Orca used to publish a *successful* Gemini
|
||||
* `retrieveUserQuota` read under the Antigravity provider id. That reported Gemini CLI per-model
|
||||
* buckets on a 60-minute window, so Antigravity's real pools ("Gemini Models" and "Claude and GPT
|
||||
* models", each weekly) were never shown and the weekly limit was always null (#9122, #22511). It
|
||||
* also made the segment depend on an installed `@google/gemini-cli` for token refresh, which an
|
||||
* Antigravity user has no reason to have.
|
||||
*
|
||||
* This runs on whichever machine owns execution; the caller is responsible for not asking a local
|
||||
* agy about a remote workspace's quota.
|
||||
*/
|
||||
export async function fetchAntigravityRateLimits(
|
||||
options: FetchAntigravityRateLimitsOptions = {}
|
||||
): Promise<ProviderRateLimits> {
|
||||
const now = options.now ?? Date.now
|
||||
if (usageCommandUnsupported) {
|
||||
return unavailable(UNSUPPORTED_USAGE_COMMAND_REASON, 'usage-unavailable', now())
|
||||
}
|
||||
const run = options.runCommand ?? runProcess
|
||||
const resolve = options.resolveCommand ?? resolveCommandOnLocalPath
|
||||
const platform = options.platform ?? process.platform
|
||||
const resolveEnvironment = options.resolveEnvironment ?? (() => resolveLoginShellEnvironment())
|
||||
|
||||
// Why the login shell's env: agy installs to ~/.local/bin, which is on the user's PATH but not on
|
||||
// the PATH an Electron app inherits from the window server or a desktop launcher.
|
||||
const env = await resolveEnvironment()
|
||||
const command = antigravityCommandName()
|
||||
const program = await resolve(command, { platform, env })
|
||||
if (!program) {
|
||||
return unavailable(
|
||||
`Antigravity usage is not available. The Antigravity CLI (\`${command}\`) was not found on this machine.`,
|
||||
'cli-unavailable',
|
||||
now()
|
||||
)
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof runProcess>>
|
||||
try {
|
||||
result = await run({
|
||||
program,
|
||||
args: ANTIGRAVITY_USAGE_ARGS,
|
||||
env,
|
||||
timeoutMs: ANTIGRAVITY_USAGE_TIMEOUT_MS,
|
||||
maxOutputBytes: ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES,
|
||||
signal: options.signal
|
||||
})
|
||||
} catch (error) {
|
||||
return failed(
|
||||
`Antigravity usage is not available. The Antigravity CLI could not be started: ${error instanceof Error ? error.message : 'unknown error'}.`,
|
||||
'cli-unavailable',
|
||||
now()
|
||||
)
|
||||
}
|
||||
|
||||
if (result.timedOut) {
|
||||
return failed(
|
||||
'Antigravity usage is not available. The Antigravity CLI did not answer in time.',
|
||||
'usage-unavailable',
|
||||
now()
|
||||
)
|
||||
}
|
||||
|
||||
const output = `${result.stdout}\n${result.stderr}`
|
||||
if (output.toLowerCase().includes(NOT_SIGNED_IN_MARKER)) {
|
||||
return unavailable(
|
||||
'Antigravity usage is not available. Sign in with `agy` to report this account’s quota.',
|
||||
'missing-credentials',
|
||||
now()
|
||||
)
|
||||
}
|
||||
|
||||
const reading = parseAntigravityUsageStdout(result.stdout)
|
||||
// Why the successful read is checked first: a real reading can never be evidence of a prompt, so
|
||||
// ordering it ahead of the turn check makes a false latch impossible.
|
||||
if (!reading && stdoutShowsModelTurn(result.stdout)) {
|
||||
usageCommandUnsupported = true
|
||||
return unavailable(UNSUPPORTED_USAGE_COMMAND_REASON, 'usage-unavailable', now())
|
||||
}
|
||||
if (!reading) {
|
||||
// Why a non-zero exit is reported only here: `runProcess` treats the exit code as data, and agy
|
||||
// exits 0 for a signed-out read, so the code only adds detail once the payload is missing.
|
||||
const exitDetail = result.code === 0 || result.code === null ? '' : ` (exit ${result.code})`
|
||||
return failed(
|
||||
`Antigravity usage is not available. The Antigravity CLI did not report a quota${exitDetail}.`,
|
||||
'parse',
|
||||
now()
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'antigravity',
|
||||
session: reading.session,
|
||||
weekly: reading.weekly,
|
||||
buckets: reading.buckets.map(({ id: _id, ...bucket }) => bucket),
|
||||
updatedAt: now(),
|
||||
error: null,
|
||||
status: 'ok',
|
||||
usageMetadata: {
|
||||
source: 'cli',
|
||||
attemptedSources: ['cli'],
|
||||
lastSuccessfulSource: 'cli',
|
||||
credentialSource: 'antigravity-cli'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher'
|
||||
|
||||
/**
|
||||
* Runs the real Antigravity CLI against the developer's own signed-in account.
|
||||
*
|
||||
* Opt in with `ORCA_REAL_AGY_CLI_TEST=1`, the same shape as the real Claude CLI suite. It is off by
|
||||
* default because it spawns `agy`, needs a live sign-in, and takes seconds — but it is the only
|
||||
* check that catches agy changing the payload the parser is written against.
|
||||
*
|
||||
* `ORCA_REAL_AGY_CLI_TEST=1 pnpm test src/main/rate-limits/antigravity-usage-real-cli.test.ts`
|
||||
*/
|
||||
const enabled = process.env.ORCA_REAL_AGY_CLI_TEST === '1'
|
||||
|
||||
describe.skipIf(!enabled)('Antigravity usage against the real agy CLI', () => {
|
||||
it('reports quota with at least one named pool', async () => {
|
||||
const result = await fetchAntigravityRateLimits()
|
||||
|
||||
if (result.status !== 'ok') {
|
||||
// A machine with no agy or no sign-in still proves the classification, not a crash.
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.error).toBeTruthy()
|
||||
return
|
||||
}
|
||||
|
||||
expect(result.provider).toBe('antigravity')
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.buckets?.length).toBeGreaterThan(0)
|
||||
expect(result.usageMetadata?.source).toBe('cli')
|
||||
for (const bucket of result.buckets ?? []) {
|
||||
expect(bucket.name.length).toBeGreaterThan(0)
|
||||
expect(bucket.usedPercent).toBeGreaterThanOrEqual(0)
|
||||
expect(bucket.usedPercent).toBeLessThanOrEqual(100)
|
||||
}
|
||||
// At least one window must be summarised, or the segment has nothing to draw.
|
||||
expect(result.session ?? result.weekly).not.toBeNull()
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseAntigravityUsageEnvelope,
|
||||
parseAntigravityUsageStdout
|
||||
} from './antigravity-usage-response'
|
||||
|
||||
/**
|
||||
* Captured verbatim from `agy -p "/usage" --output-format json` on agy 1.2.11 (macOS arm64).
|
||||
* A tier with no 5h bucket reports weekly alone, which is why `session` is null here.
|
||||
*/
|
||||
const REAL_AGY_1_2_11_STDOUT = `{"conversation_id":"","status":"SUCCESS","response":"Gemini Models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\nClaude and GPT models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\n","duration_seconds":0,"num_turns":0,"usage":{"input_tokens":0,"output_tokens":0,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":0},"command":{"name":"usage","data":{"description":"Within each group, models share a weekly limit.","groups":[{"name":"Gemini Models","description":"Models within this group: Gemini Flash, Gemini Pro","buckets":[{"id":"gemini-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]},{"name":"Claude and GPT models","description":"Models within this group: Claude Opus, Claude Sonnet, GPT-OSS","buckets":[{"id":"3p-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]}]}}}`
|
||||
|
||||
function envelope(groups: unknown, description = 'pool help'): unknown {
|
||||
return {
|
||||
status: 'SUCCESS',
|
||||
command: { name: 'usage', data: { description, groups } }
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseAntigravityUsageStdout', () => {
|
||||
it('reads the real agy 1.2.11 payload as two weekly group pools', () => {
|
||||
const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT)
|
||||
|
||||
expect(reading).not.toBeNull()
|
||||
expect(reading?.buckets).toEqual([
|
||||
{
|
||||
id: 'gemini-weekly',
|
||||
name: 'Gemini Models',
|
||||
usedPercent: 0,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
|
||||
resetDescription: null
|
||||
},
|
||||
{
|
||||
id: '3p-weekly',
|
||||
name: 'Claude and GPT models',
|
||||
usedPercent: 0,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
|
||||
resetDescription: null
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('reports the weekly window the Gemini mirror always left null', () => {
|
||||
const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT)
|
||||
|
||||
expect(reading?.weekly).toEqual({
|
||||
usedPercent: 0,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
|
||||
resetDescription: null
|
||||
})
|
||||
// Why null: this tier meters no 5h pool, and inventing one would claim headroom agy never
|
||||
// reported.
|
||||
expect(reading?.session).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores log noise printed around the envelope', () => {
|
||||
const reading = parseAntigravityUsageStdout(
|
||||
`I0926 16:22:51.157090 quota_manager.go:36] doRefreshQuota\n${REAL_AGY_1_2_11_STDOUT}\nBye.`
|
||||
)
|
||||
|
||||
expect(reading?.buckets).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns null for stdout with no envelope at all', () => {
|
||||
expect(parseAntigravityUsageStdout('You are not logged into Antigravity.')).toBeNull()
|
||||
expect(parseAntigravityUsageStdout('')).toBeNull()
|
||||
expect(parseAntigravityUsageStdout('{ not json')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAntigravityUsageEnvelope', () => {
|
||||
it('maps a 5h bucket onto the session window and weekly onto the weekly window', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
{
|
||||
id: 'gemini-5h',
|
||||
name: '5h Limit Remaining',
|
||||
window: '5h',
|
||||
remaining_fraction: 0.25,
|
||||
reset_time: '2026-09-30T12:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'gemini-weekly',
|
||||
name: 'Weekly Limit Remaining',
|
||||
window: 'weekly',
|
||||
remaining_fraction: 0.5,
|
||||
reset_time: '2026-10-07T00:00:00Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.session).toMatchObject({ usedPercent: 75, windowMinutes: 300 })
|
||||
expect(reading?.weekly).toMatchObject({ usedPercent: 50, windowMinutes: 10_080 })
|
||||
})
|
||||
|
||||
it('names both windows of one group apart', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
{ id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1 },
|
||||
{ id: 'gemini-weekly', name: 'Weekly', window: 'weekly', remaining_fraction: 1 }
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets.map((bucket) => bucket.name)).toEqual([
|
||||
'Gemini Models · 5h',
|
||||
'Gemini Models · Weekly'
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a disabled bucket instead of drawing it as unused', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
// The #22511 account: the 5h pool is not metered and the weekly pool is exhausted.
|
||||
{ id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1, disabled: true },
|
||||
{
|
||||
id: 'gemini-weekly',
|
||||
name: 'Weekly',
|
||||
window: 'weekly',
|
||||
remaining_fraction: 0,
|
||||
reset_time: '2026-10-01T00:00:00Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets).toHaveLength(1)
|
||||
expect(reading?.buckets[0]).toMatchObject({ id: 'gemini-weekly', usedPercent: 100 })
|
||||
expect(reading?.session).toBeNull()
|
||||
expect(reading?.weekly?.usedPercent).toBe(100)
|
||||
// Why the single bucket keeps the bare group name: the disabled sibling is not a row.
|
||||
expect(reading?.buckets[0]?.name).toBe('Gemini Models')
|
||||
})
|
||||
|
||||
it('summarises each window by its most constrained group', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [{ id: 'gemini-weekly', window: 'weekly', remaining_fraction: 0.9 }]
|
||||
},
|
||||
{
|
||||
name: 'Claude and GPT models',
|
||||
buckets: [{ id: '3p-weekly', window: 'weekly', remaining_fraction: 0.1 }]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
// Why the worst pool: the tier is out of Antigravity when either group is out.
|
||||
expect(reading?.weekly?.usedPercent).toBe(90)
|
||||
})
|
||||
|
||||
it('keeps an unrecognised window as a named bucket without claiming a duration', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [{ id: 'gemini-monthly', window: 'monthly', remaining_fraction: 0.4 }]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets[0]).toMatchObject({ usedPercent: 60, windowMinutes: 0 })
|
||||
expect(reading?.session).toBeNull()
|
||||
expect(reading?.weekly).toBeNull()
|
||||
})
|
||||
|
||||
it('carries agy’s own pool explanation through', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope(
|
||||
[
|
||||
{ name: 'Gemini Models', buckets: [{ id: 'g', window: 'weekly', remaining_fraction: 1 }] }
|
||||
],
|
||||
'Quota is consumed proportionally to the cost of the tokens.'
|
||||
)
|
||||
)
|
||||
|
||||
expect(reading?.description).toBe('Quota is consumed proportionally to the cost of the tokens.')
|
||||
})
|
||||
|
||||
it('clamps a fraction outside 0..1', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'G',
|
||||
buckets: [
|
||||
{ id: 'a', window: 'weekly', remaining_fraction: 1.4 },
|
||||
{ id: 'b', window: '5h', remaining_fraction: -0.2 }
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets.map((bucket) => bucket.usedPercent)).toEqual([0, 100])
|
||||
})
|
||||
|
||||
it('reads a missing or unparsable reset time as unknown', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'G',
|
||||
buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, reset_time: 'soon' }]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets[0]?.resetsAt).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-SUCCESS status', { status: 'ERROR', command: { name: 'usage', data: { groups: [] } } }],
|
||||
['another command’s payload', { status: 'SUCCESS', command: { name: 'models', data: {} } }],
|
||||
['a missing command', { status: 'SUCCESS' }],
|
||||
['no groups', envelope(undefined)],
|
||||
['an empty group list', envelope([])],
|
||||
['a group with no usable bucket', envelope([{ name: 'G', buckets: [{ id: 'a' }] }])],
|
||||
[
|
||||
'a group with every bucket disabled',
|
||||
envelope([
|
||||
{
|
||||
name: 'G',
|
||||
buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, disabled: true }]
|
||||
}
|
||||
])
|
||||
],
|
||||
['a non-object', 'nope'],
|
||||
['null', null]
|
||||
])('returns null for %s', (_label, value) => {
|
||||
expect(parseAntigravityUsageEnvelope(value)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
import { deriveMostConstrainedWindow } from './rate-limit-bucket-summary'
|
||||
|
||||
/**
|
||||
* Parses what `agy -p "/usage" --output-format json` prints.
|
||||
*
|
||||
* The shape is agy's print-mode envelope with the slash command's own payload attached, verified
|
||||
* against agy 1.2.11 on macOS:
|
||||
*
|
||||
* ```json
|
||||
* { "status": "SUCCESS", "response": "Gemini Models\tWeekly Limit Remaining\t100%\t2026-10-07T08:08:35Z\n…",
|
||||
* "command": { "name": "usage", "data": { "description": "…", "groups": [
|
||||
* { "name": "Gemini Models", "description": "Models within this group: Gemini Flash, Gemini Pro",
|
||||
* "buckets": [{ "id": "gemini-weekly", "name": "Weekly Limit Remaining", "window": "weekly",
|
||||
* "remaining_fraction": 1, "reset_time": "2026-10-07T08:08:35Z" }] } ] } } }
|
||||
* ```
|
||||
*
|
||||
* `command.data` is the contract, not the `response` text: the text is a lossy tab-joined rendering
|
||||
* that rounds the fraction to a whole percent and drops both the bucket ids and `disabled`.
|
||||
*/
|
||||
|
||||
/** 7 days. agy reports the window by name, so the minute count is Orca's mapping, not agy's. */
|
||||
const WEEKLY_WINDOW_MINUTES = 10_080
|
||||
/** 5 hours. Only some tiers expose a 5h bucket; a tier without one reports weekly alone. */
|
||||
const SESSION_WINDOW_MINUTES = 300
|
||||
|
||||
export type AntigravityUsageBucket = RateLimitBucket & {
|
||||
/** agy's stable bucket id (`gemini-weekly`, `gemini-5h`, `3p-weekly`, `3p-5h`). */
|
||||
id: string
|
||||
}
|
||||
|
||||
export type AntigravityUsageReading = {
|
||||
session: RateLimitWindow | null
|
||||
weekly: RateLimitWindow | null
|
||||
buckets: AntigravityUsageBucket[]
|
||||
/** agy's own explanation of how the pools work, shown as the segment's help text. */
|
||||
description: string | null
|
||||
}
|
||||
|
||||
type RawBucket = {
|
||||
id?: unknown
|
||||
name?: unknown
|
||||
window?: unknown
|
||||
remaining_fraction?: unknown
|
||||
reset_time?: unknown
|
||||
disabled?: unknown
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps agy's window name onto Orca's minute count.
|
||||
*
|
||||
* Why only these two: agy groups models into pools that share a limit, and a pool carries at most a
|
||||
* rolling 5h bucket and a weekly bucket. An unrecognised name is reported as a named bucket with no
|
||||
* window rather than being forced into one of the two, so a new agy window cannot silently be drawn
|
||||
* as a weekly limit.
|
||||
*/
|
||||
function windowMinutesFor(window: string | null): number | null {
|
||||
if (window === 'weekly') {
|
||||
return WEEKLY_WINDOW_MINUTES
|
||||
}
|
||||
if (window === '5h') {
|
||||
return SESSION_WINDOW_MINUTES
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseResetsAt(value: unknown): number | null {
|
||||
const text = readString(value)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const parsed = new Date(text).getTime()
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a group name and not the bucket name: every bucket in the payload is called "Weekly Limit
|
||||
* Remaining", so the bucket name alone renders two identical rows. The group is what distinguishes
|
||||
* them ("Gemini Models" vs "Claude and GPT models"), and the agy label is only appended when one
|
||||
* group reports more than one window.
|
||||
*/
|
||||
function formatBucketName(groupName: string, bucketName: string | null, siblings: number): string {
|
||||
if (siblings <= 1 || !bucketName) {
|
||||
return groupName
|
||||
}
|
||||
return `${groupName} · ${bucketName}`
|
||||
}
|
||||
|
||||
function parseBucket(
|
||||
raw: RawBucket,
|
||||
groupName: string,
|
||||
siblings: number
|
||||
): AntigravityUsageBucket | null {
|
||||
const id = readString(raw.id)
|
||||
const fraction = raw.remaining_fraction
|
||||
if (!id || typeof fraction !== 'number' || !Number.isFinite(fraction)) {
|
||||
return null
|
||||
}
|
||||
// Why skip: a disabled bucket is one the tier does not meter at all. #22511 saw `gemini-5h`
|
||||
// disabled while `gemini-weekly` was exhausted; drawing the disabled bucket as 0% used would
|
||||
// report headroom the account does not have.
|
||||
if (raw.disabled === true) {
|
||||
return null
|
||||
}
|
||||
const usedPercent = Math.min(100, Math.max(0, Math.round((1 - fraction) * 100)))
|
||||
return {
|
||||
id,
|
||||
name: formatBucketName(groupName, readString(raw.name), siblings),
|
||||
usedPercent,
|
||||
// Why 0 and not null: RateLimitWindow requires a number, and an unrecognised agy window still
|
||||
// carries a real remaining fraction worth showing as a named bucket.
|
||||
windowMinutes: windowMinutesFor(readString(raw.window)) ?? 0,
|
||||
resetsAt: parseResetsAt(raw.reset_time),
|
||||
resetDescription: null
|
||||
}
|
||||
}
|
||||
|
||||
function parseGroups(groups: unknown): AntigravityUsageBucket[] {
|
||||
if (!Array.isArray(groups)) {
|
||||
return []
|
||||
}
|
||||
const parsed: AntigravityUsageBucket[] = []
|
||||
for (const group of groups) {
|
||||
if (!isRecord(group)) {
|
||||
continue
|
||||
}
|
||||
const groupName = readString(group.name)
|
||||
const buckets = Array.isArray(group.buckets) ? group.buckets : []
|
||||
if (!groupName) {
|
||||
continue
|
||||
}
|
||||
const enabled = buckets.filter(
|
||||
(bucket): bucket is RawBucket => isRecord(bucket) && bucket.disabled !== true
|
||||
)
|
||||
for (const bucket of enabled) {
|
||||
const result = parseBucket(bucket, groupName, enabled.length)
|
||||
if (result) {
|
||||
parsed.push(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the usage payload out of an agy print-mode envelope.
|
||||
*
|
||||
* Returns null when the envelope is not a successful usage reply, which the caller reports as an
|
||||
* unreadable quota rather than as an empty one — "no buckets" and "agy did not answer" are
|
||||
* different states and only the first is safe to draw as 0% used.
|
||||
*/
|
||||
export function parseAntigravityUsageEnvelope(value: unknown): AntigravityUsageReading | null {
|
||||
if (!isRecord(value)) {
|
||||
return null
|
||||
}
|
||||
if (readString(value.status) !== 'SUCCESS') {
|
||||
return null
|
||||
}
|
||||
const command = value.command
|
||||
if (!isRecord(command)) {
|
||||
return null
|
||||
}
|
||||
// Why check the command name: `/usage` and `/quota` are aliases that both answer as `usage`, so
|
||||
// the name is what proves the payload is a quota reply and not some other command's data.
|
||||
if (readString(command.name) !== 'usage') {
|
||||
return null
|
||||
}
|
||||
const data = command.data
|
||||
if (!isRecord(data)) {
|
||||
return null
|
||||
}
|
||||
const buckets = parseGroups(data.groups)
|
||||
if (buckets.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Why drop the id first: the summary is a RateLimitWindow, and the summariser only strips `name`,
|
||||
// so an id left on the bucket would ride into the published window.
|
||||
const windowsOf = (minutes: number): RateLimitBucket[] =>
|
||||
buckets
|
||||
.filter((bucket) => bucket.windowMinutes === minutes)
|
||||
.map(({ id: _id, ...bucket }) => bucket)
|
||||
return {
|
||||
session: deriveMostConstrainedWindow(windowsOf(SESSION_WINDOW_MINUTES)),
|
||||
weekly: deriveMostConstrainedWindow(windowsOf(WEEKLY_WINDOW_MINUTES)),
|
||||
buckets,
|
||||
description: readString(data.description)
|
||||
}
|
||||
}
|
||||
|
||||
/** Finds the usage envelope in agy's stdout, which may carry log noise around the JSON line. */
|
||||
export function parseAntigravityUsageStdout(stdout: string): AntigravityUsageReading | null {
|
||||
for (const line of stdout.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('{')) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const reading = parseAntigravityUsageEnvelope(JSON.parse(trimmed))
|
||||
if (reading) {
|
||||
return reading
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the envelope shows agy ran a model turn instead of answering a command.
|
||||
*
|
||||
* Why this matters: in print mode an *unrecognised* slash command is not an error — agy sends the
|
||||
* text to the model as an ordinary prompt. On a build of agy that does not know `/usage`, polling
|
||||
* would quietly start a conversation and spend the user's quota every cycle while Orca reported
|
||||
* "did not report a quota". A real command reply carries an empty `conversation_id` and
|
||||
* `num_turns: 0`; a prompt carries a conversation id and at least one turn.
|
||||
*/
|
||||
export function didRunModelTurn(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false
|
||||
}
|
||||
const turns = value.num_turns
|
||||
if (typeof turns === 'number' && turns > 0) {
|
||||
return true
|
||||
}
|
||||
return readString(value.conversation_id) !== null
|
||||
}
|
||||
|
||||
/** Scans agy stdout for evidence that the quota read was answered by the model, not by a command. */
|
||||
export function stdoutShowsModelTurn(stdout: string): boolean {
|
||||
for (const line of stdout.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('{')) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (didRunModelTurn(JSON.parse(trimmed))) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
import { deriveMostConstrainedWindow } from './rate-limit-bucket-summary'
|
||||
|
||||
const MODEL_ID_TO_BUCKET_NAME: Record<string, string> = {
|
||||
'gemini-3.1-pro': '3.1 Pro',
|
||||
@@ -73,12 +74,5 @@ export function deduplicateBuckets(
|
||||
}
|
||||
|
||||
export function deriveSessionSummary(buckets: RateLimitBucket[]): RateLimitWindow | null {
|
||||
if (buckets.length === 0) {
|
||||
return null
|
||||
}
|
||||
const mostConstrained = buckets.reduce((worst, bucket) => {
|
||||
return bucket.usedPercent > worst.usedPercent ? bucket : worst
|
||||
})
|
||||
const { name: _name, ...window } = mostConstrained
|
||||
return window
|
||||
return deriveMostConstrainedWindow(buckets)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
|
||||
/**
|
||||
* Collapses named buckets into the one window a user is actually limited by.
|
||||
*
|
||||
* The most-consumed bucket is the binding constraint: a provider that reports one pool per model
|
||||
* family runs out of the whole tier when any single pool does, so the summary has to follow the
|
||||
* worst pool rather than an average.
|
||||
*/
|
||||
export function deriveMostConstrainedWindow(buckets: RateLimitBucket[]): RateLimitWindow | null {
|
||||
if (buckets.length === 0) {
|
||||
return null
|
||||
}
|
||||
const mostConstrained = buckets.reduce((worst, bucket) =>
|
||||
bucket.usedPercent > worst.usedPercent ? bucket : worst
|
||||
)
|
||||
const { name: _name, ...window } = mostConstrained
|
||||
return window
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { fetchCursorRateLimits } from './cursor-fetcher'
|
||||
import { readCursorAuthSession } from './cursor-auth'
|
||||
import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection'
|
||||
import { fetchZcodeRateLimits } from './zcode-usage-fetcher'
|
||||
import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher'
|
||||
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
|
||||
export type Deferred<T> = {
|
||||
@@ -94,6 +95,9 @@ export function mockFreshBackgroundProviderFetches(): void {
|
||||
vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok'))
|
||||
vi.mocked(fetchCursorRateLimits).mockImplementation(async () => unavailableProvider('cursor'))
|
||||
vi.mocked(fetchZcodeRateLimits).mockImplementation(async () => unavailableProvider('zcode'))
|
||||
vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () =>
|
||||
unavailableProvider('antigravity')
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared `beforeEach` body: healthy stubs for every provider the service polls. */
|
||||
@@ -113,6 +117,7 @@ export function resetRateLimitProviderMocks(): void {
|
||||
})
|
||||
vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor'))
|
||||
vi.mocked(fetchZcodeRateLimits).mockResolvedValue(unavailableProvider('zcode'))
|
||||
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity'))
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
|
||||
vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' })
|
||||
vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' })
|
||||
|
||||
@@ -36,6 +36,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
|
||||
fetchZcodeRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./antigravity-usage-fetcher', () => ({
|
||||
fetchAntigravityRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
fetchMiniMaxRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -41,6 +42,10 @@ vi.mock('./grok-fetcher', () => ({
|
||||
|
||||
vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() }))
|
||||
|
||||
vi.mock('./antigravity-usage-fetcher', () => ({
|
||||
fetchAntigravityRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./cursor-fetcher', () => ({
|
||||
fetchCursorRateLimits: vi.fn()
|
||||
}))
|
||||
@@ -62,27 +67,12 @@ describe('RateLimitService Antigravity usage', () => {
|
||||
resetRateLimitProviderMocks()
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20))
|
||||
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30))
|
||||
})
|
||||
|
||||
it('does not republish a Gemini failure as an Antigravity refresh failure', async () => {
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(
|
||||
errorProvider('gemini', 'Gemini project ID not found')
|
||||
)
|
||||
const service = new RateLimitService()
|
||||
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.antigravity?.status).toBe('unavailable')
|
||||
expect(state.antigravity?.error).not.toContain('Gemini project ID not found')
|
||||
expect(state.antigravity?.session).toBeNull()
|
||||
// Why: the real Gemini failure must still surface under its own provider.
|
||||
expect(state.gemini?.status).toBe('error')
|
||||
expect(state.gemini?.error).toBe('Gemini project ID not found')
|
||||
})
|
||||
|
||||
it('keeps mirroring a successful Gemini read under the Antigravity provider', async () => {
|
||||
it('publishes the Antigravity CLI reading, not the Gemini one', async () => {
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now()))
|
||||
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30))
|
||||
const service = new RateLimitService()
|
||||
|
||||
await service.refresh()
|
||||
@@ -90,21 +80,56 @@ describe('RateLimitService Antigravity usage', () => {
|
||||
const state = service.getState()
|
||||
expect(state.antigravity?.status).toBe('ok')
|
||||
expect(state.antigravity?.provider).toBe('antigravity')
|
||||
expect(state.antigravity?.session?.usedPercent).toBe(42)
|
||||
// Why both: the mirror made these two numbers the same value by construction.
|
||||
expect(state.antigravity?.session?.usedPercent).toBe(30)
|
||||
expect(state.gemini?.session?.usedPercent).toBe(42)
|
||||
})
|
||||
|
||||
it('never leaves a cached Antigravity snapshot in the error retry lane', async () => {
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now()))
|
||||
const service = new RateLimitService()
|
||||
await service.refresh()
|
||||
|
||||
it('keeps an Antigravity reading through a Gemini failure', async () => {
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(
|
||||
errorProvider('gemini', 'Token refresh failed')
|
||||
errorProvider('gemini', 'Gemini project ID not found')
|
||||
)
|
||||
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 55))
|
||||
const service = new RateLimitService()
|
||||
|
||||
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()
|
||||
const state = service.getState()
|
||||
// Why: the two providers no longer share a credential or an endpoint, so a Gemini
|
||||
// token problem is not evidence about Antigravity quota (#9122).
|
||||
expect(state.antigravity?.status).toBe('ok')
|
||||
expect(state.antigravity?.session?.usedPercent).toBe(55)
|
||||
expect(state.gemini?.status).toBe('error')
|
||||
expect(state.gemini?.error).toBe('Gemini project ID not found')
|
||||
})
|
||||
|
||||
it('reports an Antigravity failure without touching Gemini', async () => {
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now()))
|
||||
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(
|
||||
errorProvider('antigravity', 'The Antigravity CLI did not report a quota.')
|
||||
)
|
||||
const service = new RateLimitService()
|
||||
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.antigravity?.status).toBe('error')
|
||||
expect(state.antigravity?.session).toBeNull()
|
||||
expect(state.gemini?.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('surfaces a rejected Antigravity fetch as that provider\u2019s error', async () => {
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now()))
|
||||
vi.mocked(fetchAntigravityRateLimits).mockRejectedValue(new Error('spawn agy ENOENT'))
|
||||
const service = new RateLimitService()
|
||||
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.antigravity?.status).toBe('error')
|
||||
expect(state.antigravity?.error).toContain('spawn agy ENOENT')
|
||||
// Why: a thrown Antigravity fetch must not abort the cycle for everyone else.
|
||||
expect(state.claude?.status).toBe('ok')
|
||||
expect(state.gemini?.status).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,10 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() }))
|
||||
vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) }))
|
||||
vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() }))
|
||||
|
||||
vi.mock('./antigravity-usage-fetcher', () => ({
|
||||
fetchAntigravityRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() }))
|
||||
vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() }))
|
||||
vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) }))
|
||||
|
||||
@@ -43,6 +43,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
|
||||
fetchZcodeRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./antigravity-usage-fetcher', () => ({
|
||||
fetchAntigravityRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
fetchMiniMaxRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -40,6 +40,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
|
||||
fetchZcodeRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./antigravity-usage-fetcher', () => ({
|
||||
fetchAntigravityRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
fetchMiniMaxRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -37,6 +37,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
|
||||
fetchZcodeRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./antigravity-usage-fetcher', () => ({
|
||||
fetchAntigravityRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
fetchMiniMaxRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -45,6 +45,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
|
||||
fetchZcodeRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./antigravity-usage-fetcher', () => ({
|
||||
fetchAntigravityRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
fetchMiniMaxRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -45,6 +45,10 @@ vi.mock('./zcode-usage-fetcher', () => ({
|
||||
fetchZcodeRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./antigravity-usage-fetcher', () => ({
|
||||
fetchAntigravityRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
fetchMiniMaxRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation'
|
||||
import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror'
|
||||
import { settleSiblingProviderResult } from './service-sibling-provider-result'
|
||||
import type { ProviderRateLimits } from './service-types'
|
||||
|
||||
@@ -37,7 +36,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
|
||||
],
|
||||
grokResultPromise,
|
||||
cursorResultPromise,
|
||||
zcodeResultPromise
|
||||
zcodeResultPromise,
|
||||
antigravityResultPromise
|
||||
} = prepared
|
||||
if (signal.aborted) {
|
||||
return
|
||||
@@ -82,9 +82,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
|
||||
status: 'error'
|
||||
} satisfies ProviderRateLimits)
|
||||
|
||||
// Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure.
|
||||
const antigravity = deriveAntigravityRateLimits(gemini)
|
||||
|
||||
const opencodeGo =
|
||||
opencodeGoResult.status === 'fulfilled'
|
||||
? opencodeGoResult.value
|
||||
@@ -159,7 +156,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
|
||||
this.trackActiveFailureStreak('codex', codex)
|
||||
}
|
||||
this.trackActiveFailureStreak('gemini', gemini)
|
||||
this.trackActiveFailureStreak('antigravity', antigravity)
|
||||
if (shouldApplyOpencode) {
|
||||
this.trackActiveFailureStreak('opencode-go', opencodeGo)
|
||||
}
|
||||
@@ -186,7 +182,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
|
||||
: this.applyStalePolicy(opencodeGo, previousState.opencodeGo)
|
||||
: this.state.opencodeGo,
|
||||
kimi: this.applyStalePolicy(kimi, previousState.kimi),
|
||||
antigravity: this.applyStalePolicy(antigravity, previousState.antigravity),
|
||||
minimax: shouldApplyMiniMax
|
||||
? miniMaxConfigChanged
|
||||
? miniMax
|
||||
@@ -194,10 +189,11 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
|
||||
: this.state.minimax
|
||||
})
|
||||
|
||||
const [grokSettled, cursorSettled, zcodeSettled] = await Promise.all([
|
||||
const [grokSettled, cursorSettled, zcodeSettled, antigravitySettled] = await Promise.all([
|
||||
grokResultPromise,
|
||||
cursorResultPromise,
|
||||
zcodeResultPromise
|
||||
zcodeResultPromise,
|
||||
antigravityResultPromise
|
||||
])
|
||||
if (signal.aborted) {
|
||||
return
|
||||
@@ -205,6 +201,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
|
||||
const grok = settleSiblingProviderResult('grok', grokSettled)
|
||||
const cursor = settleSiblingProviderResult('cursor', cursorSettled)
|
||||
const zcode = settleSiblingProviderResult('zcode', zcodeSettled)
|
||||
const antigravity = settleSiblingProviderResult('antigravity', antigravitySettled)
|
||||
// Why: the stale policy keeps a recent snapshot through a failed refresh, but
|
||||
// a snapshot belonging to a different Cursor account must not survive the
|
||||
// switch — the Accounts pane would name the new account beside the old
|
||||
@@ -225,6 +222,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
|
||||
this.trackActiveFailureStreak('grok', grok)
|
||||
this.trackActiveFailureStreak('cursor', cursor)
|
||||
this.trackActiveFailureStreak('zcode', zcode)
|
||||
this.trackActiveFailureStreak('antigravity', antigravity)
|
||||
this.updateState({
|
||||
...this.state,
|
||||
grok: this.applyStalePolicy(grok, previousState.grok),
|
||||
@@ -232,7 +230,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
|
||||
zcode:
|
||||
zcode.status === 'error' && !sameZcodeAccount
|
||||
? zcode
|
||||
: this.applyStalePolicy(zcode, previousState.zcode)
|
||||
: this.applyStalePolicy(zcode, previousState.zcode),
|
||||
antigravity: this.applyStalePolicy(antigravity, previousState.antigravity)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readGrokAuthSession } from '../grok-auth'
|
||||
import { fetchCursorRateLimits } from '../cursor-fetcher'
|
||||
import { readCursorAuthSession } from '../cursor-auth'
|
||||
import { fetchZcodeRateLimits } from '../zcode-usage-fetcher'
|
||||
import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher'
|
||||
import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection'
|
||||
@@ -46,6 +47,7 @@ export type FetchAllCyclePrepared = {
|
||||
grokResultPromise: Promise<SettledProviderResult>
|
||||
cursorResultPromise: Promise<SettledProviderResult>
|
||||
zcodeResultPromise: Promise<SettledProviderResult>
|
||||
antigravityResultPromise: Promise<SettledProviderResult>
|
||||
}
|
||||
|
||||
export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy {
|
||||
@@ -153,6 +155,14 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
|
||||
(reason) => ({ status: 'rejected', reason }) as const
|
||||
)
|
||||
|
||||
// Why its own promise: the Antigravity read spawns `agy` and waits ~2.5 s for the CLI to start
|
||||
// its language server and refresh the quota. Inside the awaited tuple that latency would be
|
||||
// added to every other provider's cycle.
|
||||
const antigravityResultPromise = fetchAntigravityRateLimits({ signal }).then(
|
||||
(value) => ({ status: 'fulfilled', value }) as const,
|
||||
(reason) => ({ status: 'rejected', reason }) as const
|
||||
)
|
||||
|
||||
const missingWslCodexHome =
|
||||
codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget)
|
||||
const grokResultPromise = fetchGrokRateLimits({
|
||||
@@ -239,7 +249,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
|
||||
],
|
||||
grokResultPromise,
|
||||
cursorResultPromise,
|
||||
zcodeResultPromise
|
||||
zcodeResultPromise,
|
||||
antigravityResultPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user