+
+
+
= {
+ "google-antigravity": "google",
+ "openai-codex": "openai",
+ "minimax-portal": "minimax",
+ anthropic: "anthropic",
+ xai: "xai",
+ mistral: "mistral",
+ cohere: "cohere",
+ groq: "groq",
+ deepseek: "deepseek",
+}
+
export namespace Auth {
export const Oauth = z
.object({
@@ -65,4 +83,193 @@ export namespace Auth {
delete data[key]
await Filesystem.writeJson(filepath, data, 0o600)
}
+
+ /**
+ * Known OAuth token refresh endpoints per provider base name.
+ * Each entry: { tokenUrl, clientId, clientSecret? }
+ * If clientId is empty string, the refresh attempt is skipped (no known public client).
+ */
+ const OAUTH_REFRESH_ENDPOINTS: Record = {
+ google: {
+ tokenUrl: "https://oauth2.googleapis.com/token",
+ // Gemini CLI / Google AI Studio client credentials (public, same as Gemini CLI)
+ clientId: "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
+ clientSecret: "d-FL95Q19q7MQmFpd7hHD0Ty",
+ },
+ anthropic: {
+ tokenUrl: "https://console.anthropic.com/v1/oauth/token",
+ // Anthropic's public client id (base64-encoded, same as Claude.ai)
+ clientId: "9d1c250a-e61e-48f8-b396-8d4e3e816b3d",
+ },
+ }
+
+ /**
+ * Checks whether an OAuth token is expired (with a 60-second buffer).
+ */
+ export function isTokenExpired(auth: z.infer): boolean {
+ return Date.now() >= auth.expires - 60_000
+ }
+
+ /**
+ * Attempts to refresh an OAuth token using the refresh token.
+ * Returns the updated auth info on success, undefined if refresh is not supported or fails.
+ * On success, automatically persists the new token to the auth store.
+ *
+ * Fail-soft: if refresh fails, the caller should still attempt the request
+ * with the potentially-expired token rather than failing hard.
+ */
+ export async function tryRefreshToken(key: string, auth: z.infer): Promise | undefined> {
+ const baseProvider = key.replace(/-[^-]*$/, "") // strip account suffix
+ const endpoints = OAUTH_REFRESH_ENDPOINTS[baseProvider] ?? OAUTH_REFRESH_ENDPOINTS[key]
+ if (!endpoints || !endpoints.clientId) return undefined
+
+ try {
+ const body = new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: auth.refresh,
+ client_id: endpoints.clientId,
+ ...(endpoints.clientSecret ? { client_secret: endpoints.clientSecret } : {}),
+ })
+ const resp = await fetch(endpoints.tokenUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: body.toString(),
+ })
+ if (!resp.ok) return undefined
+ const json = await resp.json() as Record
+ if (typeof json.access_token !== "string") return undefined
+
+ const updated: z.infer = {
+ ...auth,
+ access: json.access_token,
+ expires: typeof json.expires_in === "number" ? Date.now() + json.expires_in * 1000 : auth.expires + 3600_000,
+ ...(typeof json.refresh_token === "string" ? { refresh: json.refresh_token } : {}),
+ }
+ await set(key, updated)
+ return updated
+ } catch {
+ return undefined
+ }
+ }
+
+ /**
+ * Gets an auth credential for a provider, attempting token refresh if it's expired.
+ * Uses fail-soft strategy: if refresh fails, returns the original (possibly expired) credential.
+ */
+ export async function getWithRefresh(providerID: string): Promise {
+ const auth = await get(providerID)
+ if (!auth) return undefined
+ if (auth.type !== "oauth") return auth
+ if (!isTokenExpired(auth)) return auth
+ const refreshed = await tryRefreshToken(providerID, auth)
+ return refreshed ?? auth // fail-soft: return expired token if refresh fails
+ }
+
+ /**
+ * Attempts to find the OpenClaw data directory.
+ * OpenClaw stores data in ~/.openclaw/.openclaw/ by convention.
+ */
+ function openclawDir(): string {
+ return path.join(os.homedir(), ".openclaw", ".openclaw")
+ }
+
+ /**
+ * Result of an OpenClaw import operation.
+ */
+ export type ImportResult = {
+ imported: string[]
+ skipped: string[]
+ errors: string[]
+ }
+
+ /**
+ * Imports credentials from an OpenClaw auth-profiles.json file.
+ *
+ * Searches for the file in standard OpenClaw agent directories.
+ * Maps OpenClaw provider names to opencode provider names using OPENCLAW_PROVIDER_MAP.
+ * For multi-account profiles (e.g. google-antigravity:user@gmail.com), creates
+ * opencode credentials in the format "google-user@gmail.com".
+ *
+ * Returns a summary of what was imported, skipped (already exists), and errored.
+ */
+ export async function importFromOpenClaw(options?: { force?: boolean }): Promise {
+ const result: ImportResult = { imported: [], skipped: [], errors: [] }
+
+ // Find auth-profiles.json — typically in agents/main/agent/ or agents/*/agent/
+ const base = openclawDir()
+ const candidates = [
+ path.join(base, "agents", "main", "agent", "auth-profiles.json"),
+ path.join(base, "auth-profiles.json"),
+ ]
+
+ // Also search agent subdirectories
+ const agentsDir = path.join(base, "agents")
+ const agentDirs = await fs.readdir(agentsDir).catch(() => [] as string[])
+ for (const agent of agentDirs) {
+ candidates.push(path.join(agentsDir, agent, "agent", "auth-profiles.json"))
+ }
+
+ let raw: unknown = undefined
+ for (const candidate of candidates) {
+ raw = await Filesystem.readJson(candidate).catch(() => undefined)
+ if (raw) break
+ }
+
+ if (!raw || typeof raw !== "object" || !("profiles" in raw)) {
+ result.errors.push("No OpenClaw auth-profiles.json found")
+ return result
+ }
+
+ const profiles = (raw as { profiles: Record }).profiles
+
+ for (const [profileKey, profileData] of Object.entries(profiles)) {
+ try {
+ // profileKey format: "provider:accountId" e.g. "google-antigravity:user@gmail.com"
+ const colonIdx = profileKey.indexOf(":")
+ const openclawProvider = colonIdx >= 0 ? profileKey.slice(0, colonIdx) : profileKey
+ const accountId = colonIdx >= 0 ? profileKey.slice(colonIdx + 1) : "default"
+
+ const opencodeProvider = OPENCLAW_PROVIDER_MAP[openclawProvider] ?? openclawProvider
+
+ // Build the opencode credential key
+ // If account is email-like or non-default, suffix it to provider
+ const isDefault = accountId === "default"
+ const credKey = isDefault ? opencodeProvider : `${opencodeProvider}-${accountId}`
+
+ const existing = await get(credKey)
+ if (existing && !options?.force) {
+ result.skipped.push(credKey)
+ continue
+ }
+
+ // Map OpenClaw profile to opencode auth format
+ const profile = profileData as Record
+ let info: Info | undefined
+
+ if (profile.type === "oauth" && typeof profile.access === "string" && typeof profile.refresh === "string") {
+ info = {
+ type: "oauth",
+ access: profile.access,
+ refresh: profile.refresh,
+ expires: typeof profile.expires === "number" ? profile.expires : Date.now() + 3600_000,
+ accountId: typeof profile.accountId === "string" ? profile.accountId : undefined,
+ }
+ } else if (profile.type === "api" && typeof profile.access === "string") {
+ info = { type: "api", key: profile.access }
+ }
+
+ if (!info) {
+ result.errors.push(`${profileKey}: unsupported auth format`)
+ continue
+ }
+
+ await set(credKey, info)
+ result.imported.push(credKey)
+ } catch (e) {
+ result.errors.push(`${profileKey}: ${e instanceof Error ? e.message : String(e)}`)
+ }
+ }
+
+ return result
+ }
}
diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts
index 95635916413..7accd611f14 100644
--- a/packages/opencode/src/cli/cmd/auth.ts
+++ b/packages/opencode/src/cli/cmd/auth.ts
@@ -13,6 +13,7 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
+import { Quota } from "../../provider/quota"
type PluginAuth = NonNullable
@@ -193,11 +194,36 @@ export function resolvePluginProviders(input: {
return result
}
+export function resolveConfigProviders(input: {
+ configProviders: Record
+ existingProviders: Record
+ disabled: Set
+ enabled?: Set
+}): Array<{ id: string; name: string; extends?: string }> {
+ const result: Array<{ id: string; name: string; extends?: string }> = []
+ for (const [id, provider] of Object.entries(input.configProviders)) {
+ if (Object.hasOwn(input.existingProviders, id)) continue
+ if (input.disabled.has(id)) continue
+ if (input.enabled && !input.enabled.has(id)) continue
+ result.push({
+ id,
+ name: provider.name ?? id,
+ extends: provider.extends,
+ })
+ }
+ return result
+}
+
export const AuthCommand = cmd({
command: "auth",
describe: "manage credentials",
builder: (yargs) =>
- yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
+ yargs
+ .command(AuthLoginCommand)
+ .command(AuthLogoutCommand)
+ .command(AuthListCommand)
+ .command(AuthImportOpenClawCommand)
+ .demandCommand(),
async handler() {},
})
@@ -212,11 +238,26 @@ export const AuthListCommand = cmd({
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(await Auth.all())
+ const config = await Config.get()
const database = await ModelsDev.get()
+ // Fetch quota concurrently for all providers, with a per-provider timeout
+ const quotaResults = await Promise.all(
+ results.map(async ([providerID]) => {
+ const status = await Promise.race([
+ Quota.fetch(providerID),
+ new Promise((r) => setTimeout(r, 5000)),
+ ])
+ return [providerID, status] as const
+ }),
+ )
+ const quotaMap = Object.fromEntries(quotaResults)
+
for (const [providerID, result] of results) {
- const name = database[providerID]?.name || providerID
- prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
+ const name = config.provider?.[providerID]?.name || database[providerID]?.name || providerID
+ const quota = quotaMap[providerID]
+ const quotaStr = quota ? UI.Style.TEXT_DIM + " · " + Quota.format(quota) : ""
+ prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}${quotaStr}`)
}
prompts.outro(`${results.length} credentials`)
@@ -321,6 +362,12 @@ export const AuthLoginCommand = cmd({
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
+ const configProviders = resolveConfigProviders({
+ configProviders: config.provider ?? {},
+ existingProviders: providers,
+ disabled,
+ enabled,
+ })
let provider = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
@@ -347,6 +394,11 @@ export const AuthLoginCommand = cmd({
value: x.id,
hint: "plugin",
})),
+ ...configProviders.map((x) => ({
+ label: x.name,
+ value: x.id,
+ hint: x.extends ? `extends ${x.extends}` : "config",
+ })),
{
value: "other",
label: "Other",
@@ -364,8 +416,8 @@ export const AuthLoginCommand = cmd({
if (provider === "other") {
provider = await prompts.text({
- message: "Enter provider id",
- validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
+ message: "Enter provider id (e.g. google-user@gmail.com or anthropic-2)",
+ validate: (x) => (x && x.match(/^[0-9a-z]([0-9a-z\-@._+]+)?$/) ? undefined : "Must start with a-z/0-9 and may contain hyphens, @, dots"),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
@@ -423,6 +475,46 @@ export const AuthLoginCommand = cmd({
},
})
+export const AuthImportOpenClawCommand = cmd({
+ command: "import-openclaw",
+ describe: "import credentials from OpenClaw auth-profiles.json",
+ builder: (yargs) =>
+ yargs.option("force", {
+ type: "boolean",
+ description: "Overwrite existing credentials",
+ default: false,
+ }),
+ async handler(args) {
+ UI.empty()
+ prompts.intro("Import from OpenClaw")
+
+ const spinner = prompts.spinner()
+ spinner.start("Searching for OpenClaw credentials...")
+
+ const result = await Auth.importFromOpenClaw({ force: args.force })
+
+ spinner.stop("Done")
+
+ if (result.imported.length > 0) {
+ prompts.log.success(`Imported: ${result.imported.join(", ")}`)
+ }
+ if (result.skipped.length > 0) {
+ prompts.log.info(`Already exists (skipped): ${result.skipped.join(", ")}`)
+ prompts.log.info("Use --force to overwrite existing credentials")
+ }
+ if (result.errors.length > 0) {
+ for (const err of result.errors) {
+ prompts.log.warn(err)
+ }
+ }
+ if (result.imported.length === 0 && result.errors.length > 0) {
+ prompts.log.error("No credentials were imported")
+ }
+
+ prompts.outro("Done")
+ },
+})
+
export const AuthLogoutCommand = cmd({
command: "logout",
describe: "log out from a configured provider",
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 141f6156985..2dadaecc843 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -930,12 +930,20 @@ export namespace Config {
export const Provider = ModelsDev.Provider.partial()
.extend({
+ extends: z
+ .string()
+ .optional()
+ .describe("Inherit settings and models from another provider, eg `google`"),
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
models: z
.record(
z.string(),
ModelsDev.Model.partial().extend({
+ fallbacks: z
+ .array(ModelId)
+ .optional()
+ .describe("Ordered fallback models in provider/model format"),
variants: z
.record(
z.string(),
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 022ec316795..89959d662ea 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -48,6 +48,424 @@ import { Installation } from "../installation"
export namespace Provider {
const log = Log.create({ service: "provider" })
+ const ANTIGRAVITY: Record<
+ string,
+ { name: string; order: string[]; providers?: string[] }
+ > = {
+ "gemini-3.1-pro": {
+ name: "Gemini 3.1 Pro (Antigravity)",
+ order: ["gemini-3.1-pro-preview"],
+ providers: ["google"],
+ },
+ "gemini-3.1-flash": {
+ name: "Gemini 3.1 Flash (Antigravity)",
+ order: ["gemini-3.1-flash-preview"],
+ providers: ["google"],
+ },
+ "gemini-3.1-flash-image": {
+ name: "Gemini 3.1 Flash Image (Antigravity)",
+ order: ["gemini-3.1-flash-image-preview"],
+ providers: ["google"],
+ },
+ "gemini-3-pro-image": {
+ name: "Gemini 3 Pro Image (Antigravity)",
+ order: ["gemini-3-pro-image-preview"],
+ providers: ["google"],
+ },
+ "gemini-3-pro-high": {
+ name: "Gemini 3 Pro High (Antigravity)",
+ order: ["gemini-3-pro-preview", "gemini-3-pro"],
+ providers: ["google"],
+ },
+ "gemini-3-pro-low": {
+ name: "Gemini 3 Pro Low (Antigravity)",
+ order: ["gemini-3-pro-preview", "gemini-3-pro"],
+ providers: ["google"],
+ },
+ "gemini-3-flash": {
+ name: "Gemini 3 Flash (Antigravity)",
+ order: ["gemini-3-flash-preview", "gemini-3-flash"],
+ providers: ["google"],
+ },
+ "gemini-2.5-pro": {
+ name: "Gemini 2.5 Pro (Antigravity)",
+ order: ["gemini-2.5-pro-preview-06-05", "gemini-2.5-pro-preview-05-06", "gemini-2.5-pro"],
+ providers: ["google"],
+ },
+ "gemini-2.5-flash": {
+ name: "Gemini 2.5 Flash (Antigravity)",
+ order: [
+ "gemini-2.5-flash-preview-09-2025",
+ "gemini-2.5-flash-preview-04-17",
+ "gemini-2.5-flash-preview-05-20",
+ "gemini-2.5-flash",
+ ],
+ providers: ["google"],
+ },
+ "gemini-2.5-flash-lite": {
+ name: "Gemini 2.5 Flash Lite (Antigravity)",
+ order: [
+ "gemini-2.5-flash-lite-preview-09-2025",
+ "gemini-2.5-flash-lite-preview-06-17",
+ "gemini-2.5-flash-lite",
+ ],
+ providers: ["google"],
+ },
+ "gemini-2.0-flash": {
+ name: "Gemini 2.0 Flash (Antigravity)",
+ order: ["gemini-2.0-flash"],
+ providers: ["google"],
+ },
+ "gemini-2.0-flash-lite": {
+ name: "Gemini 2.0 Flash Lite (Antigravity)",
+ order: ["gemini-2.0-flash-lite"],
+ providers: ["google"],
+ },
+ "gemini-1.5-pro": {
+ name: "Gemini 1.5 Pro (Antigravity)",
+ order: ["gemini-1.5-pro"],
+ providers: ["google"],
+ },
+ "gemini-1.5-flash": {
+ name: "Gemini 1.5 Flash (Antigravity)",
+ order: ["gemini-1.5-flash"],
+ providers: ["google"],
+ },
+ "gemini-1.5-flash-8b": {
+ name: "Gemini 1.5 Flash 8B (Antigravity)",
+ order: ["gemini-1.5-flash-8b"],
+ providers: ["google"],
+ },
+ "gemini-live": {
+ name: "Gemini Live (Antigravity)",
+ order: ["gemini-live-2.5-flash"],
+ providers: ["google"],
+ },
+ "gemini-flash-latest": {
+ name: "Gemini Flash Latest (Antigravity)",
+ order: ["gemini-flash-latest"],
+ providers: ["google"],
+ },
+ "gemini-flash-lite": {
+ name: "Gemini Flash Lite (Antigravity)",
+ order: ["gemini-flash-lite-latest"],
+ providers: ["google"],
+ },
+ "gemini-2.5-flash-image": {
+ name: "Gemini 2.5 Flash Image (Antigravity)",
+ order: ["gemini-2.5-flash-image-preview", "gemini-2.5-flash-image"],
+ providers: ["google"],
+ },
+ "gemini-2.5-pro-tts": {
+ name: "Gemini 2.5 Pro TTS (Antigravity)",
+ order: ["gemini-2.5-pro-preview-tts"],
+ providers: ["google"],
+ },
+ "gemini-2.5-flash-tts": {
+ name: "Gemini 2.5 Flash TTS (Antigravity)",
+ order: ["gemini-2.5-flash-preview-tts"],
+ providers: ["google"],
+ },
+ "claude-opus": {
+ name: "Claude Opus (Antigravity)",
+ order: ["claude-opus-4-6", "claude-opus-4-5-20251101", "claude-opus-4-5", "claude-opus-4-1-20250805", "claude-opus-4"],
+ providers: ["anthropic", "claude"],
+ },
+ "claude-sonnet": {
+ name: "Claude Sonnet (Antigravity)",
+ order: [
+ "claude-sonnet-4-6",
+ "claude-sonnet-4-5-20250929",
+ "claude-sonnet-4-5",
+ "claude-sonnet-4-20250514",
+ "claude-sonnet-4",
+ "claude-3-5-sonnet-20241022",
+ "claude-3-5-sonnet-20240620",
+ "claude-3.5-sonnet",
+ ],
+ providers: ["anthropic", "claude"],
+ },
+ "claude-haiku": {
+ name: "Claude Haiku (Antigravity)",
+ order: ["claude-haiku-4-5-20251001", "claude-haiku-4-5", "claude-haiku-4.5", "claude-3.5-haiku"],
+ providers: ["anthropic", "claude"],
+ },
+ "claude-3-7-sonnet": {
+ name: "Claude 3.7 Sonnet (Antigravity)",
+ order: ["claude-3-7-sonnet-20250219", "claude-3-7-sonnet-latest"],
+ providers: ["anthropic", "claude"],
+ },
+ "gpt-5": {
+ name: "GPT-5 (Antigravity)",
+ order: ["gpt-5.2", "gpt-5.2-pro", "gpt-5.1", "gpt-5-pro", "gpt-5"],
+ providers: ["openai", "openrouter"],
+ },
+ "gpt-5-mini": {
+ name: "GPT-5 Mini (Antigravity)",
+ order: ["gpt-5-mini", "gpt-5-nano"],
+ providers: ["openai", "openrouter"],
+ },
+ "gpt-5-codex": {
+ name: "GPT-5 Codex (Antigravity)",
+ order: ["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.1-codex", "gpt-5.1-codex-max", "gpt-5-codex"],
+ providers: ["openai", "openrouter"],
+ },
+ "gpt-4o": {
+ name: "GPT-4o (Antigravity)",
+ order: ["gpt-4o", "gpt-4o-2024-11-20"],
+ providers: ["openai", "openrouter"],
+ },
+ "gpt-4o-mini": {
+ name: "GPT-4o Mini (Antigravity)",
+ order: ["gpt-4o-mini"],
+ providers: ["openai", "openrouter"],
+ },
+ "gpt-4.1": {
+ name: "GPT-4.1 (Antigravity)",
+ order: ["gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"],
+ providers: ["openai", "openrouter"],
+ },
+ "gpt-4": {
+ name: "GPT-4 (Antigravity)",
+ order: ["gpt-4", "gpt-4-turbo", "gpt-4-32k"],
+ providers: ["openai", "openrouter"],
+ },
+ "gpt-3.5-turbo": {
+ name: "GPT-3.5 Turbo (Antigravity)",
+ order: ["gpt-3.5-turbo-0125", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-0613"],
+ providers: ["openai", "openrouter"],
+ },
+ "gpt-oss": {
+ name: "GPT-OSS (Antigravity)",
+ order: ["gpt-oss-120b", "gpt-oss:120b", "gpt-oss-20b", "gpt-oss:20b"],
+ providers: ["openai", "openrouter"],
+ },
+ "o3": {
+ name: "O3 (Antigravity)",
+ order: ["o3", "o3-mini"],
+ providers: ["openai", "openrouter"],
+ },
+ "o4-mini": {
+ name: "O4 Mini (Antigravity)",
+ order: ["o4-mini"],
+ providers: ["openai", "openrouter"],
+ },
+ "gpt-realtime": {
+ name: "GPT Realtime (Antigravity)",
+ order: ["gpt-realtime", "gpt-realtime-1.5", "gpt-realtime-mini"],
+ providers: ["openai"],
+ },
+ "gpt-audio": {
+ name: "GPT Audio (Antigravity)",
+ order: ["gpt-audio", "gpt-audio-1.5", "gpt-audio-mini"],
+ providers: ["openai"],
+ },
+ "gpt-image": {
+ name: "GPT Image (Antigravity)",
+ order: ["gpt-image-1.5", "chatgpt-image-latest", "gpt-image-1"],
+ providers: ["openai"],
+ },
+ "grok-4": {
+ name: "Grok 4 (Antigravity)",
+ // grok-4-0709 is the versioned ID; grok-4 is its alias
+ order: ["grok-4-0709", "grok-4", "grok-4-fast-reasoning", "grok-4-fast-non-reasoning", "grok-4-1-fast-reasoning", "grok-4-1-fast-non-reasoning", "grok-3"],
+ providers: ["xai"],
+ },
+ "grok-code": {
+ name: "Grok Code (Antigravity)",
+ order: ["grok-code-fast-1"],
+ providers: ["xai"],
+ },
+ "grok-3": {
+ name: "Grok 3 (Antigravity)",
+ order: ["grok-3", "grok-3-fast", "grok-3-mini", "grok-3-mini-fast"],
+ providers: ["xai"],
+ },
+ "grok-2": {
+ name: "Grok 2 (Antigravity)",
+ // grok-2-vision-1212 is the versioned ID; grok-2-vision is its alias
+ order: ["grok-2-vision-1212", "grok-2-vision", "grok-2", "grok-2-latest"],
+ providers: ["xai"],
+ },
+ // DeepSeek: only two models exist on the API — deepseek-chat (V3.2 non-thinking)
+ // and deepseek-reasoner (V3.2 thinking). No versioned aliases like deepseek-v3.2 exist.
+ "deepseek-v3": {
+ name: "DeepSeek V3 (Antigravity)",
+ order: ["deepseek-chat"],
+ providers: ["deepseek"],
+ },
+ "deepseek-reasoner": {
+ name: "DeepSeek Reasoner (Antigravity)",
+ order: ["deepseek-reasoner"],
+ providers: ["deepseek"],
+ },
+ "mistral-large": {
+ name: "Mistral Large (Antigravity)",
+ // mistral-large-2512 is the versioned ID; mistral-large-3 and mistral-large are aliases
+ order: ["mistral-large-2512", "mistral-large-3", "mistral-large-latest", "mistral-large-2"],
+ providers: ["mistral"],
+ },
+ "mistral-medium": {
+ name: "Mistral Medium (Antigravity)",
+ // mistral-medium-2508 is Mistral Medium 3.1; mistral-medium-3 is the previous version
+ order: ["mistral-medium-2508", "mistral-medium-3", "mistral-medium-latest"],
+ providers: ["mistral"],
+ },
+ "mistral-small": {
+ name: "Mistral Small (Antigravity)",
+ // mistral-small-2506 is Mistral Small 3.2; mistral-small-3.2 and mistral-small are aliases
+ order: ["mistral-small-2506", "mistral-small-3.2", "mistral-small-latest", "mistral-small-2503"],
+ providers: ["mistral"],
+ },
+ "devstral": {
+ name: "Devstral (Antigravity)",
+ // devstral-2512 is Devstral 2; devstral-2 and devstral are aliases
+ order: ["devstral-2512", "devstral-2", "devstral-latest"],
+ providers: ["mistral"],
+ },
+ "magistral-medium": {
+ name: "Magistral Medium (Antigravity)",
+ // magistral-medium-2509 is Magistral Medium 1.2
+ order: ["magistral-medium-2509", "magistral-medium-latest"],
+ providers: ["mistral"],
+ },
+ "magistral-small": {
+ name: "Magistral Small (Antigravity)",
+ // magistral-small-2509 is Magistral Small 1.2
+ order: ["magistral-small-2509", "magistral-small-latest"],
+ providers: ["mistral"],
+ },
+ "codestral": {
+ name: "Codestral (Antigravity)",
+ // codestral-2508 is the current version; codestral is its alias
+ order: ["codestral-2508", "codestral-latest", "codestral-2501"],
+ providers: ["mistral"],
+ },
+ "ministral": {
+ name: "Ministral (Antigravity)",
+ // ministral-3-14b-2512 and ministral-3-8b-2512 are the latest versioned IDs
+ order: ["ministral-3-14b-2512", "ministral-3-8b-2512", "ministral-8b-2410", "ministral-3b-2410"],
+ providers: ["mistral"],
+ },
+ "command-r": {
+ name: "Command R (Antigravity)",
+ order: ["command-r-plus", "command-r"],
+ providers: ["cohere"],
+ },
+ "command": {
+ name: "Command (Antigravity)",
+ order: ["command-r-plus", "command-r", "command"],
+ providers: ["cohere"],
+ },
+ "qwen3": {
+ name: "Qwen3 (Antigravity)",
+ order: ["qwen3-max", "qwen3-235b-a22b", "qwen3-30b-a3b", "qwen3-32b", "qwen3-8b"],
+ providers: ["alibaba", "openrouter"],
+ },
+ "qwen2.5": {
+ name: "Qwen 2.5 (Antigravity)",
+ order: ["qwen2.5-72b-instruct", "qwen2.5-32b-instruct", "qwen2.5-14b-instruct", "qwen2.5-7b-instruct"],
+ providers: ["alibaba", "openrouter"],
+ },
+ "qwen-coder": {
+ name: "Qwen Coder (Antigravity)",
+ order: ["qwen3-coder-480b", "qwen3-coder-30b-a3b", "qwen2.5-coder-32b", "qwen2.5-coder-7b"],
+ providers: ["alibaba", "openrouter"],
+ },
+ "qwen-vl": {
+ name: "Qwen VL (Antigravity)",
+ order: ["qwen-vl-max", "qwen2.5-vl-72b", "qwen2.5-vl-7b"],
+ providers: ["alibaba", "openrouter"],
+ },
+ "llama-4": {
+ name: "Llama 4 (Antigravity)",
+ order: ["llama-4-maverick", "llama-4-scout"],
+ providers: ["meta", "openrouter"],
+ },
+ "llama-3.3": {
+ name: "Llama 3.3 (Antigravity)",
+ order: ["llama-3.3-70b"],
+ providers: ["meta", "openrouter"],
+ },
+ "llama-3.2": {
+ name: "Llama 3.2 (Antigravity)",
+ order: ["llama-3.2-11b-vision", "llama-3.2-1b"],
+ providers: ["meta", "openrouter"],
+ },
+ "llama-3": {
+ name: "Llama 3 (Antigravity)",
+ order: ["llama-3.1-70b", "llama-3-70b"],
+ providers: ["meta", "openrouter"],
+ },
+ "phi-4": {
+ name: "Phi 4 (Antigravity)",
+ order: ["phi-4-mini"],
+ providers: ["microsoft"],
+ },
+ "phi-3": {
+ name: "Phi 3 (Antigravity)",
+ order: ["phi-3-medium", "phi-3-small"],
+ providers: ["microsoft"],
+ },
+ "gemma-3": {
+ name: "Gemma 3 (Antigravity)",
+ order: ["gemma-3-27b", "gemma-3-12b", "gemma-3-4b", "gemma-3-1b"],
+ providers: ["google"],
+ },
+ "gemma-2": {
+ name: "Gemma 2 (Antigravity)",
+ order: ["gemma-2-27b", "gemma-2-2b"],
+ providers: ["google"],
+ },
+ "kimi-k2": {
+ name: "Kimi K2 (Antigravity)",
+ order: ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo", "kimi-k2"],
+ providers: ["moonshotai", "moonshotai-cn"],
+ },
+ "doubao": {
+ name: "Doubao (Antigravity)",
+ order: ["doubao-seed-1-6-thinking", "doubao-seed-1-8", "doubao-seed-1-6-vision"],
+ providers: ["bytedance"],
+ },
+ "glm-4": {
+ name: "GLM-4 (Antigravity)",
+ order: ["glm-4.7", "glm-4.6", "glm-4.5", "glm-4"],
+ providers: ["zhipuai"],
+ },
+ "glm-4v": {
+ name: "GLM-4V (Antigravity)",
+ order: ["glm-4.6v", "glm-4.5v"],
+ providers: ["zhipuai"],
+ },
+ }
+
+ function shouldApplyAntigravity(providerID: string, aliasProviders?: string[]): boolean {
+ if (!aliasProviders || aliasProviders.length === 0) return true
+ const root = providerBaseID(providerID)
+ return aliasProviders.some((p) => root === p || providerID === p || providerID.startsWith(p + "-"))
+ }
+
+ /**
+ * Extracts the base provider ID from a multi-account provider ID.
+ * Supports formats:
+ * google-user@gmail.com → google
+ * anthropic-2 → anthropic
+ * openai:scope → openai
+ * google → google
+ */
+ function providerBaseID(providerID: string): string {
+ // Handle scoped format: provider:scope
+ const scoped = providerID.split(":")[0]
+ // Handle email-suffix format: provider-user@domain.com
+ // Match the longest known provider prefix followed by a dash and an account identifier
+ // We find the base by stripping a trailing "-" that contains @ or is purely numeric
+ const emailMatch = scoped.match(/^(.*)-([^-]+@.+)$/)
+ if (emailMatch) return emailMatch[1]
+ const numericMatch = scoped.match(/^(.*)-(\d+)$/)
+ if (numericMatch) return numericMatch[1]
+ return scoped
+ }
+
function isGpt5OrLater(modelID: string): boolean {
const match = /^gpt-(\d+)/.exec(modelID)
if (!match) {
@@ -654,6 +1072,14 @@ export namespace Provider {
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()),
release_date: z.string(),
+ fallbacks: z
+ .array(
+ z.object({
+ providerID: z.string(),
+ modelID: z.string(),
+ }),
+ )
+ .optional(),
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
})
.meta({
@@ -757,9 +1183,56 @@ export namespace Provider {
const state = Instance.state(async () => {
using _ = log.time("state")
const config = await Config.get()
+ const configData = config.provider ?? {}
const modelsDev = await ModelsDev.get()
const database = mapValues(modelsDev, fromModelsDevProvider)
+ const roots = new Map()
+ const resolved = new Map()
+
+ function root(providerID: string, seen = new Set()): string {
+ const cached = roots.get(providerID)
+ if (cached) return cached
+ const current = configData[providerID]
+ if (!current?.extends) {
+ roots.set(providerID, providerID)
+ return providerID
+ }
+ if (seen.has(providerID)) {
+ roots.set(providerID, providerID)
+ return providerID
+ }
+ seen.add(providerID)
+ const value: string = configData[current.extends] ? root(current.extends, seen) : current.extends
+ roots.set(providerID, value)
+ return value
+ }
+
+ function resolve(providerID: string, seen = new Set()): Config.Provider | undefined {
+ const cached = resolved.get(providerID)
+ if (cached) return cached
+ const current = configData[providerID]
+ if (!current) return undefined
+ if (!current.extends) {
+ resolved.set(providerID, current)
+ return current
+ }
+ if (seen.has(providerID)) {
+ resolved.set(providerID, current)
+ return current
+ }
+ seen.add(providerID)
+ const value: Config.Provider = configData[current.extends]
+ ? mergeDeep(resolve(current.extends, seen) ?? {}, current)
+ : current
+ resolved.set(providerID, value)
+ return value
+ }
+
+ for (const providerID of Object.keys(configData)) {
+ root(providerID)
+ }
+
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
@@ -778,7 +1251,9 @@ export namespace Provider {
log.info("init")
- const configProviders = Object.entries(config.provider ?? {})
+ const configProviders = Object.keys(configData).map(
+ (providerID) => [providerID, resolve(providerID) ?? configData[providerID]] as const,
+ )
// Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
if (database["github-copilot"]) {
@@ -807,16 +1282,51 @@ export namespace Provider {
providers[providerID] = mergeDeep(match, provider)
}
+ function copyModels(models: Record, providerID: string) {
+ return mapValues(models, (model) => ({
+ ...model,
+ providerID,
+ }))
+ }
+
+ function antigravity(providerID: string, models: Record) {
+ const result = {
+ ...models,
+ }
+ for (const [modelID, alias] of Object.entries(ANTIGRAVITY)) {
+ if (result[modelID]) continue
+ if (!shouldApplyAntigravity(providerID, alias.providers)) continue
+ const source = alias.order
+ .map((id) => result[id])
+ .find((item): item is Model => item !== undefined)
+ if (!source) continue
+ result[modelID] = {
+ ...source,
+ id: modelID,
+ name: alias.name,
+ providerID,
+ }
+ }
+ return result
+ }
+
+ function alias(providerID: string) {
+ const base = providerBaseID(providerID)
+ return database[base] ? base : undefined
+ }
+
// extend database from config
for (const [providerID, provider] of configProviders) {
- const existing = database[providerID]
+ const baseID = roots.get(providerID)
+ const base = database[providerID] ?? (baseID ? database[baseID] : undefined)
+ const source = modelsDev[providerID] ?? (baseID ? modelsDev[baseID] : undefined)
const parsed: Info = {
id: providerID,
- name: provider.name ?? existing?.name ?? providerID,
- env: provider.env ?? existing?.env ?? [],
- options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
+ name: provider.name ?? base?.name ?? providerID,
+ env: provider.env ?? base?.env ?? [],
+ options: mergeDeep(base?.options ?? {}, provider.options ?? {}),
source: "config",
- models: existing?.models ?? {},
+ models: base ? copyModels(base.models, providerID) : {},
}
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
@@ -834,9 +1344,9 @@ export namespace Provider {
model.provider?.npm ??
provider.npm ??
existingModel?.api.npm ??
- modelsDev[providerID]?.npm ??
+ source?.npm ??
"@ai-sdk/openai-compatible",
- url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
+ url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? source?.api,
},
status: model.status ?? existingModel?.status ?? "active",
name,
@@ -878,6 +1388,7 @@ export namespace Provider {
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
family: model.family ?? existingModel?.family ?? "",
release_date: model.release_date ?? existingModel?.release_date ?? "",
+ fallbacks: model.fallbacks ? model.fallbacks.map(parseModel) : existingModel?.fallbacks,
variants: {},
}
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
@@ -887,9 +1398,14 @@ export namespace Provider {
)
parsed.models[modelID] = parsedModel
}
+ parsed.models = antigravity(providerID, parsed.models)
database[providerID] = parsed
}
+ for (const [providerID, provider] of Object.entries(database)) {
+ provider.models = antigravity(providerID, provider.models)
+ }
+
// load env
const env = Env.all()
for (const [providerID, provider] of Object.entries(database)) {
@@ -905,12 +1421,32 @@ export namespace Provider {
// load apikeys
for (const [providerID, provider] of Object.entries(await Auth.all())) {
if (disabled.has(providerID)) continue
+ if (!database[providerID]) {
+ const baseID = alias(providerID)
+ if (baseID) {
+ const base = database[baseID]
+ // Derive a display name from the account suffix (e.g. "user@gmail.com" or "2")
+ const suffix = providerID.slice(baseID.length + 1) // strip "baseID-"
+ const label = suffix.includes("@") ? suffix : providerID
+ database[providerID] = {
+ ...base,
+ id: providerID,
+ name: `${base.name} (${label})`,
+ models: copyModels(base.models, providerID),
+ }
+ roots.set(providerID, roots.get(baseID) ?? baseID)
+ database[providerID].models = antigravity(providerID, database[providerID].models)
+ }
+ }
if (provider.type === "api") {
mergeProvider(providerID, {
source: "api",
key: provider.key,
})
}
+ if (provider.type === "oauth") {
+ mergeProvider(providerID, { source: "api" })
+ }
}
for (const plugin of await Plugin.list()) {
@@ -985,6 +1521,17 @@ export namespace Provider {
mergeProvider(providerID, partial)
}
+ const groups = new Map()
+ for (const providerID of Object.keys(providers)) {
+ const base = roots.get(providerID) ?? providerID
+ const list = groups.get(base) ?? []
+ list.push(providerID)
+ groups.set(base, list)
+ }
+ for (const list of groups.values()) {
+ list.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }))
+ }
+
for (const [providerID, provider] of Object.entries(providers)) {
if (!isProviderAllowed(providerID)) {
delete providers[providerID]
@@ -992,6 +1539,7 @@ export namespace Provider {
}
const configProvider = config.provider?.[providerID]
+ const base = roots.get(providerID) ?? providerID
for (const [modelID, model] of Object.entries(provider.models)) {
model.api.id = model.api.id ?? model.id ?? modelID
@@ -1016,6 +1564,17 @@ export namespace Provider {
(v) => omit(v, ["disabled"]),
)
}
+
+ if (!model.fallbacks || model.fallbacks.length === 0) {
+ const next = (groups.get(base) ?? [])
+ .filter((item) => item !== providerID)
+ .flatMap((item) => {
+ if (!isProviderAllowed(item)) return []
+ if (!providers[item]?.models[modelID]) return []
+ return [{ providerID: item, modelID }]
+ })
+ if (next.length) model.fallbacks = next
+ }
}
if (Object.keys(provider.models).length === 0) {
@@ -1057,7 +1616,17 @@ export namespace Provider {
const baseURL = loadBaseURL(model, options)
if (baseURL !== undefined) options["baseURL"] = baseURL
- if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
+
+ // Fail-soft OAuth token refresh: if stored token is expired, attempt refresh.
+ // On failure, fall through with the original (possibly expired) token — the
+ // retry/fallback mechanism will route to another account on 401.
+ if (options["apiKey"] === undefined && provider.key) {
+ options["apiKey"] = provider.key
+ } else if (options["apiKey"] === undefined) {
+ const auth = await Auth.getWithRefresh(model.providerID)
+ if (auth?.type === "oauth") options["apiKey"] = auth.access
+ else if (auth?.type === "api") options["apiKey"] = auth.key
+ }
if (model.headers)
options["headers"] = {
...options["headers"],
@@ -1167,6 +1736,23 @@ export namespace Provider {
return info
}
+ export async function fallbacks(model: Model) {
+ const seen = new Set([`${model.providerID}/${model.id}`])
+ const queue = [...(model.fallbacks ?? [])]
+ const result: Model[] = []
+ while (queue.length) {
+ const item = queue.shift()!
+ const key = `${item.providerID}/${item.modelID}`
+ if (seen.has(key)) continue
+ const next = await getModel(item.providerID, item.modelID).catch(() => undefined)
+ if (!next) continue
+ seen.add(key)
+ result.push(next)
+ queue.push(...(next.fallbacks ?? []))
+ }
+ return result
+ }
+
export async function getLanguage(model: Model): Promise {
const s = await state()
const key = `${model.providerID}/${model.id}`
diff --git a/packages/opencode/src/provider/quota.ts b/packages/opencode/src/provider/quota.ts
new file mode 100644
index 00000000000..b0961ed244f
--- /dev/null
+++ b/packages/opencode/src/provider/quota.ts
@@ -0,0 +1,158 @@
+import { Auth } from "../auth"
+
+export namespace Quota {
+ export type Status = {
+ used?: number
+ total?: number
+ remaining?: number
+ /** 0–100 percentage used, if calculable */
+ percent?: number
+ resetAt?: Date
+ label?: string
+ error?: string
+ }
+
+ /**
+ * Fetches quota/usage information for a provider.
+ * Returns undefined if no quota API is supported for the provider.
+ * Never throws — errors are captured in the returned Status.
+ */
+ export async function fetch(providerID: string): Promise {
+ const base = providerBaseID(providerID)
+ const handler = HANDLERS[base]
+ if (!handler) return undefined
+ try {
+ return await handler(providerID)
+ } catch (e) {
+ return { error: e instanceof Error ? e.message : String(e) }
+ }
+ }
+
+ /** Format a Status for display in the CLI. */
+ export function format(status: Status): string {
+ if (status.error) return `error: ${status.error}`
+ const parts: string[] = []
+ if (status.label) parts.push(status.label)
+ else if (status.remaining !== undefined && status.total !== undefined) {
+ parts.push(`${status.remaining}/${status.total}`)
+ } else if (status.percent !== undefined) {
+ parts.push(`${Math.round(status.percent)}% used`)
+ } else if (status.remaining !== undefined) {
+ parts.push(`${status.remaining} remaining`)
+ }
+ if (status.resetAt) {
+ const diff = status.resetAt.getTime() - Date.now()
+ if (diff > 0) parts.push(`resets in ${humanDuration(diff)}`)
+ }
+ return parts.join(" · ")
+ }
+
+ function humanDuration(ms: number): string {
+ const s = Math.floor(ms / 1000)
+ if (s < 60) return `${s}s`
+ const m = Math.floor(s / 60)
+ if (m < 60) return `${m}m`
+ const h = Math.floor(m / 60)
+ if (h < 24) return `${h}h ${m % 60}m`
+ return `${Math.floor(h / 24)}d`
+ }
+
+ function providerBaseID(providerID: string): string {
+ const scoped = providerID.split(":")[0]
+ const emailMatch = scoped.match(/^(.*)-([^-]+@.+)$/)
+ if (emailMatch) return emailMatch[1]
+ const numericMatch = scoped.match(/^(.*)-(\d+)$/)
+ if (numericMatch) return numericMatch[1]
+ return scoped
+ }
+
+ type Handler = (providerID: string) => Promise
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Google Antigravity
+ // Uses the internal CodeAssist API that Gemini CLI also uses.
+ // ──────────────────────────────────────────────────────────────────────────
+ async function googleQuota(providerID: string): Promise {
+ const auth = await Auth.getWithRefresh(providerID)
+ if (!auth || auth.type === "wellknown") return { error: "no auth" }
+ const token = auth.type === "oauth" ? auth.access : auth.key
+
+ const resp = await globalThis.fetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED" } }),
+ signal: AbortSignal.timeout(8000),
+ })
+
+ if (!resp.ok) return { error: `HTTP ${resp.status}` }
+ const json = await resp.json() as Record
+
+ const available = json.availablePromptCredits
+ const total = json.monthlyPromptCredits
+ if (typeof available !== "number" || typeof total !== "number") return { error: "unexpected response" }
+
+ const used = total - available
+ const percent = total > 0 ? (used / total) * 100 : undefined
+ return {
+ used,
+ total,
+ remaining: available,
+ percent,
+ label: `${available.toLocaleString()}/${total.toLocaleString()} credits`,
+ }
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Anthropic — rate limit info from response headers on a lightweight call
+ // ──────────────────────────────────────────────────────────────────────────
+ async function anthropicQuota(providerID: string): Promise {
+ const auth = await Auth.getWithRefresh(providerID)
+ if (!auth) return { error: "no auth" }
+ const apiKey = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
+ if (!apiKey) return { error: "no api key" }
+
+ const resp = await globalThis.fetch("https://api.anthropic.com/v1/models", {
+ method: "GET",
+ headers: {
+ "x-api-key": apiKey,
+ "anthropic-version": "2023-06-01",
+ },
+ signal: AbortSignal.timeout(8000),
+ })
+
+ // We only care about the rate-limit headers, not the body
+ const remaining = Number(resp.headers.get("anthropic-ratelimit-requests-remaining") ?? NaN)
+ const limit = Number(resp.headers.get("anthropic-ratelimit-requests-limit") ?? NaN)
+ const resetAt = resp.headers.get("anthropic-ratelimit-requests-reset")
+
+ if (!resp.ok && resp.status !== 200) return { error: `HTTP ${resp.status}` }
+
+ const parts: string[] = []
+ if (!isNaN(remaining) && !isNaN(limit)) {
+ parts.push(`${remaining}/${limit} req/min`)
+ }
+
+ const tokenRemaining = Number(resp.headers.get("anthropic-ratelimit-tokens-remaining") ?? NaN)
+ const tokenLimit = Number(resp.headers.get("anthropic-ratelimit-tokens-limit") ?? NaN)
+ if (!isNaN(tokenRemaining) && !isNaN(tokenLimit)) {
+ parts.push(`${tokenRemaining.toLocaleString()}/${tokenLimit.toLocaleString()} tok/min`)
+ }
+
+ return {
+ remaining: isNaN(remaining) ? undefined : remaining,
+ total: isNaN(limit) ? undefined : limit,
+ percent: !isNaN(remaining) && !isNaN(limit) && limit > 0 ? ((limit - remaining) / limit) * 100 : undefined,
+ resetAt: resetAt ? new Date(resetAt) : undefined,
+ label: parts.length > 0 ? parts.join(", ") : undefined,
+ }
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ const HANDLERS: Record = {
+ google: googleQuota,
+ anthropic: anthropicQuota,
+ }
+}
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index e7532d20073..456cd55a5d5 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -42,7 +42,7 @@ export namespace SessionProcessor {
partFromToolCall(toolCallID: string) {
return toolcalls[toolCallID]
},
- async process(streamInput: LLM.StreamInput) {
+ async process(streamInput: LLM.StreamInput & { maxRetries?: number }) {
log.info("process")
needsCompaction = false
const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true
@@ -359,15 +359,17 @@ export namespace SessionProcessor {
const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
attempt++
- const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
- SessionStatus.set(input.sessionID, {
- type: "retry",
- attempt,
- message: retry,
- next: Date.now() + delay,
- })
- await SessionRetry.sleep(delay, input.abort).catch(() => {})
- continue
+ if (streamInput.maxRetries === undefined || attempt <= streamInput.maxRetries) {
+ const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
+ SessionStatus.set(input.sessionID, {
+ type: "retry",
+ attempt,
+ message: retry,
+ next: Date.now() + delay,
+ })
+ await SessionRetry.sleep(delay, input.abort).catch(() => {})
+ continue
+ }
}
input.assistantMessage.error = error
Bus.publish(Session.Event.Error, {
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 75bd3c9dfac..d95aa59fd2e 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -41,6 +41,7 @@ import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { PermissionNext } from "@/permission/next"
import { SessionStatus } from "./status"
+import { SessionRetry } from "./retry"
import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
@@ -59,6 +60,8 @@ IMPORTANT:
const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
+const MODEL_RETRY_LIMIT_WITH_FALLBACK = 2
+
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
@@ -563,62 +566,6 @@ export namespace SessionPrompt {
session,
})
- const processor = SessionProcessor.create({
- assistantMessage: (await Session.updateMessage({
- id: Identifier.ascending("message"),
- parentID: lastUser.id,
- role: "assistant",
- mode: agent.name,
- agent: agent.name,
- variant: lastUser.variant,
- path: {
- cwd: Instance.directory,
- root: Instance.worktree,
- },
- cost: 0,
- tokens: {
- input: 0,
- output: 0,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- },
- modelID: model.id,
- providerID: model.providerID,
- time: {
- created: Date.now(),
- },
- sessionID,
- })) as MessageV2.Assistant,
- sessionID: sessionID,
- model,
- abort,
- })
- using _ = defer(() => InstructionPrompt.clear(processor.message.id))
-
- // Check if user explicitly invoked an agent via @ in this turn
- const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
- const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
-
- const tools = await resolveTools({
- agent,
- session,
- model,
- tools: lastUser.tools,
- processor,
- bypassAgentCheck,
- messages: msgs,
- })
-
- // Inject StructuredOutput tool if JSON schema mode enabled
- if (lastUser.format?.type === "json_schema") {
- tools["StructuredOutput"] = createStructuredOutputTool({
- schema: lastUser.format.schema,
- onSuccess(output) {
- structuredOutput = output
- },
- })
- }
-
if (step === 1) {
SessionSummary.summarize({
sessionID: sessionID,
@@ -647,57 +594,135 @@ export namespace SessionPrompt {
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
- // Build system prompt, adding structured output instruction if needed
- const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]
const format = lastUser.format ?? { type: "text" }
- if (format.type === "json_schema") {
- system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
- }
+ const candidates = [model, ...(await Provider.fallbacks(model))]
+ let result: SessionProcessor.Result = "stop"
+ for (const [index, model] of candidates.entries()) {
+ const processor = SessionProcessor.create({
+ assistantMessage: (await Session.updateMessage({
+ id: Identifier.ascending("message"),
+ parentID: lastUser.id,
+ role: "assistant",
+ mode: agent.name,
+ agent: agent.name,
+ variant: lastUser.variant,
+ path: {
+ cwd: Instance.directory,
+ root: Instance.worktree,
+ },
+ cost: 0,
+ tokens: {
+ input: 0,
+ output: 0,
+ reasoning: 0,
+ cache: { read: 0, write: 0 },
+ },
+ modelID: model.id,
+ providerID: model.providerID,
+ time: {
+ created: Date.now(),
+ },
+ sessionID,
+ })) as MessageV2.Assistant,
+ sessionID,
+ model,
+ abort,
+ })
+ using _ = defer(() => InstructionPrompt.clear(processor.message.id))
- const result = await processor.process({
- user: lastUser,
- agent,
- abort,
- sessionID,
- system,
- messages: [
- ...MessageV2.toModelMessages(msgs, model),
- ...(isLastStep
- ? [
- {
- role: "assistant" as const,
- content: MAX_STEPS,
- },
- ]
- : []),
- ],
- tools,
- model,
- toolChoice: format.type === "json_schema" ? "required" : undefined,
- })
+ // Check if user explicitly invoked an agent via @ in this turn
+ const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
+ const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
- // If structured output was captured, save it and exit immediately
- // This takes priority because the StructuredOutput tool was called successfully
- if (structuredOutput !== undefined) {
- processor.message.structured = structuredOutput
- processor.message.finish = processor.message.finish ?? "stop"
- await Session.updateMessage(processor.message)
- break
- }
+ const tools = await resolveTools({
+ agent,
+ session,
+ model,
+ tools: lastUser.tools,
+ processor,
+ bypassAgentCheck,
+ messages: msgs,
+ })
- // Check if model finished (finish reason is not "tool-calls" or "unknown")
- const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)
+ // Inject StructuredOutput tool if JSON schema mode enabled
+ if (lastUser.format?.type === "json_schema") {
+ tools["StructuredOutput"] = createStructuredOutputTool({
+ schema: lastUser.format.schema,
+ onSuccess(output) {
+ structuredOutput = output
+ },
+ })
+ }
- if (modelFinished && !processor.message.error) {
+ // Build system prompt, adding structured output instruction if needed
+ const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]
if (format.type === "json_schema") {
- // Model stopped without calling StructuredOutput tool
- processor.message.error = new MessageV2.StructuredOutputError({
- message: "Model did not produce structured output",
- retries: 0,
- }).toObject()
+ system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
+ }
+
+ result = await processor.process({
+ user: lastUser,
+ agent,
+ abort,
+ sessionID,
+ system,
+ messages: [
+ ...MessageV2.toModelMessages(msgs, model),
+ ...(isLastStep
+ ? [
+ {
+ role: "assistant" as const,
+ content: MAX_STEPS,
+ },
+ ]
+ : []),
+ ],
+ tools,
+ model,
+ toolChoice: format.type === "json_schema" ? "required" : undefined,
+ maxRetries: candidates.length > 1 ? MODEL_RETRY_LIMIT_WITH_FALLBACK : undefined,
+ })
+
+ // If structured output was captured, save it and exit immediately
+ // This takes priority because the StructuredOutput tool was called successfully
+ if (structuredOutput !== undefined) {
+ processor.message.structured = structuredOutput
+ processor.message.finish = processor.message.finish ?? "stop"
await Session.updateMessage(processor.message)
break
}
+
+ // Check if model finished (finish reason is not "tool-calls" or "unknown")
+ const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)
+
+ if (modelFinished && !processor.message.error) {
+ if (format.type === "json_schema") {
+ // Model stopped without calling StructuredOutput tool
+ processor.message.error = new MessageV2.StructuredOutputError({
+ message: "Model did not produce structured output",
+ retries: 0,
+ }).toObject()
+ await Session.updateMessage(processor.message)
+ break
+ }
+ }
+
+ const retryable = processor.message.error ? SessionRetry.retryable(processor.message.error) : undefined
+ if (retryable && index < candidates.length - 1) {
+ const next = candidates[index + 1]
+ log.info("fallback", {
+ from: `${model.providerID}/${model.id}`,
+ to: `${next.providerID}/${next.id}`,
+ reason: retryable,
+ })
+ continue
+ }
+
+ break
+ }
+
+ if (structuredOutput !== undefined) {
+ break
}
if (result === "stop") break
diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts
index 6d057f539f8..8d27c9830ed 100644
--- a/packages/opencode/src/session/retry.ts
+++ b/packages/opencode/src/session/retry.ts
@@ -62,7 +62,13 @@ export namespace SessionRetry {
// context overflow errors should not be retried
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) {
- if (!error.data.isRetryable) return undefined
+ if (!error.data.isRetryable) {
+ // 401 Unauthorized: token may be expired — allow fallback to another account
+ if (error.data.statusCode === 401 || error.data.statusCode === 403) {
+ return "Auth token expired or invalid — trying next account"
+ }
+ return undefined
+ }
if (error.data.responseBody?.includes("FreeUsageLimitError"))
return `Free usage exceeded, add credits https://opencode.ai/zen`
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
diff --git a/packages/opencode/test/cli/plugin-auth-picker.test.ts b/packages/opencode/test/cli/plugin-auth-picker.test.ts
index 3ce9094e92c..9a2be0ec39d 100644
--- a/packages/opencode/test/cli/plugin-auth-picker.test.ts
+++ b/packages/opencode/test/cli/plugin-auth-picker.test.ts
@@ -1,5 +1,5 @@
import { test, expect, describe } from "bun:test"
-import { resolvePluginProviders } from "../../src/cli/cmd/auth"
+import { resolveConfigProviders, resolvePluginProviders } from "../../src/cli/cmd/auth"
import type { Hooks } from "@opencode-ai/plugin"
function hookWithAuth(provider: string): Hooks {
@@ -118,3 +118,69 @@ describe("resolvePluginProviders", () => {
expect(result).toEqual([])
})
})
+
+describe("resolveConfigProviders", () => {
+ test("returns config-only providers not in models.dev", () => {
+ const result = resolveConfigProviders({
+ configProviders: {
+ "google-1": {
+ extends: "google",
+ name: "Google Account 1",
+ },
+ },
+ existingProviders: { google: {} },
+ disabled: new Set(),
+ })
+ expect(result).toEqual([
+ {
+ id: "google-1",
+ name: "Google Account 1",
+ extends: "google",
+ },
+ ])
+ })
+
+ test("respects disabled and enabled provider filters", () => {
+ const disabled = resolveConfigProviders({
+ configProviders: {
+ "google-1": {
+ extends: "google",
+ },
+ },
+ existingProviders: {},
+ disabled: new Set(["google-1"]),
+ })
+ expect(disabled).toEqual([])
+
+ const enabled = resolveConfigProviders({
+ configProviders: {
+ "google-1": {
+ extends: "google",
+ },
+ },
+ existingProviders: {},
+ disabled: new Set(),
+ enabled: new Set(["google-1"]),
+ })
+ expect(enabled).toEqual([
+ {
+ id: "google-1",
+ name: "google-1",
+ extends: "google",
+ },
+ ])
+ })
+
+ test("skips providers already present in models.dev", () => {
+ const result = resolveConfigProviders({
+ configProviders: {
+ google: {
+ name: "Google",
+ },
+ },
+ existingProviders: { google: {} },
+ disabled: new Set(),
+ })
+ expect(result).toEqual([])
+ })
+})
diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts
index 0a5aa415131..bdf0d577356 100644
--- a/packages/opencode/test/provider/provider.test.ts
+++ b/packages/opencode/test/provider/provider.test.ts
@@ -2,6 +2,7 @@ import { test, expect } from "bun:test"
import path from "path"
import { tmpdir } from "../fixture/fixture"
+import { Auth } from "../../src/auth"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { Env } from "../../src/env"
@@ -2218,3 +2219,305 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
},
})
})
+
+test("google aliases inherit models from base provider", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ "google-1": {
+ name: "Google Account 1",
+ extends: "google",
+ },
+ "google-2": {
+ extends: "google",
+ },
+ },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["google-1"]).toBeDefined()
+ expect(providers["google-2"]).toBeDefined()
+ expect(providers["google-1"].name).toBe("Google Account 1")
+ expect(providers["google-1"].models["gemini-3-pro-preview"]).toBeDefined()
+ },
+ })
+})
+
+test("google aliases include all major Gemini models", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ "google-1": {
+ extends: "google",
+ },
+ },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ const google1 = providers["google-1"]
+ expect(google1).toBeDefined()
+ expect(google1.models["gemini-3-pro-high"]).toBeDefined()
+ expect(google1.models["gemini-3-pro-low"]).toBeDefined()
+ expect(google1.models["gemini-3-flash"]).toBeDefined()
+ expect(google1.models["gemini-2.5-pro"]).toBeDefined()
+ expect(google1.models["gemini-2.5-flash"]).toBeDefined()
+ expect(google1.models["gemini-2.5-flash-lite"]).toBeDefined()
+ expect(google1.models["gemini-2.0-flash"]).toBeDefined()
+ expect(google1.models["gemini-2.0-flash-lite"]).toBeDefined()
+ expect(google1.models["gemini-1.5-pro"]).toBeDefined()
+ expect(google1.models["gemini-1.5-flash"]).toBeDefined()
+ expect(google1.models["gemini-1.5-flash-8b"]).toBeDefined()
+ },
+ })
+})
+
+test("google aliases can be loaded directly from auth profiles", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await Auth.set("google-1", { type: "api", key: "key-1" })
+ await Auth.set("google-2", { type: "api", key: "key-2" })
+ try {
+ const providers = await Provider.list()
+ expect(providers["google-1"]).toBeDefined()
+ expect(providers["google-2"]).toBeDefined()
+ expect(providers["google-1"].models["gemini-3-pro-preview"]).toBeDefined()
+ } finally {
+ await Auth.remove("google-1")
+ await Auth.remove("google-2")
+ }
+ },
+ })
+})
+
+test("google provider auto-adds antigravity gemini aliases", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ const provider = providers["google"]
+ expect(provider).toBeDefined()
+ expect(provider.models["gemini-3-pro-high"]).toBeDefined()
+ expect(provider.models["gemini-3-pro-low"]).toBeDefined()
+ expect(provider.models["gemini-3-flash"]).toBeDefined()
+ expect(provider.models["gemini-3-pro-high"].api.id).toBe(provider.models["gemini-3-pro-preview"].api.id)
+ },
+ })
+})
+
+test("google aliases receive automatic cross-account fallbacks", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ "google-1": {
+ extends: "google",
+ },
+ "google-2": {
+ extends: "google",
+ },
+ },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
+ },
+ fn: async () => {
+ const model = await Provider.getModel("google-1", "gemini-3-pro-preview")
+ const resolved = await Provider.fallbacks(model)
+ const refs = resolved.map((item) => `${item.providerID}/${item.id}`)
+ expect(refs).toContain("google-2/gemini-3-pro-preview")
+ },
+ })
+})
+
+test("email-format multi-account: google-user@gmail.com resolves to google base", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ await Auth.set("google-user@gmail.com", { type: "api", key: "test-google-1" })
+ await Auth.set("google-work@company.com", { type: "api", key: "test-google-2" })
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ // Both email-format providers should resolve to google base
+ expect(providers["google-user@gmail.com"]).toBeDefined()
+ expect(providers["google-work@company.com"]).toBeDefined()
+ // They should have google's models (including antigravity aliases)
+ expect(providers["google-user@gmail.com"].models["gemini-2.5-pro"]).toBeDefined()
+ expect(providers["google-work@company.com"].models["gemini-2.5-pro"]).toBeDefined()
+ // Display name should include the email
+ expect(providers["google-user@gmail.com"].name).toContain("user@gmail.com")
+ },
+ })
+})
+
+test("email-format multi-account: any provider supports email-suffix accounts", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ await Auth.set("anthropic-personal@gmail.com", { type: "api", key: "sk-ant-test-1" })
+ await Auth.set("openai-work@company.com", { type: "api", key: "sk-openai-test-2" })
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ // Email-format accounts should be created for any provider
+ expect(providers["anthropic-personal@gmail.com"]).toBeDefined()
+ expect(providers["openai-work@company.com"]).toBeDefined()
+ // They should inherit their base provider's models
+ expect(Object.keys(providers["anthropic-personal@gmail.com"].models).length).toBeGreaterThan(0)
+ expect(Object.keys(providers["openai-work@company.com"].models).length).toBeGreaterThan(0)
+ },
+ })
+})
+
+test("email-format accounts get cross-account fallbacks", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ await Auth.set("google-user@gmail.com", { type: "api", key: "test-google-1" })
+ await Auth.set("google-work@company.com", { type: "api", key: "test-google-2" })
+ },
+ fn: async () => {
+ const model = await Provider.getModel("google-user@gmail.com", "gemini-2.5-pro")
+ const resolved = await Provider.fallbacks(model)
+ const refs = resolved.map((item) => `${item.providerID}/${item.id}`)
+ // Should fall back to the other google account
+ expect(refs).toContain("google-work@company.com/gemini-2.5-pro")
+ },
+ })
+})
+
+test("explicit fallback config overrides automatic account fallback", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ "google-1": {
+ extends: "google",
+ models: {
+ "gemini-3-pro-preview": {
+ fallbacks: ["google/gemini-3-flash-preview"],
+ },
+ },
+ },
+ "google-2": {
+ extends: "google",
+ },
+ },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
+ },
+ fn: async () => {
+ const model = await Provider.getModel("google-1", "gemini-3-pro-preview")
+ expect(model.fallbacks).toEqual([
+ {
+ providerID: "google",
+ modelID: "gemini-3-flash-preview",
+ },
+ ])
+ },
+ })
+})