Skip to content
Closed
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
60 changes: 58 additions & 2 deletions src/providers/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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> {
Comment on lines +676 to +680

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve the quota endpoint from the canonical registry

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 👍 / 👎.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/quota.ts` around lines 676 - 724, Add focused mocked-fetch
regression tests for fetchAntigravityQuotaWithToken covering Gemini and
Claude-family model rows, missing quota metadata, reset-time conversion, and
unavailable project IDs. Place them beside the existing quota-provider tests,
and assert the returned custom windows and null outcomes for each case.

Source: Path instructions


async function fetchAccountQuota(
provider: string,
accountId: string,
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add focused Antigravity per-account quota coverage

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 projectId, response parsing, and independent failure handling can regress while the submitted test remains green. Extend tests/provider-account-quota.test.ts with multiple Antigravity accounts and distinct tokens/projects, including a failed sibling probe.

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.
Expand Down
19 changes: 13 additions & 6 deletions src/server/management/oauth-account-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Capture a stable identity for Antigravity pool logins

For a normal Google token exchange that omits id_token, this provider cannot retain multiple accounts even though the new route exposes a pool: credentialsFromPayload tries to derive email only by decoding the optional ID token or the access token, but the requested scopes omit openid and there is no userinfo request, so an opaque Google access token leaves both email and accountId unset. saveCredential treats such identityless credentials as replacement-style and overwrites the active slot, meaning addAccount: true can replace the first Antigravity login instead of appending a second one. Request and persist a stable Google identity, for example via OpenID claims or the userinfo endpoint, before enabling pooling.

Useful? React with 👍 / 👎.

return jsonResponse({
provider,
enabled: pool.enabled === true,
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

poolKey selects Google Antigravity only for enabled. Lines 327, 339, and 347 still read config.anthropicAccountPool. A partial update for google-antigravity can reset its stored threshold, strategy, or sticky limit to Anthropic values or defaults.

Read all existing fields from config[poolKey].

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const poolKey = provider === "google-antigravity" ? "googleAntigravityAccountPool" : "anthropicAccountPool";
let enabled = config[poolKey]?.enabled === true;
const poolKey = provider === "google-antigravity" ? "googleAntigravityAccountPool" : "anthropicAccountPool";
const existingPool = config[poolKey] ?? {};
let enabled = existingPool.enabled === true;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/management/oauth-account-routes.ts` around lines 321 - 322, Update
the provider pool update flow around poolKey so every existing setting read at
the referenced threshold, strategy, and sticky-limit handling uses
config[poolKey] rather than config.anthropicAccountPool, preserving Google
Antigravity values during partial updates. Add a regression test that configures
distinct Google Antigravity pool values and verifies a partial PUT or PATCH
retains the omitted settings.

Comment on lines +321 to +322

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the selected provider's partial pool settings

When a Google Antigravity caller sends a partial PATCH, only enabled is initialized through poolKey; threshold, strategy, and stickyLimit below are still read from config.anthropicAccountPool. For example, toggling an existing Antigravity pool off and back on silently replaces its saved threshold and strategy with Anthropic's values or defaults. Read every omitted field from config[poolKey] before rebuilding the provider-specific object.

Useful? React with 👍 / 👎.

if (body.enabled !== undefined) {
if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
enabled = body.enabled;
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire the Antigravity pool config into request routing

Enabling this setting has no effect on Antigravity requests: src/server/responses/core.ts only invokes pool selection and 429 failover when route.providerName === "anthropic" (lines 1576 and 2903-2907), while Antigravity continues through getValidAccessTokenSnapshot, which always selects the single active account. Consequently, the advertised threshold, rotation strategy, affinity, and failover never run; implement the corresponding Antigravity request-routing path and cover actual account selection and 429 rotation rather than only testing config persistence.

AGENTS.md reference: AGENTS.md:L228-L230

Useful? React with 👍 / 👎.

saveConfigPreservingClaudeCode(config);
reconcileLiveStateStores();
return jsonResponse({
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Dispatch cooldown clearing to the requested provider

For provider: "google-antigravity", this newly accepted route still calls clearAnthropicAccountCooldown. It therefore always reports no Antigravity cooldown cleared, and if the Google and Anthropic accounts share an identity-derived eight-character ID (IDs are hashed from account ID/email in src/oauth/store.ts), it can instead clear the unrelated Anthropic account's cooldown. Dispatch to provider-owned Antigravity state, or reject this provider until such state exists.

Useful? React with 👍 / 👎.

if (!accountId) return jsonResponse({ error: "missing accountId" }, 400);
const { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing");
const cleared = clearAnthropicAccountCooldown(accountId);
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new Antigravity pool configuration

This adds a user-facing configuration object, but docs-site/src/content/docs/reference/configuration/providers.md and its translated counterparts still document only anthropicAccountPool, leaving users without the setting name, defaults, ranges, or experimental behavior needed to configure the feature. Add the Antigravity pool section and keep localized references consistent with the English source.

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). */
Expand Down
23 changes: 23 additions & 0 deletions tests/oauth-accounts-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Remove the duplicate const declarations.

getJson is declared twice in one test callback. putJson is declared three times in the same callback. Bun cannot parse this file, so the OAuth account API tests cannot run.

Keep one declaration for each variable. Based on learnings, repeated const declarations are valid only in separate test callbacks; these declarations share one callback scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/oauth-accounts-api.test.ts` around lines 183 - 192, Remove the
duplicate const declarations for getJson and putJson within the same OAuth
account API test callback, retaining one declaration of each and reusing those
variables throughout the callback. Keep declarations in separate test callbacks
unchanged.

Source: 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 {
Expand Down
Loading