diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 658329fb6ef..eb949ec1c1b 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -274,8 +274,14 @@ 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, + } + const displayNames: Record = { + "google-vertex": "Google Vertex AI", + "google-vertex-anthropic": "Google Vertex AI (Anthropic)", } let provider = await prompts.autocomplete({ message: "Select provider", @@ -289,11 +295,13 @@ export const AuthLoginCommand = cmd({ (x) => x.name ?? x.id, ), map((x) => ({ - label: x.name, + label: displayNames[x.id] ?? x.name, value: x.id, hint: { opencode: "recommended", anthropic: "Claude Max or API key", + "google-vertex": "Service Account", + "google-vertex-anthropic": "Service Account", }[x.id], })), ), @@ -349,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 + } + const key = await prompts.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), 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 bc90dbb5c6e..3688a5f3832 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -8,6 +8,15 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { Keybind } from "@/util/keybind" import * as fuzzysort from "fuzzysort" +const PROVIDER_DISPLAY_NAMES: Record = { + "google-vertex": "Google Vertex AI", + "google-vertex-anthropic": "Google Vertex AI (Anthropic)", +} + +function getProviderDisplayName(provider: { id: string; name: string }): string { + return PROVIDER_DISPLAY_NAMES[provider.id] ?? provider.name +} + export function useConnected() { const sync = useSync() return createMemo(() => @@ -55,7 +64,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, @@ -86,7 +95,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, @@ -108,7 +117,7 @@ export function DialogModel(props: { providerID?: string }) { sync.data.provider, sortBy( (provider) => provider.id !== "opencode", - (provider) => provider.name, + (provider) => getProviderDisplayName(provider), ), flatMap((provider) => pipe( @@ -129,7 +138,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 5cc114f92f0..5dfaa1c25a9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -16,7 +16,14 @@ const PROVIDER_PRIORITY: Record = { "github-copilot": 2, openai: 3, google: 4, - openrouter: 5, + "google-vertex": 5, + "google-vertex-anthropic": 6, + openrouter: 7, +} + +const PROVIDER_DISPLAY_NAMES: Record = { + "google-vertex": "Google Vertex AI", + "google-vertex-anthropic": "Google Vertex AI (Anthropic)", } export function createDialogProviderOptions() { @@ -28,11 +35,13 @@ export function createDialogProviderOptions() { sync.data.provider_next.all, sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), map((provider) => ({ - title: provider.name, + title: PROVIDER_DISPLAY_NAMES[provider.id] ?? provider.name, value: provider.id, description: { opencode: "(Recommended)", anthropic: "(Claude Max or API key)", + "google-vertex": "(Service Account)", + "google-vertex-anthropic": "(Service Account)", }[provider.id], category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", async onSelect() { @@ -79,6 +88,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(() => ) } }, @@ -222,3 +235,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: any + + 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 + + + + +