diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts
index 112e2bc60a1a..1c859b868490 100644
--- a/packages/app/script/e2e-local.ts
+++ b/packages/app/script/e2e-local.ts
@@ -63,6 +63,7 @@ const serverEnv = {
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
+ OPENCODE_DISABLE_OLLAMA_CHECK: "true",
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
XDG_DATA_HOME: path.join(sandbox, "share"),
XDG_CACHE_HOME: path.join(sandbox, "cache"),
diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx
index 90f4f41f7c6f..a1d11184fb38 100644
--- a/packages/app/src/components/dialog-connect-provider.tsx
+++ b/packages/app/src/components/dialog-connect-provider.tsx
@@ -301,9 +301,48 @@ export function DialogConnectProvider(props: { provider: string }) {
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
-
+
+
+
+
+
+
+
+
)
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 82d562bb093b..c80dd701a4c8 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -36,13 +36,13 @@
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@standard-schema/spec": "1.0.0",
- "@tsconfig/bun": "catalog:",
+ "@tsconfig/bun": "1.0.9",
"@types/babel__core": "7.20.5",
- "@types/bun": "catalog:",
+ "@types/bun": "1.3.9",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
- "@typescript/native-preview": "catalog:",
- "typescript": "catalog:",
+ "@typescript/native-preview": "7.0.0-dev.20251207.1",
+ "typescript": "5.8.2",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
"zod-to-json-schema": "3.24.5"
@@ -74,11 +74,11 @@
"@gitlab/gitlab-ai-provider": "3.5.0",
"@gitlab/opencode-gitlab-auth": "1.3.2",
"@hono/standard-validator": "0.1.5",
- "@hono/zod-validator": "catalog:",
+ "@hono/zod-validator": "0.4.2",
"@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2",
- "@octokit/rest": "catalog:",
- "@openauthjs/openauth": "catalog:",
+ "@octokit/rest": "22.0.0",
+ "@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -87,40 +87,40 @@
"@opentui/core": "0.1.79",
"@opentui/solid": "0.1.79",
"@parcel/watcher": "2.5.1",
- "@pierre/diffs": "catalog:",
+ "@pierre/diffs": "1.1.0-beta.13",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
- "ai": "catalog:",
+ "ai": "5.0.124",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
- "diff": "catalog:",
+ "diff": "8.0.2",
"fuzzysort": "3.1.0",
"gray-matter": "4.0.3",
- "hono": "catalog:",
- "hono-openapi": "catalog:",
+ "hono": "4.10.7",
+ "hono-openapi": "1.1.2",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
- "remeda": "catalog:",
- "solid-js": "catalog:",
+ "remeda": "2.26.0",
+ "solid-js": "1.9.10",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"turndown": "7.2.0",
- "ulid": "catalog:",
+ "ulid": "3.0.1",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
- "zod": "catalog:",
+ "zod": "4.1.8",
"zod-to-json-schema": "3.24.5"
}
}
diff --git a/packages/opencode/src/auth/google.ts b/packages/opencode/src/auth/google.ts
new file mode 100644
index 000000000000..958678b77213
--- /dev/null
+++ b/packages/opencode/src/auth/google.ts
@@ -0,0 +1,143 @@
+import * as prompts from "@clack/prompts"
+import { Auth } from "./index"
+import open from "open"
+
+export namespace GoogleAuth {
+ const CALLBACK_PORT = 45961
+ const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/oauth2callback`
+
+ // NOTE: These would typically come from an environment variable or a configuration file.
+ // For the purpose of this implementation, we assume the user might provide them
+ // or they are baked into the CLI if it's an official integration.
+ // If OpenCode has its own proxy for this, the URL would point there.
+ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
+ const TOKEN_URL = "https://oauth2.googleapis.com/token"
+
+ export async function loginWeb(redirectUrl?: string) {
+ const existing = await Auth.get("google")
+
+ // OpenCode requires client ID and secret to be provided via environment variables
+ // or configured in the auth provider settings.
+ const DEFAULT_CLIENT_ID = ""
+ const DEFAULT_CLIENT_SECRET = ""
+
+ let clientId = process.env.GOOGLE_CLIENT_ID || (existing?.type === "oauth" ? existing.clientId : undefined) || DEFAULT_CLIENT_ID
+ let clientSecret = process.env.GOOGLE_CLIENT_SECRET || (existing?.type === "oauth" ? existing.clientSecret : undefined) || DEFAULT_CLIENT_SECRET
+
+ const state = Math.random().toString(36).substring(7)
+ const scope = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
+
+ const url = new URL(AUTH_URL)
+ url.searchParams.set("client_id", clientId)
+ url.searchParams.set("redirect_uri", REDIRECT_URI)
+ url.searchParams.set("response_type", "code")
+ url.searchParams.set("scope", scope)
+ url.searchParams.set("state", state)
+ url.searchParams.set("access_type", "offline")
+ url.searchParams.set("prompt", "consent")
+
+ prompts.log.info("Opening browser for Google Authentication...")
+ await open(url.toString())
+
+ const spinner = prompts.spinner()
+ spinner.start("Waiting for authorization...")
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ server.stop()
+ spinner.stop("Login timed out", 1)
+ resolve()
+ }, 5 * 60 * 1000) // 5 minutes timeout
+
+ const server = Bun.serve({
+ port: CALLBACK_PORT,
+ hostname: "127.0.0.1",
+ async fetch(req) {
+ const reqUrl = new URL(req.url)
+ if (reqUrl.pathname === "/oauth2callback") {
+ const code = reqUrl.searchParams.get("code")
+ const returnedState = reqUrl.searchParams.get("state")
+
+ if (returnedState !== state) {
+ const msg = "Authentication failed: State mismatch"
+ spinner.stop(msg, 1)
+ clearTimeout(timeout)
+ setTimeout(() => {
+ server.stop()
+ resolve()
+ }, 1000)
+ return new Response(msg, { status: 400 })
+ }
+
+ if (!code) {
+ const msg = "Authentication failed: No code received"
+ spinner.stop(msg, 1)
+ clearTimeout(timeout)
+ setTimeout(() => {
+ server.stop()
+ resolve()
+ }, 1000)
+ return new Response(msg, { status: 400 })
+ }
+
+ let success = false
+ let errorMsg = ""
+
+ try {
+ const tokenResponse = await fetch(TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ code,
+ client_id: clientId!,
+ client_secret: clientSecret!,
+ redirect_uri: REDIRECT_URI,
+ grant_type: "authorization_code",
+ }),
+ })
+
+ const tokens = await tokenResponse.json() as any
+
+ if (tokens.error) {
+ errorMsg = tokens.error_description || tokens.error
+ spinner.stop(`Token exchange failed: ${errorMsg}`, 1)
+ } else {
+ await Auth.set("google", {
+ type: "oauth",
+ access: tokens.access_token,
+ refresh: tokens.refresh_token,
+ expires: Date.now() + tokens.expires_in * 1000,
+ clientId,
+ clientSecret,
+ })
+ spinner.stop("Google Login successful")
+ success = true
+ }
+ } catch (err) {
+ errorMsg = err instanceof Error ? err.message : String(err)
+ spinner.stop(`Error during token exchange: ${errorMsg}`, 1)
+ console.error(err)
+ }
+
+ clearTimeout(timeout)
+ // Small delay to ensure user sees the "Success" message in browser before server stops
+ setTimeout(() => {
+ server.stop()
+ resolve()
+ }, 1000)
+
+ if (success) {
+ if (redirectUrl) {
+ return Response.redirect(redirectUrl)
+ }
+ return new Response("Authentication successful! You can close this window and return to the CLI.")
+ } else {
+ return new Response(`Authentication failed: ${errorMsg}`, { status: 500 })
+ }
+ }
+ return new Response("Not Found", { status: 404 })
+ },
+ })
+ })
+ }
+}
diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts
index ce948b92ac80..05509b151a42 100644
--- a/packages/opencode/src/auth/index.ts
+++ b/packages/opencode/src/auth/index.ts
@@ -13,6 +13,8 @@ export namespace Auth {
expires: z.number(),
accountId: z.string().optional(),
enterpriseUrl: z.string().optional(),
+ clientId: z.string().optional(),
+ clientSecret: z.string().optional(),
})
.meta({ ref: "OAuth" })
diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts
index 34e2269d0c16..ebd2801abb9c 100644
--- a/packages/opencode/src/cli/cmd/auth.ts
+++ b/packages/opencode/src/cli/cmd/auth.ts
@@ -164,7 +164,7 @@ export const AuthCommand = cmd({
describe: "manage credentials",
builder: (yargs) =>
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
- async handler() {},
+ async handler() { },
})
export const AuthListCommand = cmd({
@@ -214,20 +214,34 @@ export const AuthListCommand = cmd({
},
})
+import { GoogleAuth } from "../../auth/google"
+
export const AuthLoginCommand = cmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
- yargs.positional("url", {
- describe: "opencode auth provider",
- type: "string",
- }),
+ yargs
+ .positional("url", {
+ describe: "opencode auth provider",
+ type: "string",
+ })
+ .option("web", {
+ describe: "use web-based login (OAuth)",
+ type: "boolean",
+ default: false,
+ }),
async handler(args) {
await Instance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Add credential")
+ if (args.url === "google" && args.web) {
+ await GoogleAuth.loginWeb()
+ prompts.outro("Done")
+ return
+ }
+
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
@@ -251,7 +265,7 @@ export const AuthLoginCommand = cmd({
prompts.outro("Done")
return
}
- await ModelsDev.refresh().catch(() => {})
+ await ModelsDev.refresh().catch(() => { })
const config = await Config.get()
@@ -307,6 +321,12 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(provider)) throw new UI.CancelledError()
+ if (provider === "google" && args.web) {
+ await GoogleAuth.loginWeb()
+ prompts.outro("Done")
+ return
+ }
+
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
@@ -337,10 +357,10 @@ export const AuthLoginCommand = cmd({
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
- " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
- " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
- "Configure via opencode.json options (profile, region, endpoint) or\n" +
- "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
+ " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
+ " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
+ "Configure via opencode.json options (profile, region, endpoint) or\n" +
+ "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 44bcf8adb3de..347da0a13d9c 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -505,7 +505,7 @@ export namespace Provider {
if (!apiToken) {
throw new Error(
"CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " +
- "Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.",
+ "Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.",
)
}
@@ -525,6 +525,18 @@ export namespace Provider {
options: {},
}
},
+ google: async (input) => {
+ const auth = await Auth.get(input.id)
+ if (auth?.type === "oauth") {
+ return {
+ autoload: true,
+ options: {
+ apiKey: auth.access,
+ },
+ }
+ }
+ return { autoload: false }
+ },
cerebras: async () => {
return {
autoload: false,
@@ -646,13 +658,13 @@ export namespace Provider {
},
experimentalOver200K: model.cost?.context_over_200k
? {
- cache: {
- read: model.cost.context_over_200k.cache_read ?? 0,
- write: model.cost.context_over_200k.cache_write ?? 0,
- },
- input: model.cost.context_over_200k.input,
- output: model.cost.context_over_200k.output,
- }
+ cache: {
+ read: model.cost.context_over_200k.cache_read ?? 0,
+ write: model.cost.context_over_200k.cache_write ?? 0,
+ },
+ input: model.cost.context_over_200k.input,
+ output: model.cost.context_over_200k.output,
+ }
: undefined,
},
limit: {
@@ -837,6 +849,111 @@ export namespace Provider {
database[providerID] = parsed
}
+
+ // Dynamic Ollama Loading for multiple potential provider IDs
+ const ollamaProviderIDs = ["ollama", "ollama-cloud"]
+
+ if (process.env.OPENCODE_DISABLE_OLLAMA_CHECK !== "true") {
+ for (const providerID of ollamaProviderIDs) {
+ if (!isProviderAllowed(providerID)) continue
+
+ try {
+ const defaultBase = "http://127.0.0.1:11434"
+ const configBase = database[providerID]?.options?.baseURL
+ const apiBase = database[providerID]?.options?.api
+
+ const rawBase = (typeof configBase === "string" && configBase) || (typeof apiBase === "string" && apiBase)
+ const baseUrl = rawBase ? rawBase.replace(/\/v1\/?$/, "") : defaultBase
+
+ // Only auto-discover for 'ollama' (default) OR if the provider is explicitly configured in database
+ if (providerID !== "ollama" && !database[providerID]) continue
+
+ // Only attempt if it looks like a local ollama instance or explicitly requested
+ if (baseUrl.includes("127.0.0.1") || baseUrl.includes("localhost") || providerID === "ollama") {
+ const response = await fetch(`${baseUrl}/api/tags`, {
+ signal: AbortSignal.timeout(500),
+ })
+ if (response.ok) {
+ const data = (await response.json()) as { models: { name: string }[] }
+ const ollamaModels: Record = {}
+
+ for (const model of data.models) {
+ // Skip models that are already defined in opencode.json to respect manual overrides
+ if (database[providerID]?.models?.[model.name]) continue
+
+ ollamaModels[model.name] = {
+ id: model.name,
+ providerID: providerID,
+ name: model.name,
+ status: "active",
+ api: {
+ id: model.name,
+ url: `${baseUrl}/v1`,
+ npm: "@ai-sdk/openai-compatible",
+ },
+ capabilities: {
+ toolcall: true,
+ temperature: true,
+ reasoning: false,
+ attachment: false,
+ interleaved: false,
+ input: {
+ text: true,
+ audio: false,
+ image: false,
+ video: false,
+ pdf: false,
+ },
+ output: {
+ text: true,
+ audio: false,
+ image: false,
+ video: false,
+ pdf: false,
+ },
+ },
+ cost: {
+ input: 0,
+ output: 0,
+ cache: { read: 0, write: 0 },
+ },
+ limit: { context: 128000, output: 4096 },
+ options: {},
+ headers: {},
+ family: "",
+ release_date: new Date().toISOString(),
+ variants: {},
+ }
+ }
+
+ if (Object.keys(ollamaModels).length > 0) {
+ if (!database[providerID]) {
+ database[providerID] = {
+ id: providerID,
+ name: providerID === "ollama" ? "Ollama" : providerID,
+ source: "custom",
+ env: [],
+ options: { baseURL: `${baseUrl}/v1` },
+ models: {},
+ }
+ }
+ database[providerID].models = { ...database[providerID].models, ...ollamaModels }
+
+ // Explicitly register the provider so it appears in the list
+ mergeProvider(providerID, {
+ source: "custom",
+ options: { baseURL: `${baseUrl}/v1` },
+ models: ollamaModels
+ })
+ }
+ }
+ }
+ } catch (e) {
+ // Silent failure if Ollama is not running
+ }
+ }
+ }
+
// load env
const env = Env.all()
for (const [providerID, provider] of Object.entries(database)) {
diff --git a/packages/opencode/src/server/routes/auth.ts b/packages/opencode/src/server/routes/auth.ts
new file mode 100644
index 000000000000..a88d0a109e44
--- /dev/null
+++ b/packages/opencode/src/server/routes/auth.ts
@@ -0,0 +1,47 @@
+import { Hono } from "hono"
+import { describeRoute, validator, resolver } from "hono-openapi"
+import z from "zod"
+import { GoogleAuth } from "../../auth/google"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
+
+export const AuthRoutes = lazy(() =>
+ new Hono().post(
+ "/google/browser",
+ describeRoute({
+ summary: "Google browser OAuth",
+ description: "Initiate browser-based OAuth flow for Google authentication",
+ operationId: "auth.google.browser",
+ responses: {
+ 200: {
+ description: "OAuth flow completed successfully",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z.object({
+ success: z.boolean(),
+ }),
+ ),
+ },
+ },
+ },
+ ...errors(400, 500),
+ },
+ }),
+ async (c) => {
+ try {
+ const origin = new URL(c.req.url).origin
+ await GoogleAuth.loginWeb(origin)
+ return c.json({ success: true })
+ } catch (error) {
+ return c.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : "Authentication failed",
+ },
+ 500,
+ )
+ }
+ },
+ ),
+)
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 9fb5206551b6..e30a143c8283 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -39,6 +39,7 @@ import { errors } from "./error"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
+import { AuthRoutes } from "./routes/auth"
import { MDNS } from "./mdns"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
@@ -130,6 +131,7 @@ export namespace Server {
}),
)
.route("/global", GlobalRoutes())
+ .route("/auth", AuthRoutes())
.put(
"/auth/:providerID",
describeRoute({