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
1 change: 1 addition & 0 deletions packages/core/src/model-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ const ANTIGRAVITY_OPENCODE_MODEL_IDS = [
"antigravity-gemini-3.1-pro",
"antigravity-claude-sonnet-4-6-thinking",
"antigravity-claude-opus-4-6-thinking",
"antigravity-gemini-3.1-flash-image",
] as const

function pickModelDefinitions(ids: readonly string[]): OpencodeModelDefinitions {
Expand Down
92 changes: 60 additions & 32 deletions packages/opencode/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
isGenerativeLanguageRequest,
prepareAntigravityRequest,
transformAntigravityResponse,
initSessionId,
} from "./plugin/request";
import { resolveModelWithTier } from "./plugin/transform/model-resolver";
import {
Expand Down Expand Up @@ -65,7 +66,7 @@ import {
parseGeminiDumpCommandAction,
setGeminiDumpEnabled,
} from "./plugin/gemini-dump";
import { getAntigravityOpencodeModelIds, OPENCODE_MODEL_DEFINITIONS } from "./plugin/model-registry";
import { OPENCODE_MODEL_DEFINITIONS } from "./plugin/model-registry";
import type {
GetAuth,
LoaderResult,
Expand All @@ -80,16 +81,11 @@ const MAX_OAUTH_ACCOUNTS = 10;
const MAX_WARMUP_SESSIONS = 1000;
const MAX_WARMUP_RETRIES = 2;
const MAX_TOTAL_CAPACITY_RETRIES = 4;
const CAPACITY_BACKOFF_TIERS_MS = [5000, 10000, 20000, 30000, 60000];

function isCapacityRetryBudgetExhausted(totalCapacityRetries: number): boolean {
return totalCapacityRetries >= MAX_TOTAL_CAPACITY_RETRIES;
}

function getCapacityBackoffDelay(consecutiveFailures: number): number {
const index = Math.min(consecutiveFailures, CAPACITY_BACKOFF_TIERS_MS.length - 1);
return CAPACITY_BACKOFF_TIERS_MS[Math.max(0, index)] ?? 5000;
}
const warmupAttemptedSessionIds = new Set<string>();
const warmupSucceededSessionIds = new Set<string>();

Expand Down Expand Up @@ -1165,17 +1161,6 @@ function resetRateLimitState(accountIndex: number, quotaKey: string): void {
rateLimitStateByAccountQuota.delete(stateKey);
}

/**
* Reset all rate limit state for an account (all quotas).
* Used when account is completely healthy.
*/
function resetAllRateLimitStateForAccount(accountIndex: number): void {
for (const key of rateLimitStateByAccountQuota.keys()) {
if (key.startsWith(`${accountIndex}:`)) {
rateLimitStateByAccountQuota.delete(key);
}
}
}

function headerStyleToQuotaKey(headerStyle: HeaderStyle, family: ModelFamily): string {
if (family === "claude") return "claude";
Expand Down Expand Up @@ -1287,6 +1272,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
// Load configuration from files and environment variables
const config = loadConfig(directory);
initRuntimeConfig(config);
initSessionId(directory);

// Cached getAuth function for tool access
let cachedGetAuth: GetAuth | null = null;
Expand Down Expand Up @@ -1464,7 +1450,7 @@ export const createAntigravityPlugin = (providerId: string) => async (

return {
config: async (opencodeConfig: Record<string, unknown>) => {
applyAntigravityProviderCatalog(opencodeConfig, providerId);
applyAntigravityProviderCatalog(opencodeConfig, providerId, config);
const mutableConfig = opencodeConfig as Record<string, unknown> & {
command?: Record<string, unknown>;
};
Expand Down Expand Up @@ -1627,7 +1613,7 @@ export const createAntigravityPlugin = (providerId: string) => async (

if (accountManager.getAccountCount() === 0) {
return createSyntheticErrorResponse(
"No Antigravity accounts configured. Run `opencode auth login`.",
"[Antigravity Error] No Antigravity accounts configured. Run `opencode auth login`.",
"unknown",
);
}
Expand Down Expand Up @@ -1731,7 +1717,7 @@ export const createAntigravityPlugin = (providerId: string) => async (

if (accountCount === 0) {
return createSyntheticErrorResponse(
"No Antigravity accounts available. Run `opencode auth login`.",
"[Antigravity Error] No Antigravity accounts available. Run `opencode auth login`.",
model ?? "unknown",
);
}
Expand Down Expand Up @@ -1783,7 +1769,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
"error"
);
return createSyntheticErrorResponse(
`Quota protection: All ${accountCount} account(s) are over ${threshold}% usage for ${family}. ` +
`[Antigravity Error] Quota protection: All ${accountCount} account(s) are over ${threshold}% usage for ${family}. ` +
`Quota resets in ${waitTimeFormatted}. ` +
`Add more accounts, wait for quota reset, or set soft_quota_threshold_percent: 100 to disable.`,
model ?? "unknown",
Expand Down Expand Up @@ -1834,7 +1820,7 @@ export const createAntigravityPlugin = (providerId: string) => async (

// Return a proper rate limit error response
return createSyntheticErrorResponse(
`All ${accountCount} account(s) rate-limited for ${family}. ` +
`[Antigravity Error] All ${accountCount} account(s) rate-limited for ${family}. ` +
`Quota resets in ${waitTimeFormatted}. ` +
`Add more accounts with \`opencode auth login\` or wait and retry.`,
model ?? "unknown",
Expand Down Expand Up @@ -1938,7 +1924,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
}

return createSyntheticErrorResponse(
"All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.",
"[Antigravity Error] All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.",
model ?? "unknown",
);
}
Expand All @@ -1964,7 +1950,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
lastError = new Error("Missing access token");
if (accountCount <= 1) {
return createSyntheticErrorResponse(
"Missing access token. Run `opencode auth login` to reauthenticate.",
"[Antigravity Error] Missing access token. Run `opencode auth login` to reauthenticate.",
model ?? "unknown",
);
}
Expand Down Expand Up @@ -2558,6 +2544,35 @@ export const createAntigravityPlugin = (providerId: string) => async (
shouldSwitchAccount = true;
break;
}

// Check for "not eligible" 403 — permanently disable account
const lowerBody = errorBodyText.toLowerCase();
const isIneligible = (
lowerBody.includes("not eligible") ||
lowerBody.includes("ineligible") ||
lowerBody.includes("not available for your account") ||
lowerBody.includes("access denied")

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: Some recoverable 403 responses can now permanently disable an account because the ineligibility matcher includes the generic phrase access denied. Tightening that check to explicit ineligibility phrases avoids false permanent disablement and preserves normal retry/fallback behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/plugin.ts, line 2554:

<comment>Some recoverable 403 responses can now permanently disable an account because the ineligibility matcher includes the generic phrase `access denied`. Tightening that check to explicit ineligibility phrases avoids false permanent disablement and preserves normal retry/fallback behavior.</comment>

<file context>
@@ -2558,6 +2544,35 @@ export const createAntigravityPlugin = (providerId: string) => async (
+                    lowerBody.includes("not eligible") ||
+                    lowerBody.includes("ineligible") ||
+                    lowerBody.includes("not available for your account") ||
+                    lowerBody.includes("access denied")
+                  );
+
</file context>

);

if (isIneligible) {
const ineligibleLabel = account.email || `Account ${account.index + 1}`;
accountManager.markAccountIneligible(account.index, errorBodyText.slice(0, 200));

if (accountManager.shouldShowAccountToast(account.index, 60000)) {
await showToast(
`🚫 ${ineligibleLabel} is not eligible for Antigravity. Permanently disabled.`,
"warning",
);
accountManager.markToastShown(account.index);
}

pushDebug(`ineligible: permanently disabled account ${account.index}`);
getHealthTracker().recordFailure(account.index);

lastFailure = createFailureContext(response);
shouldSwitchAccount = true;
break;
}
}

const shouldRetryEndpoint = (
Expand Down Expand Up @@ -2664,7 +2679,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
// Clean up and return a synthetic response after max attempts
emptyResponseAttempts.delete(emptyAttemptKey);
return createSyntheticErrorResponse(
`Empty response after ${currentAttempts} attempts for model ${prepared.effectiveModel ?? "unknown"}.`,
`[Antigravity Error] Empty response after ${currentAttempts} attempts for model ${prepared.effectiveModel ?? "unknown"}.`,
prepared.effectiveModel ?? "unknown",
);
}
Expand Down Expand Up @@ -2816,7 +2831,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
);
}
return createSyntheticErrorResponse(
lastError?.message || `Exceeded max account switches (${maxAccountSwitches}). All accounts rate-limited.`,
`[Antigravity Error] ${lastError?.message || `Exceeded max account switches (${maxAccountSwitches}). All accounts rate-limited.`}`,
model ?? "unknown",
);
}
Expand All @@ -2841,7 +2856,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
}

return createSyntheticErrorResponse(
lastError?.message || "All Antigravity endpoints failed",
`[Antigravity Error] ${lastError?.message || "All Antigravity endpoints failed"}`,
model ?? "unknown",
);
}
Expand Down Expand Up @@ -2869,7 +2884,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
}

return createSyntheticErrorResponse(
lastError?.message || "All Antigravity accounts failed",
`[Antigravity Error] ${lastError?.message || "All Antigravity accounts failed"}`,
model ?? "unknown",
);
}
Expand Down Expand Up @@ -3851,16 +3866,29 @@ type OpencodeMutableConfig = Record<string, unknown> & {
}>;
};

