From 5d3e0dca60873c87d944b80bd5943a72f0f5b640 Mon Sep 17 00:00:00 2001 From: Sandeep Srinivasa Date: Sun, 28 Dec 2025 14:55:19 +0530 Subject: [PATCH 1/2] feat: add Google Vertex AI service account JSON authentication Add support for authenticating to Google Vertex AI using service account JSON credentials, with proper credential separation from regular Google (Gemini) API keys. Changes: - Add service account JSON auth flow in CLI (`opencode auth login`) - Add TUI dialog for pasting service account JSON with location input - Update provider loaders to use stored credentials with googleAuthOptions - Explicitly set `key: undefined` and `apiKey: null` in Vertex loaders to prevent credential leakage from other providers - Rename "Vertex" to "Google Vertex AI" consistently across UI: - /connect menu - Model selection dialog - Status bar below text entry - Add comprehensive tests verifying credential separation between google, google-vertex, and google-vertex-anthropic providers The three providers (google, google-vertex, google-vertex-anthropic) are now completely independent and can be configured simultaneously without credentials leaking between them. --- packages/opencode/src/cli/cmd/auth.ts | 60 ++- .../cli/cmd/tui/component/dialog-model.tsx | 17 +- .../cli/cmd/tui/component/dialog-provider.tsx | 178 ++++++- .../src/cli/cmd/tui/context/local.tsx | 6 +- packages/opencode/src/provider/provider.ts | 74 +++ .../opencode/test/provider/provider.test.ts | 446 ++++++++++++++++++ 6 files changed, 771 insertions(+), 10 deletions(-) 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 + + + + +