diff --git a/packages/app/src/components/dock-status-popover.tsx b/packages/app/src/components/dock-status-popover.tsx new file mode 100644 index 00000000000..7f7b1f12971 --- /dev/null +++ b/packages/app/src/components/dock-status-popover.tsx @@ -0,0 +1,163 @@ +import { createMemo, For, Show } from "solid-js" +import { useParams } from "@solidjs/router" +import { Popover } from "@opencode-ai/ui/popover" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import type { IconName } from "@opencode-ai/ui/icons/provider" +import { useSync } from "@/context/sync" +import { useLocal } from "@/context/local" +import { useLanguage } from "@/context/language" +import { getSessionContextMetrics } from "@/components/session/session-context-metrics" + +export function DockStatusPopover() { + const sync = useSync() + const params = useParams() + const local = useLocal() + const language = useLanguage() + + const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) + const context = createMemo(() => metrics().context) + + const model = createMemo(() => local.model.current()) + + const fallbacks = createMemo(() => { + const m = model() + if (!m?.fallbacks?.length) return [] + return m.fallbacks.flatMap((f) => { + const p = sync.data.provider.all.find((x) => x.id === f.providerID) + if (!p) return [] + const fm = p.models[f.modelID] + return [{ provider: p, model: fm, providerID: f.providerID, modelID: f.modelID }] + }) + }) + + const usd = createMemo( + () => + new Intl.NumberFormat(language.locale(), { + style: "currency", + currency: "USD", + maximumFractionDigits: 4, + }), + ) + + const cost = createMemo(() => usd().format(metrics().totalCost)) + + const usagePct = createMemo(() => context()?.usage ?? 0) + + // colour band: green → yellow → red + const barColor = createMemo(() => { + const pct = usagePct() + if (pct >= 90) return "bg-icon-critical-base" + if (pct >= 70) return "bg-icon-warning-base" + return "bg-icon-success-base" + }) + + return ( + + } + > +
+ {/* Active model */} + + {(m) => ( +
+ Active model +
+ + + + + {m().provider?.name ?? m().providerID} + + · + {m().name} +
+
+ )} +
+ + {/* Context usage */} + + {(ctx) => ( +
+
+ Context + + {ctx().total.toLocaleString(language.locale())} + + {(lim) => ( + / {lim().toLocaleString(language.locale())} + )} + + tok + +
+ {/* progress bar */} +
+
+
+
+ {usagePct()}% used + {cost()} +
+
+ )} + + + {/* Fallback chain */} + 0}> +
+ Fallback chain +
+ + {(fb, i) => ( +
+ {i() + 1}. + + + + + {fb.provider?.name ?? fb.providerID} + + · + + {fb.model?.name ?? fb.modelID} + +
+ )} +
+
+
+
+ + {/* No data state */} + + No active session + +
+ +
+ ) +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 3ba3763b8c5..502595a2a2f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -55,6 +55,8 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { SessionContextUsage } from "@/components/session-context-usage" +import { DockStatusPopover } from "@/components/dock-status-popover" interface PromptInputProps { class?: string @@ -1460,7 +1462,9 @@ export const PromptInput: Component = (props) => {
-
+
+ + = { + "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", + }, + ]) + }, + }) +})