function applyAntigravityProviderCatalog(config: Record<string, unknown>, providerId: string): void {
const mutableConfig = config as OpencodeMutableConfig;
function applyAntigravityProviderCatalog(
opencodeConfig: Record<string, unknown>,
providerId: string,
pluginConfig: AntigravityConfig
): void {
const mutableConfig = opencodeConfig as OpencodeMutableConfig;
mutableConfig.provider ??= {};

const providerConfig = mutableConfig.provider[providerId] ?? {};

// Merge order (lowest to highest priority):
// 1. Built-in defaults: OPENCODE_MODEL_DEFINITIONS
// 2. Decoupled models (from antigravity.json / antigravity-models.json): pluginConfig.models
// 3. User's main opencode.json models (preserves backwards compatibility / custom overrides)
providerConfig.models = {
...(providerConfig.models ?? {}),
...OPENCODE_MODEL_DEFINITIONS,
...(pluginConfig.models ?? {}),
...(providerConfig.models ?? {}),
};
providerConfig.whitelist = getAntigravityOpencodeModelIds();

// Whitelist should be the union of all registered models so they aren't pruned by OpenCode
providerConfig.whitelist = Object.keys(providerConfig.models);

mutableConfig.provider[providerId] = providerConfig;
}

Expand Down
28 changes: 28 additions & 0 deletions packages/opencode/src/plugin/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ export interface ManagedAccount {
verificationRequiredAt?: number;
verificationRequiredReason?: string;
verificationUrl?: string;
/** Account permanently ineligible for Antigravity */
ineligible?: boolean;

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: Ineligibility metadata is lost on the first disk save, so after restart isAccountIneligible() returns false and the permanent-disable reason/timestamp cannot be recovered. Include these three fields in both the storage snapshot and loaded-account mapping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/plugin/accounts.ts, line 162:

<comment>Ineligibility metadata is lost on the first disk save, so after restart `isAccountIneligible()` returns false and the permanent-disable reason/timestamp cannot be recovered. Include these three fields in both the storage snapshot and loaded-account mapping.</comment>

<file context>
@@ -158,6 +158,10 @@ export interface ManagedAccount {
   verificationRequiredReason?: string;
   verificationUrl?: string;
+  /** Account permanently ineligible for Antigravity */
+  ineligible?: boolean;
+  ineligibleAt?: number;
+  ineligibleReason?: string;
</file context>

ineligibleAt?: number;
ineligibleReason?: string;
/** Daily request counts per model family */
dailyRequestCounts?: {
date: string
Expand Down Expand Up @@ -956,6 +960,30 @@ export class AccountManager {
return true;
}

markAccountIneligible(accountIndex: number, reason?: string): boolean {
const account = this.accounts[accountIndex];
if (!account) {
return false;
}

account.ineligible = true;
account.ineligibleAt = nowMs();
account.ineligibleReason = reason?.trim() || undefined;

if (account.enabled !== false) {
this.setAccountEnabled(accountIndex, false);
} else {
this.requestSaveToDisk();
}

return true;
}

isAccountIneligible(accountIndex: number): boolean {
const account = this.accounts[accountIndex];
return account?.ineligible === true;
}

removeAccountByIndex(accountIndex: number): boolean {
if (accountIndex < 0 || accountIndex >= this.accounts.length) {
return false;
Expand Down
59 changes: 59 additions & 0 deletions packages/opencode/src/plugin/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,56 @@ function mergeConfigs(
* @param directory - The project directory (for project-level config)
* @returns Fully resolved configuration
*/
function stripJsonCommentsAndTrailingCommas(json: string): string {
return json
.replace(
/\\"|"(?:\\"|[^"])*"|(\/{2}.*|\/\*[\s\S]*?\*\/)/g,
(match: string, group: string | undefined) => (group ? "" : match)
)
.replace(/,(\s*[}\]])/g, "$1");

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: Model metadata containing ,} or ,] is silently changed while loading JSONC files because this replacement also matches inside quoted values. Strip trailing commas only outside JSON strings (or use a JSONC parser) so valid model fields remain intact.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/plugin/config/loader.ts, line 127:

<comment>Model metadata containing `,}` or `,]` is silently changed while loading JSONC files because this replacement also matches inside quoted values. Strip trailing commas only outside JSON strings (or use a JSONC parser) so valid model fields remain intact.</comment>

<file context>
@@ -118,6 +118,56 @@ function mergeConfigs(
+      /\\"|"(?:\\"|[^"])*"|(\/{2}.*|\/\*[\s\S]*?\*\/)/g,
+      (match: string, group: string | undefined) => (group ? "" : match)
+    )
+    .replace(/,(\s*[}\]])/g, "$1");
+}
+
</file context>

}

