* 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>
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
/**
|
|
* Antigravity Integration Tests - End-to-End
|
|
*
|
|
* Tests the complete request transformation pipeline:
|
|
* - Request parsing and model extraction
|
|
* - System prompt injection (handled by transformRequest)
|
|
* - Thinking config application (handled by applyThinkingConfigToRequest)
|
|
* - Body wrapping for Antigravity API format
|
|
*/
|
|
|
|
import { describe, it, expect } from "bun:test"
|
|
import { transformRequest } from "./request"
|
|
import { extractThinkingConfig, applyThinkingConfigToRequest } from "./thinking"
|
|
|
|
describe("Antigravity Integration - End-to-End", () => {
|
|
describe("Thinking Config Integration", () => {
|
|
it("Gemini 3 with reasoning_effort='high' → thinkingLevel='high'", () => {
|
|
// #given
|
|
const inputBody: Record<string, unknown> = {
|
|
model: "gemini-3-pro-preview",
|
|
reasoning_effort: "high",
|
|
messages: [{ role: "user", content: "test" }],
|
|
}
|
|
|
|
// #when
|
|
const transformed = transformRequest({
|
|
url: "https://generativelanguage.googleapis.com/v1internal/models/gemini-3-pro-preview:generateContent",
|
|
body: inputBody,
|
|
accessToken: "test-token",
|
|
projectId: "test-project",
|
|
sessionId: "test-session",
|
|
modelName: "gemini-3-pro-preview",
|
|
})
|
|
|
|
const thinkingConfig = extractThinkingConfig(
|
|
inputBody,
|
|
inputBody.generationConfig as Record<string, unknown> | undefined,
|
|
inputBody,
|
|
)
|
|
if (thinkingConfig) {
|
|
applyThinkingConfigToRequest(
|
|
transformed.body as unknown as Record<string, unknown>,
|
|
"gemini-3-pro-preview",
|
|
thinkingConfig,
|
|
)
|
|
}
|
|
|
|
// #then
|
|
const genConfig = transformed.body.request.generationConfig as Record<string, unknown> | undefined
|
|
const thinkingConfigResult = genConfig?.thinkingConfig as Record<string, unknown> | undefined
|
|
expect(thinkingConfigResult?.thinkingLevel).toBe("high")
|
|
expect(thinkingConfigResult?.thinkingBudget).toBeUndefined()
|
|
const systemInstruction = transformed.body.request.systemInstruction as Record<string, unknown> | undefined
|
|
const parts = systemInstruction?.parts as Array<{ text: string }> | undefined
|
|
expect(parts?.[0]?.text).toContain("<identity>")
|
|
})
|
|
|
|
it("Gemini 2.5 with reasoning_effort='high' → thinkingBudget=24576", () => {
|
|
// #given
|
|
const inputBody: Record<string, unknown> = {
|
|
model: "gemini-2.5-flash",
|
|
reasoning_effort: "high",
|
|
messages: [{ role: "user", content: "test" }],
|
|
}
|
|
|
|
// #when
|
|
const transformed = transformRequest({
|
|
url: "https://generativelanguage.googleapis.com/v1internal/models/gemini-2.5-flash:generateContent",
|
|
body: inputBody,
|
|
accessToken: "test-token",
|
|
projectId: "test-project",
|
|
sessionId: "test-session",
|
|
modelName: "gemini-2.5-flash",
|
|
})
|
|
|
|
const thinkingConfig = extractThinkingConfig(
|
|
inputBody,
|
|
inputBody.generationConfig as Record<string, unknown> | undefined,
|
|
inputBody,
|
|
)
|
|
if (thinkingConfig) {
|
|
applyThinkingConfigToRequest(
|
|
transformed.body as unknown as Record<string, unknown>,
|
|
"gemini-2.5-flash",
|
|
thinkingConfig,
|
|
)
|
|
}
|
|
|
|
// #then
|
|
const genConfig = transformed.body.request.generationConfig as Record<string, unknown> | undefined
|
|
const thinkingConfigResult = genConfig?.thinkingConfig as Record<string, unknown> | undefined
|
|
expect(thinkingConfigResult?.thinkingBudget).toBe(24576)
|
|
expect(thinkingConfigResult?.thinkingLevel).toBeUndefined()
|
|
})
|
|
|
|
it("reasoning_effort='none' → thinkingConfig deleted", () => {
|
|
// #given
|
|
const inputBody: Record<string, unknown> = {
|
|
model: "gemini-2.5-flash",
|
|
reasoning_effort: "none",
|
|
messages: [{ role: "user", content: "test" }],
|
|
}
|
|
|
|
// #when
|
|
const transformed = transformRequest({
|
|
url: "https://generativelanguage.googleapis.com/v1internal/models/gemini-2.5-flash:generateContent",
|
|
body: inputBody,
|
|
accessToken: "test-token",
|
|
projectId: "test-project",
|
|
sessionId: "test-session",
|
|
modelName: "gemini-2.5-flash",
|
|
})
|
|
|
|
const thinkingConfig = extractThinkingConfig(
|
|
inputBody,
|
|
inputBody.generationConfig as Record<string, unknown> | undefined,
|
|
inputBody,
|
|
)
|
|
if (thinkingConfig) {
|
|
applyThinkingConfigToRequest(
|
|
transformed.body as unknown as Record<string, unknown>,
|
|
"gemini-2.5-flash",
|
|
thinkingConfig,
|
|
)
|
|
}
|
|
|
|
// #then
|
|
const genConfig = transformed.body.request.generationConfig as Record<string, unknown> | undefined
|
|
expect(genConfig?.thinkingConfig).toBeUndefined()
|
|
})
|
|
|
|
it("Claude via Antigravity with reasoning_effort='high'", () => {
|
|
// #given
|
|
const inputBody: Record<string, unknown> = {
|
|
model: "gemini-claude-sonnet-4-5",
|
|
reasoning_effort: "high",
|
|
messages: [{ role: "user", content: "test" }],
|
|
}
|
|
|
|
// #when
|
|
const transformed = transformRequest({
|
|
url: "https://generativelanguage.googleapis.com/v1internal/models/gemini-claude-sonnet-4-5:generateContent",
|
|
body: inputBody,
|
|
accessToken: "test-token",
|
|
projectId: "test-project",
|
|
sessionId: "test-session",
|
|
modelName: "gemini-claude-sonnet-4-5",
|
|
})
|
|
|
|
const thinkingConfig = extractThinkingConfig(
|
|
inputBody,
|
|
inputBody.generationConfig as Record<string, unknown> | undefined,
|
|
inputBody,
|
|
)
|
|
if (thinkingConfig) {
|
|
applyThinkingConfigToRequest(
|
|
transformed.body as unknown as Record<string, unknown>,
|
|
"gemini-claude-sonnet-4-5",
|
|
thinkingConfig,
|
|
)
|
|
}
|
|
|
|
// #then
|
|
const genConfig = transformed.body.request.generationConfig as Record<string, unknown> | undefined
|
|
const thinkingConfigResult = genConfig?.thinkingConfig as Record<string, unknown> | undefined
|
|
expect(thinkingConfigResult?.thinkingBudget).toBe(24576)
|
|
})
|
|
|
|
it("System prompt not duplicated on retry", () => {
|
|
// #given
|
|
const inputBody: Record<string, unknown> = {
|
|
model: "gemini-3-pro-high",
|
|
reasoning_effort: "high",
|
|
messages: [{ role: "user", content: "test" }],
|
|
}
|
|
|
|
// #when - First transformation
|
|
const firstOutput = transformRequest({
|
|
url: "https://generativelanguage.googleapis.com/v1internal/models/gemini-3-pro-high:generateContent",
|
|
body: inputBody,
|
|
accessToken: "test-token",
|
|
projectId: "test-project",
|
|
sessionId: "test-session",
|
|
modelName: "gemini-3-pro-high",
|
|
})
|
|
|
|
// Extract thinking config and apply to first output (simulating what fetch.ts does)
|
|
const thinkingConfig = extractThinkingConfig(
|
|
inputBody,
|
|
inputBody.generationConfig as Record<string, unknown> | undefined,
|
|
inputBody,
|
|
)
|
|
if (thinkingConfig) {
|
|
applyThinkingConfigToRequest(
|
|
firstOutput.body as unknown as Record<string, unknown>,
|
|
"gemini-3-pro-high",
|
|
thinkingConfig,
|
|
)
|
|
}
|
|
|
|
// #then
|
|
const systemInstruction = firstOutput.body.request.systemInstruction as Record<string, unknown> | undefined
|
|
const parts = systemInstruction?.parts as Array<{ text: string }> | undefined
|
|
const identityCount = parts?.filter((p) => p.text.includes("<identity>")).length ?? 0
|
|
expect(identityCount).toBe(1) // Should have exactly ONE <identity> block
|
|
})
|
|
|
|
it("reasoning_effort='low' for Gemini 3 → thinkingLevel='low'", () => {
|
|
// #given
|
|
const inputBody: Record<string, unknown> = {
|
|
model: "gemini-3-flash-preview",
|
|
reasoning_effort: "low",
|
|
messages: [{ role: "user", content: "test" }],
|
|
}
|
|
|
|
// #when
|
|
const transformed = transformRequest({
|
|
url: "https://generativelanguage.googleapis.com/v1internal/models/gemini-3-flash-preview:generateContent",
|
|
body: inputBody,
|
|
accessToken: "test-token",
|
|
projectId: "test-project",
|
|
sessionId: "test-session",
|
|
modelName: "gemini-3-flash-preview",
|
|
})
|
|
|
|
const thinkingConfig = extractThinkingConfig(
|
|
inputBody,
|
|
inputBody.generationConfig as Record<string, unknown> | undefined,
|
|
inputBody,
|
|
)
|
|
if (thinkingConfig) {
|
|
applyThinkingConfigToRequest(
|
|
transformed.body as unknown as Record<string, unknown>,
|
|
"gemini-3-flash-preview",
|
|
thinkingConfig,
|
|
)
|
|
}
|
|
|
|
// #then
|
|
const genConfig = transformed.body.request.generationConfig as Record<string, unknown> | undefined
|
|
const thinkingConfigResult = genConfig?.thinkingConfig as Record<string, unknown> | undefined
|
|
expect(thinkingConfigResult?.thinkingLevel).toBe("low")
|
|
})
|
|
|
|
it("Full pipeline: transformRequest + thinking config preserves all fields", () => {
|
|
// #given
|
|
const inputBody: Record<string, unknown> = {
|
|
model: "gemini-2.5-flash",
|
|
reasoning_effort: "medium",
|
|
messages: [
|
|
{ role: "system", content: "You are a helpful assistant." },
|
|
{ role: "user", content: "Write a function" },
|
|
],
|
|
generationConfig: {
|
|
temperature: 0.7,
|
|
maxOutputTokens: 1000,
|
|
},
|
|
}
|
|
|
|
// #when
|
|
const transformed = transformRequest({
|
|
url: "https://generativelanguage.googleapis.com/v1internal/models/gemini-2.5-flash:generateContent",
|
|
body: inputBody,
|
|
accessToken: "test-token",
|
|
projectId: "test-project",
|
|
sessionId: "test-session",
|
|
modelName: "gemini-2.5-flash",
|
|
})
|
|
|
|
const thinkingConfig = extractThinkingConfig(
|
|
inputBody,
|
|
inputBody.generationConfig as Record<string, unknown> | undefined,
|
|
inputBody,
|
|
)
|
|
if (thinkingConfig) {
|
|
applyThinkingConfigToRequest(
|
|
transformed.body as unknown as Record<string, unknown>,
|
|
"gemini-2.5-flash",
|
|
thinkingConfig,
|
|
)
|
|
}
|
|
|
|
// #then
|
|
// Verify basic structure is preserved
|
|
expect(transformed.body.project).toBe("test-project")
|
|
expect(transformed.body.model).toBe("gemini-2.5-flash")
|
|
expect(transformed.body.userAgent).toBe("antigravity")
|
|
expect(transformed.body.request.sessionId).toBe("test-session")
|
|
|
|
// Verify generation config is preserved
|
|
const genConfig = transformed.body.request.generationConfig as Record<string, unknown> | undefined
|
|
expect(genConfig?.temperature).toBe(0.7)
|
|
expect(genConfig?.maxOutputTokens).toBe(1000)
|
|
|
|
// Verify thinking config is applied
|
|
const thinkingConfigResult = genConfig?.thinkingConfig as Record<string, unknown> | undefined
|
|
expect(thinkingConfigResult?.thinkingBudget).toBe(8192)
|
|
expect(thinkingConfigResult?.include_thoughts).toBe(true)
|
|
|
|
// Verify system prompt is injected
|
|
const systemInstruction = transformed.body.request.systemInstruction as Record<string, unknown> | undefined
|
|
const parts = systemInstruction?.parts as Array<{ text: string }> | undefined
|
|
expect(parts?.[0]?.text).toContain("<identity>")
|
|
})
|
|
})
|
|
})
|