Skip to content
Merged
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
9 changes: 9 additions & 0 deletions src/main/rate-limits/antigravity-usage-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ export const ANTIGRAVITY_USAGE_ARGS: readonly string[] = [
*/
export const ANTIGRAVITY_USAGE_TIMEOUT_MS = 30_000

// Before agy 1.1.11, `/usage` in print mode spends a model turn (agy's bundled changelog).
export const ANTIGRAVITY_MIN_USAGE_VERSION = '1.1.11'

/** The version probe; free in every sense — no conversation, no quota. */
export const ANTIGRAVITY_VERSION_ARGS: readonly string[] = ['--version']

/** Why bound it: the probe runs before every quota read, so a wedged CLI must not stall the cycle. */
export const ANTIGRAVITY_VERSION_TIMEOUT_MS = 5_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

Expand Down
78 changes: 78 additions & 0 deletions src/main/rate-limits/antigravity-usage-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { classifyAntigravityUsageFailure } from './antigravity-usage-error'

/** Captured verbatim from agy 1.2.11 when the account had exhausted its weekly pool. */
const REAL_RESOURCE_EXHAUSTED =
'error: Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.\n' +
'AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","retryable":true,"error_id":"6baa4ce9-c373-4086-bb0d-fbb8421bacf3-1"}'

function agyError(fields: Record<string, unknown>): string {
return `AGY_ERROR: ${JSON.stringify(fields)}`
}

describe('classifyAntigravityUsageFailure', () => {
it('reads the real RESOURCE_EXHAUSTED diagnostic as rate limiting, not as a sign-out', () => {
const failure = classifyAntigravityUsageFailure(REAL_RESOURCE_EXHAUSTED)

expect(failure).toEqual({ failureKind: 'rate-limited', signedOut: false })
})

it.each([
['status', { status: 'UNAUTHENTICATED' }],
['http code', { error_code: 401 }]
])('reads an unauthenticated %s as a missing sign-in', (_label, fields) => {
expect(classifyAntigravityUsageFailure(agyError(fields))).toEqual({
failureKind: 'missing-credentials',
signedOut: true
})
})

it('separates an unentitled account from a signed-out one', () => {
// Why this matters: telling a signed-in user to sign in sends them to re-auth that fixes
// nothing.
expect(classifyAntigravityUsageFailure(agyError({ status: 'PERMISSION_DENIED' }))).toEqual({
failureKind: 'no-subscription',
signedOut: false
})
})

it('reads a 5xx as a server failure', () => {
expect(classifyAntigravityUsageFailure(agyError({ error_code: 503 }))).toEqual({
failureKind: 'server',
signedOut: false
})
})

it.each([
'You are not logged into Antigravity.',
'Error: not signed in. Run `agy` to continue.',
'please sign in to continue',
'AUTHENTICATION REQUIRED'
])('falls back to the sentence when no structured error is printed: %s', (output) => {
// Why a fallback at all: the structured line is the stable signal, but not every path prints
// one, and a signed-out account must never be reported as an unreadable payload.
expect(classifyAntigravityUsageFailure(output)).toEqual({
failureKind: 'missing-credentials',
signedOut: true
})
})

it('prefers the structured status over a sentence that disagrees with it', () => {
const output = `not logged in\n${agyError({ status: 'RESOURCE_EXHAUSTED' })}`

expect(classifyAntigravityUsageFailure(output)).toEqual({
failureKind: 'rate-limited',
signedOut: false
})
})

it.each([
['a successful read', '{"status":"SUCCESS","command":{"name":"usage"}}'],
['unrelated noise', 'I0926 quota_manager.go:45] doRefreshQuota: starting reload'],
['an unparsable structured line', 'AGY_ERROR: {not json'],
['an unrecognised status', agyError({ status: 'SOMETHING_NEW' })],
['empty output', '']
])('returns null for %s rather than inventing a cause', (_label, output) => {
expect(classifyAntigravityUsageFailure(output)).toBeNull()
})
})
83 changes: 83 additions & 0 deletions src/main/rate-limits/antigravity-usage-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { UsageRateLimitFailureKind } from '../../shared/rate-limit-types'

const AGY_ERROR_PREFIX = 'AGY_ERROR:'

const SIGNED_OUT_PHRASES = [
'not logged into antigravity',
'not logged in',
'not signed in',
'not authenticated',
'not-authenticated',
'unauthenticated',
'run agy login',
'please sign in',
'please log in',
'sign in to antigravity',
'no credentials',
'authentication required'
] as const

export type AntigravityUsageFailure = {
failureKind: UsageRateLimitFailureKind
/** Missing authentication requires sign-in guidance. */
signedOut: boolean
}

type StructuredAgyError = { status?: unknown; error_code?: unknown }

function parseStructuredErrors(output: string): StructuredAgyError[] {
const parsed: StructuredAgyError[] = []
for (const line of output.split('\n')) {
const index = line.indexOf(AGY_ERROR_PREFIX)
if (index === -1) {
continue
}
const payload = line.slice(index + AGY_ERROR_PREFIX.length).trim()
if (!payload.startsWith('{')) {
continue
}
try {
const value: unknown = JSON.parse(payload)
if (typeof value === 'object' && value !== null) {
parsed.push(value)
}
} catch {
continue
}
}
return parsed
}

function classifyStructured(error: StructuredAgyError): AntigravityUsageFailure | null {
const status = typeof error.status === 'string' ? error.status.toUpperCase() : ''
const code = typeof error.error_code === 'number' ? error.error_code : null
if (status === 'UNAUTHENTICATED' || code === 401) {
return { failureKind: 'missing-credentials', signedOut: true }
}
if (status === 'PERMISSION_DENIED' || code === 403) {
// The session is real; the account simply is not entitled to what was asked for.
return { failureKind: 'no-subscription', signedOut: false }
}
if (status === 'RESOURCE_EXHAUSTED' || code === 429) {
return { failureKind: 'rate-limited', signedOut: false }
}
if (code !== null && code >= 500 && code < 600) {
return { failureKind: 'server', signedOut: false }
}
return null
}

// Structured errors outrank prose; an unknown diagnostic supplies no failure cause.
export function classifyAntigravityUsageFailure(output: string): AntigravityUsageFailure | null {
for (const error of parseStructuredErrors(output)) {
const classified = classifyStructured(error)
if (classified) {
return classified
}
}
const lowered = output.toLowerCase()
if (SIGNED_OUT_PHRASES.some((phrase) => lowered.includes(phrase))) {
return { failureKind: 'missing-credentials', signedOut: true }
}
return null
}
Loading
Loading