diff --git a/packages/opencode/opencode-linux-x64.tar.gz b/packages/opencode/opencode-linux-x64.tar.gz new file mode 100644 index 000000000000..fb63afb3cc2b Binary files /dev/null and b/packages/opencode/opencode-linux-x64.tar.gz differ diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index bbaecfd8c711..7f5eb0d578bc 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -10,6 +10,7 @@ import { Config } from "../../config/config" import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" +import { PROVIDER_DISPLAY_NAMES } from "../../provider/display-names" import type { Hooks } from "@opencode-ai/plugin" type PluginAuth = NonNullable @@ -274,8 +275,10 @@ export const AuthLoginCommand = cmd({ "github-copilot": 2, openai: 3, google: 4, - openrouter: 5, - vercel: 6, + "google-vertex": 5, + "google-vertex-anthropic": 6, + openrouter: 7, + vercel: 8, } let provider = await prompts.autocomplete({ message: "Select provider", @@ -289,12 +292,14 @@ export const AuthLoginCommand = cmd({ (x) => x.name ?? x.id, ), map((x) => ({ - label: x.name, + label: PROVIDER_DISPLAY_NAMES[x.id] ?? x.name, value: x.id, hint: { opencode: "recommended", anthropic: "Claude Max or API key", openai: "ChatGPT Plus/Pro or API key", + "google-vertex": "Service Account", + "google-vertex-anthropic": "Service Account", }[x.id], })), ), @@ -352,6 +357,52 @@ export const AuthLoginCommand = cmd({ prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") } + if (provider === "google-vertex" || provider === "google-vertex-anthropic") { + prompts.log.info("Paste your service account JSON below.") + prompts.log.info("Download from https://console.cloud.google.com/iam-admin/serviceaccounts") + + const serviceAccountJson = await prompts.text({ + message: "Service Account JSON", + placeholder: '{"type": "service_account", ...}', + validate: (x) => { + if (!x) return "Required" + try { + const json = JSON.parse(x) + if (json.type !== "service_account") return "Invalid: 'type' must be 'service_account'" + if (!json.client_email) return "Invalid: missing 'client_email' field" + if (!json.private_key) return "Invalid: missing 'private_key' field" + if (!json.project_id) return "Invalid: missing 'project_id' field" + return undefined + } catch (e) { + return `Invalid JSON: ${e instanceof Error ? e.message : "parse error"}` + } + }, + }) + if (prompts.isCancel(serviceAccountJson)) throw new UI.CancelledError() + + const json = JSON.parse(serviceAccountJson) + + const defaultLocation = provider === "google-vertex-anthropic" ? "global" : "us-east5" + const location = await prompts.text({ + message: `Location (default: ${defaultLocation})`, + placeholder: defaultLocation, + }) + if (prompts.isCancel(location)) throw new UI.CancelledError() + + await Auth.set(provider, { + type: "api", + key: JSON.stringify({ + client_email: json.client_email, + private_key: json.private_key, + project_id: json.project_id, + location: location || defaultLocation, + }), + }) + + prompts.outro("Done") + return + } + if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { prompts.log.info( "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index bcbbe69287be..3d376d681d23 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -7,6 +7,7 @@ import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { useKeybind } from "../context/keybind" import * as fuzzysort from "fuzzysort" +import { getProviderDisplayName } from "@/provider/display-names" export function useConnected() { const sync = useSync() @@ -59,7 +60,7 @@ export function DialogModel(props: { providerID?: string }) { modelID: model.id, }, title: model.name ?? item.modelID, - description: provider.name, + description: getProviderDisplayName(provider), category: "Favorites", disabled: provider.id === "opencode" && model.id.includes("-nano"), footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, @@ -92,7 +93,7 @@ export function DialogModel(props: { providerID?: string }) { modelID: model.id, }, title: model.name ?? item.modelID, - description: provider.name, + description: getProviderDisplayName(provider), category: "Recent", disabled: provider.id === "opencode" && model.id.includes("-nano"), footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, @@ -115,7 +116,7 @@ export function DialogModel(props: { providerID?: string }) { sync.data.provider, sortBy( (provider) => provider.id !== "opencode", - (provider) => provider.name, + (provider) => getProviderDisplayName(provider), ), flatMap((provider) => pipe( @@ -136,7 +137,7 @@ export function DialogModel(props: { providerID?: string }) { ) ? "(Favorite)" : undefined, - category: connected() ? provider.name : undefined, + category: connected() ? getProviderDisplayName(provider) : undefined, disabled: provider.id === "opencode" && model.includes("-nano"), footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, onSelect() { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 4e1171a42017..3229bb1af3d8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -10,6 +10,7 @@ import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2" import { DialogModel } from "./dialog-model" +import { PROVIDER_DISPLAY_NAMES } from "@/provider/display-names" import { useKeyboard } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { useToast } from "../ui/toast" @@ -20,6 +21,9 @@ const PROVIDER_PRIORITY: Record = { "github-copilot": 2, openai: 3, google: 4, + "google-vertex": 5, + "google-vertex-anthropic": 6, + openrouter: 7, } export function createDialogProviderOptions() { @@ -34,12 +38,14 @@ export function createDialogProviderOptions() { map((provider) => { const isConnected = connected().has(provider.id) return { - title: provider.name, + title: PROVIDER_DISPLAY_NAMES[provider.id] ?? provider.name, value: provider.id, description: { opencode: "(Recommended)", anthropic: "(Claude Max or API key)", openai: "(ChatGPT Plus/Pro or API key)", + "google-vertex": "(Service Account)", + "google-vertex-anthropic": "(Service Account)", }[provider.id], category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", footer: isConnected ? "Connected" : undefined, @@ -97,6 +103,10 @@ export function createDialogProviderOptions() { } } if (method.type === "api") { + // Use ServiceAccountMethod for Vertex AI providers + if (provider.id === "google-vertex" || provider.id === "google-vertex-anthropic") { + return dialog.replace(() => ) + } return dialog.replace(() => ) } }, @@ -254,3 +264,164 @@ function ApiMethod(props: ApiMethodProps) { /> ) } + +interface ServiceAccountMethodProps { + providerID: string +} +function ServiceAccountMethod(props: ServiceAccountMethodProps) { + const dialog = useDialog() + + onMount(() => { + dialog.setSize("large") + }) + + return +} + +function ServiceAccountPasteInput(props: { providerID: string }) { + const dialog = useDialog() + const { theme } = useTheme() + const [error, setError] = createSignal("") + let textareaRef: { plainText: string; focus: () => void } | undefined + + const providerDisplayName = () => + props.providerID === "google-vertex-anthropic" ? "Google Vertex AI (Anthropic)" : "Google Vertex AI" + + const validateJson = (content: string): { valid: boolean; json?: any; error?: string } => { + try { + const json = JSON.parse(content) + if (json.type !== "service_account") { + return { valid: false, error: "Invalid: 'type' must be 'service_account'" } + } + if (!json.client_email) { + return { valid: false, error: "Invalid: missing 'client_email' field" } + } + if (!json.private_key) { + return { valid: false, error: "Invalid: missing 'private_key' field" } + } + if (!json.project_id) { + return { valid: false, error: "Invalid: missing 'project_id' field" } + } + return { valid: true, json } + } catch (e) { + return { valid: false, error: `Invalid JSON: ${e instanceof Error ? e.message : "parse error"}` } + } + } + + const handleSubmit = () => { + const content = textareaRef?.plainText || "" + setError("") + + if (!content || !content.trim()) { + setError("Required - paste your service account JSON") + return + } + + const result = validateJson(content) + if (!result.valid) { + setError(result.error!) + return + } + + dialog.replace(() => ( + + )) + } + + return ( + + + + + {providerDisplayName()} + + esc + + + Paste your service account JSON below and press Ctrl+S to submit. + + Download from{" "} + https://console.cloud.google.com/iam-admin/serviceaccounts + + + + +