-
Notifications
You must be signed in to change notification settings - Fork 716
feat(oauth): add account pool support and per-account quota probing for Google Antigravity (#1062) #1084
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(oauth): add account pool support and per-account quota probing for Google Antigravity (#1062) #1084
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -568,7 +568,7 @@ export interface ProviderAccountQuota { | |
|
|
||
| /** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */ | ||
| export function supportsPerAccountQuota(provider: string): boolean { | ||
| return provider === "anthropic"; | ||
| return provider === "anthropic" || provider === "google-antigravity"; | ||
| } | ||
|
|
||
| function accountCacheKey(provider: string, accountId: string): string { | ||
|
|
@@ -673,6 +673,56 @@ async function getTokenForAccountQuotaProbe(provider: string, accountId: string) | |
| return getValidAccessTokenForAccount(provider, accountId); | ||
| } | ||
|
|
||
| async function fetchAntigravityQuotaWithToken( | ||
| accessToken: string, | ||
| projectId: string, | ||
| baseUrl = "https://daily-cloudcode-pa.googleapis.com", | ||
| ): Promise<ProviderQuota | null> { | ||
| const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1internal:fetchAvailableModels`, { | ||
| method: "POST", | ||
| headers: { | ||
| Accept: "application/json", | ||
| "Content-Type": "application/json", | ||
| "User-Agent": antigravityUserAgent(), | ||
| Authorization: `Bearer ${accessToken}`, | ||
| }, | ||
| body: JSON.stringify({ project: projectId }), | ||
| signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), | ||
| }); | ||
| if (!response.ok) return null; | ||
| const body = asRecord(await response.json().catch(() => null)); | ||
| const models = asRecord(body?.models); | ||
| if (!models) return null; | ||
|
|
||
| const windows = new Map<string, ProviderQuotaWindow>(); | ||
| for (const [modelId, rawModelInfo] of Object.entries(models)) { | ||
| const modelInfo = asRecord(rawModelInfo); | ||
| if (!modelInfo) continue; | ||
| for (const quotaInfo of quotaInfoEntries(modelInfo)) { | ||
| const label = classifyAntigravityFamily(modelId, modelInfo, quotaInfo); | ||
| if (!label || windows.has(label)) continue; | ||
| const percent = antigravityUsedPercent(quotaInfo); | ||
| if (percent === undefined) continue; | ||
| windows.set(label, { | ||
| label, | ||
| percent, | ||
| ...(normalizeResetAt(quotaInfo.resetTime) !== undefined ? { resetAt: normalizeResetAt(quotaInfo.resetTime) } : {}), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| const customWindows = ["Gem", "Cla"].flatMap(label => { | ||
| const window = windows.get(label); | ||
| return window ? [window] : []; | ||
| }); | ||
| if (customWindows.length === 0) return null; | ||
|
|
||
| return { | ||
| customWindows, | ||
| updatedAt: Date.now(), | ||
| }; | ||
| } | ||
|
Comment on lines
+676
to
+724
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Add focused quota-probe regression tests. This new parser handles untyped upstream model metadata, quota units, family classification, and reset timestamps. The supplied test changes cover only pool configuration endpoints. They do not exercise this quota probe. Add mocked-fetch tests for Gemini and Claude-family rows, missing quota metadata, reset-time conversion, and unavailable project IDs. As per path instructions, “A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.” 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| async function fetchAccountQuota( | ||
| provider: string, | ||
| accountId: string, | ||
|
|
@@ -688,7 +738,13 @@ async function fetchAccountQuota( | |
| const probe = (async (): Promise<AccountQuotaCacheEntry> => { | ||
| try { | ||
| const token = await getTokenForAccountQuotaProbe(provider, accountId); | ||
| const quota = await fetchAnthropicUsageQuota(token); | ||
| let quota: ProviderQuota | null = null; | ||
| if (provider === "anthropic") { | ||
| quota = await fetchAnthropicUsageQuota(token); | ||
| } else if (provider === "google-antigravity") { | ||
| const cred = getAccountCredential(provider, accountId); | ||
| quota = cred?.projectId ? await fetchAntigravityQuotaWithToken(token, cred.projectId) : null; | ||
|
Comment on lines
+742
to
+746
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The only test added by this commit exercises pool-config GET/PUT; it never calls the new Antigravity quota branch. As a result, account-specific bearer selection, each credential's AGENTS.md reference: src/AGENTS.md:L24-L27 Useful? React with 👍 / 👎. |
||
| } | ||
| if (!quota) { | ||
| // Preserve last-good bars and mark unavailable; advance TTL so failures | ||
| // negative-cache instead of re-probing on every GUI poll. | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -289,8 +289,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< | |||||||||||
| // Opt-in Anthropic OAuth account pool (#294): enable/threshold/strategy + clear cooldown. | ||||||||||||
| if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") { | ||||||||||||
| const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); | ||||||||||||
| if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400); | ||||||||||||
| const pool = config.anthropicAccountPool ?? {}; | ||||||||||||
| if (provider !== "anthropic" && provider !== "google-antigravity") { | ||||||||||||
| return jsonResponse({ error: "pool config is only supported for anthropic and google-antigravity" }, 400); | ||||||||||||
| } | ||||||||||||
| const pool = provider === "google-antigravity" ? (config.googleAntigravityAccountPool ?? {}) : (config.anthropicAccountPool ?? {}); | ||||||||||||
|
Comment on lines
+292
to
+295
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a normal Google token exchange that omits Useful? React with 👍 / 👎. |
||||||||||||
| return jsonResponse({ | ||||||||||||
| provider, | ||||||||||||
| enabled: pool.enabled === true, | ||||||||||||
|
|
@@ -313,8 +315,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< | |||||||||||
| stickyLimit?: unknown; | ||||||||||||
| }; | ||||||||||||
| const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; | ||||||||||||
| if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400); | ||||||||||||
| let enabled = config.anthropicAccountPool?.enabled === true; | ||||||||||||
| if (provider !== "anthropic" && provider !== "google-antigravity") { | ||||||||||||
| return jsonResponse({ error: "pool config is only supported for anthropic and google-antigravity" }, 400); | ||||||||||||
| } | ||||||||||||
| const poolKey = provider === "google-antigravity" ? "googleAntigravityAccountPool" : "anthropicAccountPool"; | ||||||||||||
| let enabled = config[poolKey]?.enabled === true; | ||||||||||||
|
Comment on lines
+321
to
+322
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Preserve the selected provider's existing pool settings.
Read all existing fields from Proposed fix const poolKey = provider === "google-antigravity" ? "googleAntigravityAccountPool" : "anthropicAccountPool";
-let enabled = config[poolKey]?.enabled === true;
+const existingPool = config[poolKey] ?? {};
+let enabled = existingPool.enabled === true;
...
-let threshold = config.anthropicAccountPool?.autoSwitchThreshold ?? 80;
+let threshold = existingPool.autoSwitchThreshold ?? 80;
...
-let strategy = config.anthropicAccountPool?.strategy;
+let strategy = existingPool.strategy;
...
-let stickyLimit = config.anthropicAccountPool?.stickyLimit;
+let stickyLimit = existingPool.stickyLimit;Add a regression test that configures Google Antigravity values, then sends a partial PUT or PATCH request. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Comment on lines
+321
to
+322
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Google Antigravity caller sends a partial Useful? React with 👍 / 👎. |
||||||||||||
| if (body.enabled !== undefined) { | ||||||||||||
| if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400); | ||||||||||||
| enabled = body.enabled; | ||||||||||||
|
|
@@ -347,12 +352,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< | |||||||||||
| } | ||||||||||||
| stickyLimit = parsed; | ||||||||||||
| } | ||||||||||||
| config.anthropicAccountPool = { | ||||||||||||
| const poolObj = { | ||||||||||||
| enabled, | ||||||||||||
| autoSwitchThreshold: threshold, | ||||||||||||
| ...(strategy !== undefined ? { strategy } : {}), | ||||||||||||
| ...(stickyLimit !== undefined ? { stickyLimit } : {}), | ||||||||||||
| }; | ||||||||||||
| if (provider === "google-antigravity") config.googleAntigravityAccountPool = poolObj; | ||||||||||||
| else config.anthropicAccountPool = poolObj; | ||||||||||||
|
Comment on lines
+361
to
+362
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Enabling this setting has no effect on Antigravity requests: AGENTS.md reference: AGENTS.md:L228-L230 Useful? React with 👍 / 👎. |
||||||||||||
| saveConfigPreservingClaudeCode(config); | ||||||||||||
| reconcileLiveStateStores(); | ||||||||||||
| return jsonResponse({ | ||||||||||||
|
|
@@ -369,7 +376,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< | |||||||||||
| const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; accountId?: unknown }; | ||||||||||||
| const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; | ||||||||||||
| const accountId = typeof body.accountId === "string" ? body.accountId.trim() : ""; | ||||||||||||
| if (provider !== "anthropic") return jsonResponse({ error: "clear-cooldown is only supported for anthropic" }, 400); | ||||||||||||
| if (provider !== "anthropic" && provider !== "google-antigravity") return jsonResponse({ error: "clear-cooldown is supported for anthropic and google-antigravity" }, 400); | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For Useful? React with 👍 / 👎. |
||||||||||||
| if (!accountId) return jsonResponse({ error: "missing accountId" }, 400); | ||||||||||||
| const { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing"); | ||||||||||||
| const cleared = clearAnthropicAccountCooldown(accountId); | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -783,6 +783,12 @@ export interface OcxConfig { | |
| * Failover on 429 + sticky affinity; new sessions may pick lowest known 5h usage. | ||
| * Experimental — see docs and GUI warning before enabling. | ||
| */ | ||
| googleAntigravityAccountPool?: { | ||
| enabled?: boolean; | ||
| autoSwitchThreshold?: number; | ||
| strategy?: OcxAccountPoolRotationStrategy; | ||
| stickyLimit?: number; | ||
| }; | ||
|
Comment on lines
+786
to
+791
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This adds a user-facing configuration object, but AGENTS.md reference: AGENTS.md:L231-L232 Useful? React with 👍 / 👎. |
||
| anthropicAccountPool?: { | ||
| enabled?: boolean; | ||
| /** Usage % threshold for new-session auto-pick. Default 80. 0 = disabled (affinity/active only). */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -175,6 +175,29 @@ describe("multiauth accounts API", () => { | |
| } | ||
| }); | ||
|
|
||
| test("pool config GET and PUT work for google-antigravity", async () => { | ||
| const server = startServer(0); | ||
| try { | ||
| const getRes = await fetch(new URL("/api/oauth/accounts/pool?provider=google-antigravity", server.url)); | ||
| expect(getRes.status).toBe(200); | ||
| const getJson = await getRes.json() as { provider: string; enabled: boolean }; | ||
| expect(getJson.provider).toBe("google-antigravity"); | ||
| expect(getJson.enabled).toBe(false); | ||
|
|
||
| const putRes = await fetch(new URL("/api/oauth/accounts/pool", server.url), { | ||
| method: "PUT", headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ provider: "google-antigravity", enabled: true, autoSwitchThreshold: 85 }), | ||
| }); | ||
| expect(putRes.status).toBe(200); | ||
| const putJson = await putRes.json() as { ok: boolean; enabled: boolean; autoSwitchThreshold: number }; | ||
|
Comment on lines
+183
to
+192
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win Remove the duplicate
Keep one declaration for each variable. Based on learnings, repeated 🤖 Prompt for AI AgentsSource: Learnings |
||
| expect(putJson.ok).toBe(true); | ||
| expect(putJson.enabled).toBe(true); | ||
| expect(putJson.autoSwitchThreshold).toBe(85); | ||
| } finally { | ||
| await server.stop(true); | ||
| } | ||
| }); | ||
|
|
||
| test("DELETE removes one account; active removal promotes the other", async () => { | ||
| const server = startServer(0); | ||
| try { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The per-account probe duplicates the Antigravity service URL as a default argument even though the canonical endpoint already lives in
src/providers/registry.ts. If that pinned endpoint changes, ordinary Antigravity traffic can follow the registry while every per-account quota probe continues calling the obsolete host and reports all rows unavailable. Resolve the fixed, allowlisted host from the registry or share the existing provider-level probe instead of maintaining a second provider fact.AGENTS.md reference: src/AGENTS.md:L18-L18
Useful? React with 👍 / 👎.