function loadModelsFile(path: string): Record<string, unknown> | null {
try {
if (!existsSync(path)) {
return null;
}
const content = readFileSync(path, "utf-8");
const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(content));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return null;
} catch (error) {
log.warn("Failed to load decoupled models file", { path, error: String(error) });
return null;
}
}

export function loadDecoupledModels(directory: string): Record<string, unknown> {
let mergedModels: Record<string, unknown> = {};

const configDir = getConfigDir();
const userJsonPath = join(configDir, "antigravity-models.json");
const userJsoncPath = join(configDir, "antigravity-models.jsonc");
const projectJsonPath = join(directory, ".opencode", "antigravity-models.json");
const projectJsoncPath = join(directory, ".opencode", "antigravity-models.jsonc");

// User level (prefer jsonc if both exist)
const userModels = loadModelsFile(existsSync(userJsoncPath) ? userJsoncPath : userJsonPath);
if (userModels) {
mergedModels = { ...mergedModels, ...userModels };
}

// Project level (prefer jsonc if both exist) - overrides user level
const projectModels = loadModelsFile(existsSync(projectJsoncPath) ? projectJsoncPath : projectJsonPath);
if (projectModels) {
mergedModels = { ...mergedModels, ...projectModels };
}

return mergedModels;
}

export function loadConfig(directory: string): AntigravityConfig {
// Start with defaults
let config: AntigravityConfig = { ...DEFAULT_CONFIG };
Expand All @@ -136,6 +186,15 @@ export function loadConfig(directory: string): AntigravityConfig {
config = mergeConfigs(config, projectConfig);
}

// Load decoupled models from antigravity-models.json(c) and merge into config.models
const decoupledModels = loadDecoupledModels(directory);
if (Object.keys(decoupledModels).length > 0) {
config.models = {
...(config.models ?? {}),
...decoupledModels,
};
}

return config;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/plugin/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,12 @@ export const AntigravityConfigSchema = z.object({
*/
auto_update: z.boolean().default(true),

/**
* Decoupled model definitions to inject.
*/
models: z.record(z.string(), z.any()).optional(),


});

export type AntigravityConfig = z.infer<typeof AntigravityConfigSchema>;
Expand Down
Loading
Loading