[ORCHESTRATOR TEST] feat(auth): multi-account Google Antigravity auth with automatic rotation (#579)
* feat(auth): add multi-account types and storage layer Add foundation for multi-account Google Antigravity auth: - ModelFamily, AccountTier, RateLimitState types for rate limit tracking - AccountMetadata, AccountStorage, ManagedAccount interfaces - Cross-platform storage module with XDG_DATA_HOME/APPDATA support - Comprehensive test coverage for storage operations 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(auth): implement AccountManager for multi-account rotation Add AccountManager class with automatic account rotation: - Per-family rate limit tracking (claude, gemini-flash, gemini-pro) - Paid tier prioritization in rotation logic - Round-robin account selection within tier pools - Account add/remove operations with index management - Storage persistence integration 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(auth): add CLI prompts for multi-account setup Add @clack/prompts-based CLI utilities: - promptAddAnotherAccount() for multi-account flow - promptAccountTier() for free/paid tier selection - Non-TTY environment handling (graceful skip) 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(auth): integrate multi-account OAuth flow into plugin Enhance OAuth flow for multi-account support: - Prompt for additional accounts after first OAuth (up to 10) - Collect email and tier for each account - Save accounts to storage via AccountManager - Load AccountManager in loader() from stored accounts - Toast notifications for account authentication success - Backward compatible with single-account flow 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(auth): add rate limit rotation to fetch interceptor Integrate AccountManager into fetch for automatic rotation: - Model family detection from URL (claude/gemini-flash/gemini-pro) - Rate limit detection (429 with retry-after > 5s, 5xx errors) - Mark rate-limited accounts and rotate to next available - Recursive retry with new account on rotation - Lazy load accounts from storage on first request - Debug logging for account switches 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(cli): add auth account management commands Add CLI commands for managing Google Antigravity accounts: - `auth list`: Show all accounts with email, tier, rate limit status - `auth remove <index|email>`: Remove account by index or email - Help text with usage examples - Active account indicator and remaining rate limit display 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * refactor(auth): address review feedback - remove duplicate ManagedAccount and reuse fetch function - Remove unused ManagedAccount interface from types.ts (duplicate of accounts.ts) - Reuse fetchFn in rate limit retry instead of creating new fetch closure Preserves cachedTokens, cachedProjectId, fetchInstanceId, accountsLoaded state * fix(auth): address Cubic review feedback (8 issues) P1 fixes: - storage.ts: Use mode 0o600 for OAuth credentials file (security) - fetch.ts: Return original 5xx status instead of synthesized 429 - accounts.ts: Adjust activeIndex/currentIndex in removeAccount - plugin.ts: Fix multi-account migration to split on ||| not | P2 fixes: - cli.ts: Remove confusing cancel message when returning default - auth.ts: Use strict parseInt check to prevent partial matches - storage.test.ts: Use try/finally for env var cleanup * refactor(test): import ManagedAccount from accounts.ts instead of duplicating * fix(auth): address Oracle review findings (P1/P2) P1 fixes: - Clear cachedProjectId on account change to prevent stale project IDs - Continue endpoint fallback for single-account users on rate limit - Restore access/expires tokens from storage for non-active accounts - Re-throw non-ENOENT filesystem errors (keep returning null for parse errors) - Use atomic write (temp file + rename) for account storage P2 fixes: - Derive RateLimitState type from ModelFamily using mapped type - Add MODEL_FAMILIES constant and use dynamic iteration in clearExpiredRateLimits - Add missing else branch in storage.test.ts env cleanup - Handle open() errors gracefully with user-friendly toast message Tests updated to reflect correct behavior for token restoration. * fix(auth): address Cubic review round 2 (5 issues) P1: Return original 429/5xx response on last endpoint instead of generic 503 P2: Use unique temp filename (pid+timestamp) and cleanup on rename failure P2: Clear cachedProjectId when first account introduced (lastAccountIndex null) P3: Add console.error logging to open() catch block * test(auth): add AccountManager removeAccount index tests Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * test(auth): add storage layer security and atomicity tests Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix(auth): address Cubic review round 3 (4 issues) P1 Fixes: - plugin.ts: Validate refresh_token before constructing first account - plugin.ts: Validate additionalTokens.refresh_token before pushing accounts - fetch.ts: Reset cachedTokens when switching accounts during rotation P2 Fixes: - fetch.ts: Improve model-family detection (parse model from body, fallback to URL) * fix(auth): address Cubic review round 4 (3 issues) P1 Fixes: - plugin.ts: Close serverHandle before early return on missing refresh_token - plugin.ts: Close additionalServerHandle before continue on missing refresh_token P2 Fixes: - fetch.ts: Remove overly broad 'pro' matching in getModelFamilyFromModelName * fix(auth): address Cubic review round 5 (9 issues) P1 Fixes: - plugin.ts: Close additionalServerHandle after successful account auth - fetch.ts: Cancel response body on 429/5xx to prevent connection leaks P2 Fixes: - plugin.ts: Close additionalServerHandle on OAuth error/missing code - plugin.ts: Close additionalServerHandle on verifier mismatch - auth.ts: Set activeIndex to -1 when all accounts removed - storage.ts: Use shared getDataDir utility for consistent paths - fetch.ts: Catch loadAccounts IO errors with graceful fallback - storage.test.ts: Improve test assertions with proper error tracking * feat(antigravity): add system prompt and thinking config constants * feat(antigravity): add reasoning_effort and Gemini 3 thinkingLevel support * feat(antigravity): inject system prompt into all requests * feat(antigravity): integrate thinking config and system prompt in fetch layer * feat(auth): auto-open browser for OAuth login on all platforms * fix(auth): add alias2ModelName for Antigravity Claude models Root cause: Antigravity API expects 'claude-sonnet-4-5-thinking' but we were sending 'gemini-claude-sonnet-4-5-thinking'. Ported alias mapping from CLIProxyAPI antigravity_executor.go:1328-1347. Transforms: - gemini-claude-sonnet-4-5-thinking → claude-sonnet-4-5-thinking - gemini-claude-opus-4-5-thinking → claude-opus-4-5-thinking - gemini-3-pro-preview → gemini-3-pro-high - gemini-3-flash-preview → gemini-3-flash * fix(auth): add requestType and toolConfig for Antigravity API Missing required fields from CLIProxyAPI implementation: - requestType: 'agent' - request.toolConfig.functionCallingConfig.mode: 'VALIDATED' - Delete request.safetySettings Also strip 'antigravity-' prefix before alias transformation. * fix(auth): remove broken alias2ModelName transformations for Gemini 3 CLIProxyAPI's alias mappings don't work with public Antigravity API: - gemini-3-pro-preview → gemini-3-pro-high (404!) - gemini-3-flash-preview → gemini-3-flash (404!) Tested: -preview suffix names work, transformed names return 404. Keep only gemini-claude-* prefix stripping for future Claude support. * fix(auth): implement correct alias2ModelName transformations for Antigravity API Implements explicit switch-based model name mappings for Antigravity API. Updates SANDBOX endpoint constants to clarify quota/availability behavior. Fixes test expectations to match new transformation logic. 🤖 Generated with assistance of OhMyOpenCode --------- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -20,6 +20,9 @@
|
||||
import { ANTIGRAVITY_ENDPOINT_FALLBACKS } from "./constants"
|
||||
import { fetchProjectContext, clearProjectContextCache, invalidateProjectContextByRefreshToken } from "./project"
|
||||
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage, AntigravityTokenRefreshError } from "./token"
|
||||
import { AccountManager, type ManagedAccount } from "./accounts"
|
||||
import { loadAccounts } from "./storage"
|
||||
import type { ModelFamily } from "./types"
|
||||
import { transformRequest } from "./request"
|
||||
import { convertRequestBody, hasOpenAIMessages } from "./message-converter"
|
||||
import {
|
||||
@@ -28,7 +31,7 @@ import {
|
||||
isStreamingResponse,
|
||||
} from "./response"
|
||||
import { normalizeToolsForGemini, type OpenAITool } from "./tools"
|
||||
import { extractThinkingBlocks, shouldIncludeThinking, transformResponseThinking } from "./thinking"
|
||||
import { extractThinkingBlocks, shouldIncludeThinking, transformResponseThinking, extractThinkingConfig, applyThinkingConfigToRequest } from "./thinking"
|
||||
import {
|
||||
getThoughtSignature,
|
||||
setThoughtSignature,
|
||||
@@ -69,6 +72,33 @@ function isRetryableError(status: number): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function getModelFamilyFromModelName(modelName: string): ModelFamily | null {
|
||||
const lower = modelName.toLowerCase()
|
||||
if (lower.includes("claude") || lower.includes("anthropic")) return "claude"
|
||||
if (lower.includes("flash")) return "gemini-flash"
|
||||
if (lower.includes("gemini")) return "gemini-pro"
|
||||
return null
|
||||
}
|
||||
|
||||
function getModelFamilyFromUrl(url: string): ModelFamily {
|
||||
if (url.includes("claude")) return "claude"
|
||||
if (url.includes("flash")) return "gemini-flash"
|
||||
return "gemini-pro"
|
||||
}
|
||||
|
||||
function getModelFamily(url: string, init?: RequestInit): ModelFamily {
|
||||
if (init?.body && typeof init.body === "string") {
|
||||
try {
|
||||
const body = JSON.parse(init.body) as Record<string, unknown>
|
||||
if (typeof body.model === "string") {
|
||||
const fromModel = getModelFamilyFromModelName(body.model)
|
||||
if (fromModel) return fromModel
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return getModelFamilyFromUrl(url)
|
||||
}
|
||||
|
||||
const GCP_PERMISSION_ERROR_PATTERNS = [
|
||||
"PERMISSION_DENIED",
|
||||
"does not have permission",
|
||||
@@ -109,7 +139,13 @@ interface AttemptFetchOptions {
|
||||
thoughtSignature?: string
|
||||
}
|
||||
|
||||
type AttemptFetchResult = Response | null | "pass-through" | "needs-refresh"
|
||||
interface RateLimitInfo {
|
||||
type: "rate-limited"
|
||||
retryAfterMs: number
|
||||
status: number
|
||||
}
|
||||
|
||||
type AttemptFetchResult = Response | null | "pass-through" | "needs-refresh" | RateLimitInfo
|
||||
|
||||
async function attemptFetch(
|
||||
options: AttemptFetchOptions
|
||||
@@ -169,6 +205,23 @@ async function attemptFetch(
|
||||
thoughtSignature,
|
||||
})
|
||||
|
||||
// Apply thinking config from reasoning_effort (from think-mode hook)
|
||||
const effectiveModel = modelName || transformed.body.model
|
||||
const thinkingConfig = extractThinkingConfig(
|
||||
parsedBody,
|
||||
parsedBody.generationConfig as Record<string, unknown> | undefined,
|
||||
parsedBody,
|
||||
)
|
||||
if (thinkingConfig) {
|
||||
debugLog(`[THINKING] Applying thinking config for model: ${effectiveModel}`)
|
||||
applyThinkingConfigToRequest(
|
||||
transformed.body as unknown as Record<string, unknown>,
|
||||
effectiveModel,
|
||||
thinkingConfig,
|
||||
)
|
||||
debugLog(`[THINKING] Thinking config applied successfully`)
|
||||
}
|
||||
|
||||
debugLog(`[REQ] streaming=${transformed.streaming}, url=${transformed.url}`)
|
||||
|
||||
const maxPermissionRetries = 10
|
||||
@@ -204,6 +257,31 @@ async function attemptFetch(
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get("retry-after")
|
||||
let retryAfterMs = 60000
|
||||
if (retryAfter) {
|
||||
const parsed = parseInt(retryAfter, 10)
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
retryAfterMs = parsed * 1000
|
||||
} else {
|
||||
const httpDate = Date.parse(retryAfter)
|
||||
if (!isNaN(httpDate)) {
|
||||
retryAfterMs = Math.max(0, httpDate - Date.now())
|
||||
}
|
||||
}
|
||||
}
|
||||
debugLog(`[429] Rate limited, retry-after: ${retryAfterMs}ms`)
|
||||
await response.body?.cancel()
|
||||
return { type: "rate-limited" as const, retryAfterMs, status: 429 }
|
||||
}
|
||||
|
||||
if (response.status >= 500 && response.status < 600) {
|
||||
debugLog(`[5xx] Server error ${response.status}, marking for rotation`)
|
||||
await response.body?.cancel()
|
||||
return { type: "rate-limited" as const, retryAfterMs: 300000, status: response.status }
|
||||
}
|
||||
|
||||
if (!response.ok && (await isRetryableResponse(response))) {
|
||||
debugLog(`Endpoint failed: ${endpoint} (status: ${response.status}), trying next`)
|
||||
return null
|
||||
@@ -350,13 +428,17 @@ export function createAntigravityFetch(
|
||||
client: AuthClient,
|
||||
providerId: string,
|
||||
clientId?: string,
|
||||
clientSecret?: string
|
||||
clientSecret?: string,
|
||||
accountManager?: AccountManager | null
|
||||
): (url: string, init?: RequestInit) => Promise<Response> {
|
||||
let cachedTokens: AntigravityTokens | null = null
|
||||
let cachedProjectId: string | null = null
|
||||
let lastAccountIndex: number | null = null
|
||||
const fetchInstanceId = crypto.randomUUID()
|
||||
let manager: AccountManager | null = accountManager || null
|
||||
let accountsLoaded = false
|
||||
|
||||
return async (url: string, init: RequestInit = {}): Promise<Response> => {
|
||||
const fetchFn = async (url: string, init: RequestInit = {}): Promise<Response> => {
|
||||
debugLog(`Intercepting request to: ${url}`)
|
||||
|
||||
// Get current auth state
|
||||
@@ -366,7 +448,55 @@ export function createAntigravityFetch(
|
||||
}
|
||||
|
||||
// Parse stored token format
|
||||
const refreshParts = parseStoredToken(auth.refresh)
|
||||
let refreshParts = parseStoredToken(auth.refresh)
|
||||
|
||||
if (!accountsLoaded && !manager && auth.refresh) {
|
||||
try {
|
||||
const storedAccounts = await loadAccounts()
|
||||
if (storedAccounts) {
|
||||
manager = new AccountManager(
|
||||
{ refresh: auth.refresh, access: auth.access || "", expires: auth.expires || 0 },
|
||||
storedAccounts
|
||||
)
|
||||
debugLog(`[ACCOUNTS] Loaded ${manager.getAccountCount()} accounts from storage`)
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog(`[ACCOUNTS] Failed to load accounts, falling back to single-account: ${error instanceof Error ? error.message : "Unknown"}`)
|
||||
}
|
||||
accountsLoaded = true
|
||||
}
|
||||
|
||||
let currentAccount: ManagedAccount | null = null
|
||||
if (manager) {
|
||||
const family = getModelFamily(url, init)
|
||||
currentAccount = manager.getCurrentOrNextForFamily(family)
|
||||
|
||||
if (currentAccount) {
|
||||
debugLog(`[ACCOUNTS] Using account ${currentAccount.index + 1}/${manager.getAccountCount()} for ${family}`)
|
||||
|
||||
if (lastAccountIndex === null || lastAccountIndex !== currentAccount.index) {
|
||||
if (lastAccountIndex !== null) {
|
||||
debugLog(`[ACCOUNTS] Account changed from ${lastAccountIndex + 1} to ${currentAccount.index + 1}, clearing cached state`)
|
||||
} else if (cachedProjectId) {
|
||||
debugLog(`[ACCOUNTS] First account introduced, clearing cached state`)
|
||||
}
|
||||
cachedProjectId = null
|
||||
cachedTokens = null
|
||||
}
|
||||
lastAccountIndex = currentAccount.index
|
||||
|
||||
if (currentAccount.access && currentAccount.expires) {
|
||||
auth.access = currentAccount.access
|
||||
auth.expires = currentAccount.expires
|
||||
}
|
||||
|
||||
refreshParts = {
|
||||
refreshToken: currentAccount.parts.refreshToken,
|
||||
projectId: currentAccount.parts.projectId,
|
||||
managedProjectId: currentAccount.parts.managedProjectId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build initial token state
|
||||
if (!cachedTokens) {
|
||||
@@ -581,7 +711,52 @@ export function createAntigravityFetch(
|
||||
}
|
||||
}
|
||||
|
||||
if (response) {
|
||||
if (response && typeof response === "object" && "type" in response && response.type === "rate-limited") {
|
||||
const rateLimitInfo = response as RateLimitInfo
|
||||
const family = getModelFamily(url, init)
|
||||
|
||||
if (rateLimitInfo.retryAfterMs > 5000 && manager && currentAccount) {
|
||||
manager.markRateLimited(currentAccount, rateLimitInfo.retryAfterMs, family)
|
||||
await manager.save()
|
||||
debugLog(`[RATE-LIMIT] Account ${currentAccount.index + 1} rate-limited for ${family}, rotating...`)
|
||||
|
||||
const nextAccount = manager.getCurrentOrNextForFamily(family)
|
||||
if (nextAccount && nextAccount.index !== currentAccount.index) {
|
||||
debugLog(`[RATE-LIMIT] Switched to account ${nextAccount.index + 1}`)
|
||||
return fetchFn(url, init)
|
||||
}
|
||||
}
|
||||
|
||||
const isLastEndpoint = i === maxEndpoints - 1
|
||||
if (isLastEndpoint) {
|
||||
const isServerError = rateLimitInfo.status >= 500
|
||||
debugLog(`[RATE-LIMIT] No alternative account or endpoint, returning ${rateLimitInfo.status}`)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: isServerError
|
||||
? `Server error (${rateLimitInfo.status}). Retry after ${Math.ceil(rateLimitInfo.retryAfterMs / 1000)} seconds`
|
||||
: `Rate limited. Retry after ${Math.ceil(rateLimitInfo.retryAfterMs / 1000)} seconds`,
|
||||
type: isServerError ? "server_error" : "rate_limit",
|
||||
code: isServerError ? "server_error" : "rate_limited",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: rateLimitInfo.status,
|
||||
statusText: isServerError ? "Server Error" : "Too Many Requests",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Retry-After": String(Math.ceil(rateLimitInfo.retryAfterMs / 1000)),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
debugLog(`[RATE-LIMIT] No alternative account available, trying next endpoint`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (response && response instanceof Response) {
|
||||
debugLog(`Success with endpoint: ${endpoint}`)
|
||||
const transformedResponse = await transformResponseWithThinking(
|
||||
response,
|
||||
@@ -613,6 +788,8 @@ export function createAntigravityFetch(
|
||||
|
||||
return executeWithEndpoints()
|
||||
}
|
||||
|
||||
return fetchFn
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user