8394926fe1
* 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>
379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
/**
|
|
* Antigravity request transformer.
|
|
* Transforms OpenAI-format requests to Antigravity format.
|
|
* Does NOT handle tool normalization (handled by tools.ts in Task 9).
|
|
*/
|
|
|
|
import {
|
|
ANTIGRAVITY_API_VERSION,
|
|
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
|
ANTIGRAVITY_HEADERS,
|
|
ANTIGRAVITY_SYSTEM_PROMPT,
|
|
SKIP_THOUGHT_SIGNATURE_VALIDATOR,
|
|
alias2ModelName,
|
|
} from "./constants"
|
|
import type { AntigravityRequestBody } from "./types"
|
|
|
|
/**
|
|
* Result of request transformation including URL, headers, and body.
|
|
*/
|
|
export interface TransformedRequest {
|
|
/** Transformed URL for Antigravity API */
|
|
url: string
|
|
/** Request headers including Authorization and Antigravity-specific headers */
|
|
headers: Record<string, string>
|
|
/** Transformed request body in Antigravity format */
|
|
body: AntigravityRequestBody
|
|
/** Whether this is a streaming request */
|
|
streaming: boolean
|
|
}
|
|
|
|
/**
|
|
* Build Antigravity-specific request headers.
|
|
* Includes Authorization, User-Agent, X-Goog-Api-Client, and Client-Metadata.
|
|
*
|
|
* @param accessToken - OAuth access token for Authorization header
|
|
* @returns Headers object with all required Antigravity headers
|
|
*/
|
|
export function buildRequestHeaders(accessToken: string): Record<string, string> {
|
|
return {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"Content-Type": "application/json",
|
|
"User-Agent": ANTIGRAVITY_HEADERS["User-Agent"],
|
|
"X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"],
|
|
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract model name from request body.
|
|
* OpenAI-format requests include model in the body.
|
|
*
|
|
* @param body - Request body that may contain a model field
|
|
* @returns Model name or undefined if not found
|
|
*/
|
|
export function extractModelFromBody(
|
|
body: Record<string, unknown>
|
|
): string | undefined {
|
|
const model = body.model
|
|
if (typeof model === "string" && model.trim()) {
|
|
return model.trim()
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Extract model name from URL path.
|
|
* Handles Google Generative Language API format: /models/{model}:{action}
|
|
*
|
|
* @param url - Request URL to parse
|
|
* @returns Model name or undefined if not found
|
|
*/
|
|
export function extractModelFromUrl(url: string): string | undefined {
|
|
// Match Google's API format: /models/gemini-3-pro:generateContent
|
|
const match = url.match(/\/models\/([^:]+):/)
|
|
if (match && match[1]) {
|
|
return match[1]
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Determine the action type from the URL path.
|
|
* E.g., generateContent, streamGenerateContent
|
|
*
|
|
* @param url - Request URL to parse
|
|
* @returns Action name or undefined if not found
|
|
*/
|
|
export function extractActionFromUrl(url: string): string | undefined {
|
|
// Match Google's API format: /models/gemini-3-pro:generateContent
|
|
const match = url.match(/\/models\/[^:]+:(\w+)/)
|
|
if (match && match[1]) {
|
|
return match[1]
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Check if a URL is targeting Google's Generative Language API.
|
|
*
|
|
* @param url - URL to check
|
|
* @returns true if this is a Google Generative Language API request
|
|
*/
|
|
export function isGenerativeLanguageRequest(url: string): boolean {
|
|
return url.includes("generativelanguage.googleapis.com")
|
|
}
|
|
|
|
/**
|
|
* Build Antigravity API URL for the given action.
|
|
*
|
|
* @param baseEndpoint - Base Antigravity endpoint URL (from fallbacks)
|
|
* @param action - API action (e.g., generateContent, streamGenerateContent)
|
|
* @param streaming - Whether to append SSE query parameter
|
|
* @returns Formatted Antigravity API URL
|
|
*/
|
|
export function buildAntigravityUrl(
|
|
baseEndpoint: string,
|
|
action: string,
|
|
streaming: boolean
|
|
): string {
|
|
const query = streaming ? "?alt=sse" : ""
|
|
return `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:${action}${query}`
|
|
}
|
|
|
|
/**
|
|
* Get the first available Antigravity endpoint.
|
|
* Can be used with fallback logic in fetch.ts.
|
|
*
|
|
* @returns Default (first) Antigravity endpoint
|
|
*/
|
|
export function getDefaultEndpoint(): string {
|
|
return ANTIGRAVITY_ENDPOINT_FALLBACKS[0]
|
|
}
|
|
|
|
function generateRequestId(): string {
|
|
return `agent-${crypto.randomUUID()}`
|
|
}
|
|
|
|
/**
|
|
* Inject ANTIGRAVITY_SYSTEM_PROMPT into request.systemInstruction.
|
|
* Prepends Antigravity prompt before any existing systemInstruction.
|
|
* Prevents duplicate injection by checking for <identity> marker.
|
|
*
|
|
* CRITICAL: Modifies wrappedBody.request.systemInstruction (NOT outer body!)
|
|
*
|
|
* @param wrappedBody - The wrapped request body with request field
|
|
*/
|
|
export function injectSystemPrompt(wrappedBody: { request?: unknown }): void {
|
|
if (!wrappedBody.request || typeof wrappedBody.request !== "object") {
|
|
return
|
|
}
|
|
|
|
const req = wrappedBody.request as Record<string, unknown>
|
|
|
|
// Check for duplicate injection - if <identity> marker exists in first part, skip
|
|
if (req.systemInstruction && typeof req.systemInstruction === "object") {
|
|
const existing = req.systemInstruction as Record<string, unknown>
|
|
if (existing.parts && Array.isArray(existing.parts)) {
|
|
const firstPart = existing.parts[0]
|
|
if (firstPart && typeof firstPart === "object" && "text" in firstPart) {
|
|
const text = (firstPart as { text: string }).text
|
|
if (text.includes("<identity>")) {
|
|
return // Already injected, skip
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build new parts array - Antigravity prompt first, then existing parts
|
|
const newParts: Array<{ text: string }> = [{ text: ANTIGRAVITY_SYSTEM_PROMPT }]
|
|
|
|
// Prepend existing parts if systemInstruction exists with parts
|
|
if (req.systemInstruction && typeof req.systemInstruction === "object") {
|
|
const existing = req.systemInstruction as Record<string, unknown>
|
|
if (existing.parts && Array.isArray(existing.parts)) {
|
|
for (const part of existing.parts) {
|
|
if (part && typeof part === "object" && "text" in part) {
|
|
newParts.push(part as { text: string })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set the new systemInstruction
|
|
req.systemInstruction = {
|
|
role: "user",
|
|
parts: newParts,
|
|
}
|
|
}
|
|
|
|
export function wrapRequestBody(
|
|
body: Record<string, unknown>,
|
|
projectId: string,
|
|
modelName: string,
|
|
sessionId: string
|
|
): AntigravityRequestBody {
|
|
const requestPayload = { ...body }
|
|
delete requestPayload.model
|
|
|
|
let normalizedModel = modelName
|
|
if (normalizedModel.startsWith("antigravity-")) {
|
|
normalizedModel = normalizedModel.substring("antigravity-".length)
|
|
}
|
|
const apiModel = alias2ModelName(normalizedModel)
|
|
debugLog(`[MODEL] input="${modelName}" → normalized="${normalizedModel}" → api="${apiModel}"`)
|
|
|
|
const requestObj = {
|
|
...requestPayload,
|
|
sessionId,
|
|
toolConfig: {
|
|
...(requestPayload.toolConfig as Record<string, unknown> || {}),
|
|
functionCallingConfig: {
|
|
mode: "VALIDATED",
|
|
},
|
|
},
|
|
}
|
|
delete (requestObj as Record<string, unknown>).safetySettings
|
|
|
|
const wrappedBody: AntigravityRequestBody = {
|
|
project: projectId,
|
|
model: apiModel,
|
|
userAgent: "antigravity",
|
|
requestType: "agent",
|
|
requestId: generateRequestId(),
|
|
request: requestObj,
|
|
}
|
|
|
|
injectSystemPrompt(wrappedBody)
|
|
|
|
return wrappedBody
|
|
}
|
|
|
|
interface ContentPart {
|
|
functionCall?: Record<string, unknown>
|
|
thoughtSignature?: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
interface ContentBlock {
|
|
role?: string
|
|
parts?: ContentPart[]
|
|
[key: string]: unknown
|
|
}
|
|
|
|
function debugLog(message: string): void {
|
|
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
|
console.log(`[antigravity-request] ${message}`)
|
|
}
|
|
}
|
|
|
|
export function injectThoughtSignatureIntoFunctionCalls(
|
|
body: Record<string, unknown>,
|
|
signature: string | undefined
|
|
): Record<string, unknown> {
|
|
// Always use skip validator as fallback (CLIProxyAPI approach)
|
|
const effectiveSignature = signature || SKIP_THOUGHT_SIGNATURE_VALIDATOR
|
|
debugLog(`[TSIG][INJECT] signature=${effectiveSignature.substring(0, 30)}... (${signature ? "provided" : "default"})`)
|
|
debugLog(`[TSIG][INJECT] body keys: ${Object.keys(body).join(", ")}`)
|
|
|
|
const contents = body.contents as ContentBlock[] | undefined
|
|
if (!contents || !Array.isArray(contents)) {
|
|
debugLog(`[TSIG][INJECT] No contents array! Has messages: ${!!body.messages}`)
|
|
return body
|
|
}
|
|
|
|
debugLog(`[TSIG][INJECT] Found ${contents.length} content blocks`)
|
|
let injectedCount = 0
|
|
const modifiedContents = contents.map((content) => {
|
|
if (!content.parts || !Array.isArray(content.parts)) {
|
|
return content
|
|
}
|
|
|
|
const modifiedParts = content.parts.map((part) => {
|
|
if (part.functionCall && !part.thoughtSignature) {
|
|
injectedCount++
|
|
return {
|
|
...part,
|
|
thoughtSignature: effectiveSignature,
|
|
}
|
|
}
|
|
return part
|
|
})
|
|
|
|
return { ...content, parts: modifiedParts }
|
|
})
|
|
|
|
debugLog(`[TSIG][INJECT] injected signature into ${injectedCount} functionCall(s)`)
|
|
return { ...body, contents: modifiedContents }
|
|
}
|
|
|
|
/**
|
|
* Detect if request is for streaming.
|
|
* Checks both action name and request body for stream flag.
|
|
*
|
|
* @param url - Request URL
|
|
* @param body - Request body
|
|
* @returns true if streaming is requested
|
|
*/
|
|
export function isStreamingRequest(
|
|
url: string,
|
|
body: Record<string, unknown>
|
|
): boolean {
|
|
// Check URL action
|
|
const action = extractActionFromUrl(url)
|
|
if (action === "streamGenerateContent") {
|
|
return true
|
|
}
|
|
|
|
// Check body for stream flag
|
|
if (body.stream === true) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export interface TransformRequestOptions {
|
|
url: string
|
|
body: Record<string, unknown>
|
|
accessToken: string
|
|
projectId: string
|
|
sessionId: string
|
|
modelName?: string
|
|
endpointOverride?: string
|
|
thoughtSignature?: string
|
|
}
|
|
|
|
export function transformRequest(options: TransformRequestOptions): TransformedRequest {
|
|
const {
|
|
url,
|
|
body,
|
|
accessToken,
|
|
projectId,
|
|
sessionId,
|
|
modelName,
|
|
endpointOverride,
|
|
thoughtSignature,
|
|
} = options
|
|
|
|
const effectiveModel =
|
|
modelName || extractModelFromBody(body) || extractModelFromUrl(url) || "gemini-3-pro-high"
|
|
|
|
const streaming = isStreamingRequest(url, body)
|
|
const action = streaming ? "streamGenerateContent" : "generateContent"
|
|
|
|
const endpoint = endpointOverride || getDefaultEndpoint()
|
|
const transformedUrl = buildAntigravityUrl(endpoint, action, streaming)
|
|
|
|
const headers = buildRequestHeaders(accessToken)
|
|
if (streaming) {
|
|
headers["Accept"] = "text/event-stream"
|
|
}
|
|
|
|
const bodyWithSignature = injectThoughtSignatureIntoFunctionCalls(body, thoughtSignature)
|
|
const wrappedBody = wrapRequestBody(bodyWithSignature, projectId, effectiveModel, sessionId)
|
|
|
|
return {
|
|
url: transformedUrl,
|
|
headers,
|
|
body: wrappedBody,
|
|
streaming,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepare request headers for streaming responses.
|
|
* Adds Accept header for SSE format.
|
|
*
|
|
* @param headers - Existing headers object
|
|
* @returns Headers with streaming support
|
|
*/
|
|
export function addStreamingHeaders(
|
|
headers: Record<string, string>
|
|
): Record<string, string> {
|
|
return {
|
|
...headers,
|
|
Accept: "text/event-stream",
|
|
}
|
|
}
|