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
7 changes: 5 additions & 2 deletions src/oauth/account-quota-rank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { getCachedProviderAccountQuota, hasPassiveAccountQuota } from "../provid
import { kiroAccountEvidence } from "../providers/kiro-usage";
import type { ProviderAccount } from "./types";

/** Antigravity hosts Gemini and Claude windows on one account; ranking must not mix them. */
export type QuotaModelFamily = "gem" | "cla";
/** Antigravity hosts Gemini, Claude, and Argon windows on one account; ranking must not mix them. */
export type QuotaModelFamily = "gem" | "cla" | "argon";

export function classifyModelFamilyForQuota(
provider: string,
Expand All @@ -27,6 +27,8 @@ export function classifyModelFamilyForQuota(
const id = modelId.toLowerCase();
// Gemma is not Gemini: a substring/prefix match would poison Gemini ranking.
if (/(?:^|[^a-z])gemma(?:[^a-z]|$)/.test(id)) return undefined;
// Gemini 4 Argon has a separate dedicated weekly quota bucket from standard Gemini 3.x models.
if (/(?:^|[^a-z])argon(?:[^a-z]|$)/.test(id)) return "argon";
// Catalog ids are gemini-*, never a bare gem- token. Window labels still match Gem via
// windowMatchesFamily; this classifier is only for request model ids.
if (/(?:^|[^a-z])gemini(?:[^a-z]|$)/.test(id)) return "gem";
Expand All @@ -42,6 +44,7 @@ export function classifyModelFamilyForQuota(

function windowMatchesFamily(label: string, family: QuotaModelFamily): boolean {
const token = label.trim().split(/[\s(/]+/)[0] ?? "";
if (family === "argon") return /^argon$/i.test(token);
if (family === "gem") return /^gem(?:ini)?$/i.test(token);
return /^cla(?:ude)?$/i.test(token);
}
Expand Down
37 changes: 33 additions & 4 deletions src/oauth/generic-account-failover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import type { KiroRefusalKind } from "../adapters/kiro-refusal";
import { kiroAccountEvidence } from "../providers/kiro-usage";
import { kiroEvidenceIdentity } from "../providers/kiro-account-state-disk";
import { kiroAccountSupportsModel } from "../providers/kiro-model-catalog";
import { antigravityAccountSupportsModel } from "../providers/antigravity-account-roster";
import { antigravityAccountSupportsModel, recordAntigravityModelDenial } from "../providers/antigravity-account-roster";
import { ACCOUNT_QUOTA_TTL_MS } from "../providers/quota-wire";
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
import { subscribeOAuthAccountPauseChanges } from "../lib/account-selection-events";
Expand Down Expand Up @@ -435,6 +435,29 @@ export function rotateAntigravityAccountOnAuthRefusal(
return ring.find(id => id !== failedAccountId && eligible.has(id)) ?? null;
}

/** Select an alternate Antigravity account when Google CCA rejects a model with 404. */
export function rotateAntigravityAccountOnModelNotFound(
poolActivated: boolean,
failedAccountId: string,
requestedModelId: string | null | undefined,
now = Date.now(),
): string | null {
if (!poolActivated || !requestedModelId) return null;
recordAntigravityModelDenial("google-antigravity", failedAccountId, requestedModelId, now);
const set = getAccountSet("google-antigravity");
if (!set || set.accounts.length < 2) return null;
const family = classifyModelFamilyForQuota("google-antigravity", requestedModelId);
const eligibleIds = eligibleIdsIn(set, "google-antigravity", now, family);
const candidates = eligibleIds.filter(id => accountListsModel("google-antigravity", id, requestedModelId) !== false);
if (candidates.length === 0) return null;
const positive = candidates.filter(id => accountListsModel("google-antigravity", id, requestedModelId) === true);
const pool = positive.length > 0 ? positive : candidates;
const order = set.accounts.map(row => row.id);
const start = order.indexOf(failedAccountId);
const ring = start >= 0 ? [...order.slice(start + 1), ...order.slice(0, start)] : order;
return ring.find(id => id !== failedAccountId && pool.includes(id)) ?? null;
}

/** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */
type ActiveGenericStrategy = "round-robin" | "fill-first" | "reset-first" | "least-loaded";

Expand Down Expand Up @@ -754,13 +777,19 @@ export function preferredInitialAccount(
const modelEligible = preferKiroModelSupport(providerName,
eligibleFailoverAccounts(providerName, now, classifyModelFamilyForQuota(providerName, requestedModelId)),
requestedModelId, config);
const activeListed = active && requestedModelId ? accountListsModel(providerName, active, requestedModelId) : undefined;
const activeLacks = (providerName === "kiro" || providerName === "google-antigravity")
&& !!active && !!requestedModelId
&& accountListsModel(providerName, active, requestedModelId) === false;
&& activeListed === false;
// Only a PROVEN omission moves the request: an account with no cached roster stays usable,
// which is why the widened search here is the reactively-denied sibling, not any unknown one.
const listingSibling = activeLacks
? modelEligible.find(id => id !== active && (providerName === "kiro"
? kiroModelListingHasRoom(config, id, requestedModelId!)
: accountListsModel(providerName, id, requestedModelId!) === true))
? kiroModelListingHasRoom(config, id, requestedModelId!)
: accountListsModel(providerName, id, requestedModelId!) === true))
?? (providerName === "google-antigravity"
? modelEligible.find(id => id !== active && accountListsModel(providerName, id, requestedModelId!) === undefined)
: undefined)
: undefined;
if (listingSibling) return listingSibling;

Expand Down
65 changes: 57 additions & 8 deletions src/providers/antigravity-account-roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,45 @@ import { ANTIGRAVITY_MODEL_ALIASES, canonicalAntigravityUsageModel, type Antigra
/** A roster older than this is treated as unknown; entitlements change with plan and rollout. */
export const ANTIGRAVITY_ACCOUNT_ROSTER_TTL_MS = 6 * 60 * 60 * 1000;

/** A model denial is remembered for one day; a fresh roster clears it sooner. */
export const ANTIGRAVITY_MODEL_DENIAL_TTL_MS = 24 * 60 * 60 * 1000;

interface AccountRoster {
readonly ids: ReadonlySet<string>;
readonly modelIds: readonly string[];
readonly observedAt: number;
}

const rosters = new Map<string, AccountRoster>();
const modelDenials = new Map<string, Map<string, number>>();

function rosterKey(providerName: string, accountId: string): string {
return `${getConfigDir()}\0${providerName}\0${accountId}`;
}

/** Record one account's denial for a model (e.g. from upstream HTTP 404 "Requested entity was not found"). */
export function recordAntigravityModelDenial(
providerName: string,
accountId: string,
modelId: string,
now = Date.now(),
): void {
if (!providerName || !accountId) return;
const id = modelId?.trim();
if (!id) return;
const key = rosterKey(providerName, accountId);
let accountDenials = modelDenials.get(key);
if (!accountDenials) {
accountDenials = new Map<string, number>();
modelDenials.set(key, accountDenials);
}
accountDenials.set(id, now);
const canonical = canonicalAntigravityUsageModel(id);
accountDenials.set(canonical, now);
const alias = Object.hasOwn(ANTIGRAVITY_MODEL_ALIASES, id) ? ANTIGRAVITY_MODEL_ALIASES[id] : undefined;
if (alias) accountDenials.set(alias, now);
}

/** Record one account's discovered roster: picker ids, representative wire ids, and tier wire ids. */
export function recordAntigravityAccountRoster(
providerName: string,
Expand All @@ -48,7 +75,9 @@ export function recordAntigravityAccountRoster(
ids.add(model.wireModelId);
for (const wire of Object.values(model.effortWireModelIds ?? {})) if (wire) ids.add(wire);
}
rosters.set(rosterKey(providerName, accountId), {
const key = rosterKey(providerName, accountId);
modelDenials.delete(key);
rosters.set(key, {
ids,
modelIds: models.map(model => model.id),
observedAt: now,
Expand All @@ -73,30 +102,50 @@ export function antigravityAccountRosterModelIds(
/**
* Whether one account's roster lists `modelId`: `true` / `false` from its last discovery,
* `undefined` when that account has no fresh roster. A picker id, one of its tier wire ids, or a
* compatibility alias of either all count as listed.
* compatibility alias of either all count as listed. Denials recorded from upstream 404 responses
* immediately answer `false`.
*/
export function antigravityAccountSupportsModel(
providerName: string,
accountId: string,
modelId: string,
now = Date.now(),
): boolean | undefined {
const roster = freshRoster(providerName, accountId, now);
if (!roster) return undefined;
const id = modelId.trim();
if (!id) return undefined;
if (roster.ids.has(id)) return true;
const canonical = canonicalAntigravityUsageModel(id);
if (roster.ids.has(canonical)) return true;
const alias = Object.hasOwn(ANTIGRAVITY_MODEL_ALIASES, id) ? ANTIGRAVITY_MODEL_ALIASES[id] : undefined;

const key = rosterKey(providerName, accountId);
const accountDenials = modelDenials.get(key);
if (accountDenials) {
for (const candidate of [id, canonical, alias]) {
if (!candidate) continue;
const deniedAt = accountDenials.get(candidate);
if (deniedAt !== undefined) {
if (now - deniedAt <= ANTIGRAVITY_MODEL_DENIAL_TTL_MS) {
return false;
}
accountDenials.delete(candidate);
}
}
}

const roster = freshRoster(providerName, accountId, now);
if (!roster) return undefined;
if (roster.ids.has(id)) return true;
if (roster.ids.has(canonical)) return true;
return alias !== undefined && roster.ids.has(alias);
}

/** Forget rosters (one account, or all). For account removal and tests. */
/** Forget rosters and denials (one account, or all). For account removal and tests. */
export function clearAntigravityAccountRosters(providerName?: string, accountId?: string): void {
if (providerName && accountId) {
rosters.delete(rosterKey(providerName, accountId));
const key = rosterKey(providerName, accountId);
rosters.delete(key);
modelDenials.delete(key);
return;
}
rosters.clear();
modelDenials.clear();
}
4 changes: 2 additions & 2 deletions src/providers/quota-decision-publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ export function providerDecisionWindows(quota: ProviderQuota): DecisionQuotaWind
}
return windows;
}
/** Antigravity family buckets: Gem/Cla are 5h counters, "(Weekly)" their weekly pair; per-model rows and other groups are display-only. */
/** Antigravity family buckets: Gem/Claude GPT are 5h counters, "(Weekly)" their weekly pair; per-model rows and other groups are display-only. */
export function antigravityDecisionWindows(quota: ProviderQuota): DecisionQuotaWindow[] {
const windows: DecisionQuotaWindow[] = [];
for (const row of Array.isArray(quota.customWindows) ? quota.customWindows : []) {
const match = typeof row?.label === "string" && row.modelId === undefined ? /^(Gem|Cla)( \(Weekly\))?$/.exec(row.label) : null;
const match = typeof row?.label === "string" && row.modelId === undefined ? /^(Gem|Cla|Claude \/ GPT)( \(Weekly\))?$/.exec(row.label) : null;
if (!match || !Number.isFinite(row.percent)) continue;
windows.push({ window: match[2] ? "weekly" : "5h", family: match[1] === "Gem" ? "gem" : "cla", percent: row.percent,
observedAt: quota.updatedAt, ...(row.resetAt !== undefined ? { resetAt: row.resetAt } : {}) });
Expand Down
26 changes: 15 additions & 11 deletions src/providers/quota/antigravity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { antigravityUserAgent } from "../../adapters/client-fingerprint";
import { antigravityCliUserAgent, antigravityUserAgent } from "../../adapters/client-fingerprint";
import { DestinationDnsResolutionError } from "../../lib/destination-policy";
import { PinnedHttpError, type PinnedHttpErrorCode } from "../../lib/pinned-http";
import { ProviderOutboundPolicyError, providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../../lib/provider-outbound";
Expand Down Expand Up @@ -66,14 +66,14 @@ function antigravitySummaryBucketFamily(bucket: Record<string, unknown>): Antigr
return undefined;
}

function classifyAntigravityFamily(modelId: string, modelInfo: Record<string, unknown>, quotaInfo: Record<string, unknown>): "Gem" | "Cla" | null {
function classifyAntigravityFamily(modelId: string, modelInfo: Record<string, unknown>, quotaInfo: Record<string, unknown>): "Gem" | "Claude / GPT" | null {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const displayName = typeof modelInfo.displayName === "string" ? modelInfo.displayName : "";
// Argon models keep their exact model rows but never seed the shared Gem family bar.
if (isAntigravityArgonIdentity(`${modelId} ${displayName}`)) return null;
const tier = typeof quotaInfo.tier === "string" ? quotaInfo.tier : "";
const haystack = `${modelId} ${displayName} ${tier}`.toLowerCase();
if (haystack.includes("gemini")) return "Gem";
if (haystack.includes("claude") || haystack.includes("opus") || haystack.includes("sonnet") || haystack.includes("gpt-oss") || haystack.includes("gpt_oss")) return "Cla";
if (haystack.includes("claude") || haystack.includes("opus") || haystack.includes("sonnet") || haystack.includes("gpt-oss") || haystack.includes("gpt_oss")) return "Claude / GPT";
return null;
}

Expand All @@ -88,14 +88,14 @@ function antigravityUsedPercent(quotaInfo: Record<string, unknown>): number | un
return normalizePercent(100 - remaining);
}

/** Gem/Cla windows from a `fetchAvailableModels` body; shared by the provider and account probes. */
/** Gem/Claude windows from a `fetchAvailableModels` body; shared by the provider and account probes. */
function antigravityWindowsFromModels(body: Record<string, unknown> | null): ProviderQuotaWindow[] {
const models = asRecord(body?.models);
if (!models) return [];

const windows = new Map<string, ProviderQuotaWindow>();
const modelWindows: ProviderQuotaWindow[] = [];
const labels = new Set(["Gem", "Cla"]);
const labels = new Set(["Gem", "Claude / GPT"]);
for (const [modelId, rawModelInfo] of Object.entries(models)) {
const modelInfo = asRecord(rawModelInfo);
if (!modelInfo) continue;
Expand Down Expand Up @@ -131,7 +131,7 @@ function antigravityWindowsFromModels(body: Record<string, unknown> | null): Pro
}
}

const customWindows = ["Gem", "Cla"].flatMap(label => {
const customWindows = ["Gem", "Claude / GPT"].flatMap(label => {
const window = windows.get(label);
return window ? [window] : [];
});
Expand Down Expand Up @@ -183,7 +183,7 @@ function parseAntigravityQuotaSummary(body: Record<string, unknown> | null): Pro
customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
}
} else if (family === "cla") {
const label = is5h ? "Cla" : isWeekly ? "Cla (Weekly)" : "";
const label = is5h ? "Claude / GPT" : isWeekly ? "Claude / GPT (Weekly)" : "";
if (label && !customWindowsMap.has(label)) {
customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
}
Expand All @@ -197,7 +197,7 @@ function parseAntigravityQuotaSummary(body: Record<string, unknown> | null): Pro
}
}

const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)", "Argon", "Argon (Weekly)"];
const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Claude / GPT", "Claude / GPT (Weekly)", "Argon", "Argon (Weekly)"];
const customWindows = Array.from(customWindowsMap.values()).sort((a, b) => {
const ia = PREFERRED_ORDER.indexOf(a.label);
const ib = PREFERRED_ORDER.indexOf(b.label);
Expand Down Expand Up @@ -303,7 +303,7 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId:
// An optional caller signal (account import) cancels every request of this probe, including the
// 403 retry and the models fallback, alongside the per-request deadline.
const requestSignal = () => signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
const fetchQuota = (url: string, userAgent = antigravityUserAgent()) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
const fetchQuota = (url: string, userAgent = (url === ANTIGRAVITY_QUOTA_SUMMARY_URL ? antigravityCliUserAgent() : antigravityUserAgent())) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
headers: {
Accept: "application/json", "Content-Type": "application/json",
"User-Agent": userAgent, Authorization: `Bearer ${accessToken}`,
Expand All @@ -314,9 +314,13 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId:
try {
let response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL);
if (response.status === 403) {
// Some valid accounts reject the IDE fingerprint only for quota accounting.
// Some valid accounts reject the CLI fingerprint or IDE fingerprint only for quota accounting.
try { await response.body?.cancel(); } catch { /* Best-effort release before retry. */ }
response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL, "antigravity/1.0");
response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL, antigravityUserAgent());
if (response.status === 403) {
try { await response.body?.cancel(); } catch { /* Best-effort release before retry. */ }
response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL, "antigravity/1.0");
}
}
if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_SUMMARY_URL)) return unavailableAntigravityQuota("redirect_blocked");
if (response.status === 401 || response.status === 403) return unavailableAntigravityQuota("access_denied");
Expand Down
Loading
Loading