Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 57 additions & 3 deletions packages/opencode/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"google-vertex": "Google Vertex AI",
"google-vertex-anthropic": "Google Vertex AI (Anthropic)",
}
let provider = await prompts.autocomplete({
message: "Select provider",
Expand All @@ -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],
})),
),
Expand Down Expand Up @@ -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"}`
}
},
})
Comment thread
sandys marked this conversation as resolved.
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"),
Expand Down
17 changes: 13 additions & 4 deletions packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"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(() =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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() {
Expand Down
178 changes: 176 additions & 2 deletions packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ const PROVIDER_PRIORITY: Record<string, number> = {
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
"google-vertex": 5,
"google-vertex-anthropic": 6,
openrouter: 7,
}

const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
"google-vertex": "Google Vertex AI",
"google-vertex-anthropic": "Google Vertex AI (Anthropic)",
}

export function createDialogProviderOptions() {
Expand All @@ -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() {
Expand Down Expand Up @@ -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(() => <ServiceAccountMethod providerID={provider.id} />)
}
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
Expand Down Expand Up @@ -222,3 +235,164 @@ function ApiMethod(props: ApiMethodProps) {
/>
)
}

interface ServiceAccountMethodProps {
providerID: string
}
function ServiceAccountMethod(props: ServiceAccountMethodProps) {
const dialog = useDialog()

onMount(() => {
dialog.setSize("large")
})

return <ServiceAccountPasteInput providerID={props.providerID} />
}

function ServiceAccountPasteInput(props: { providerID: string }) {
const dialog = useDialog()
const { theme } = useTheme()
const [error, setError] = createSignal("")
let textareaRef: any
Comment thread
sandys marked this conversation as resolved.
Outdated

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(() => (
<ServiceAccountLocationInput
providerID={props.providerID}
serviceAccountJson={content}
/>
))
}

return (
<box gap={1} paddingBottom={1}>
<box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{providerDisplayName()}
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingTop={1}>
<text fg={theme.textMuted}>Paste your service account JSON below and press Ctrl+S to submit.</text>
<text fg={theme.text}>
Download from{" "}
<span style={{ fg: theme.primary }}>https://console.cloud.google.com/iam-admin/serviceaccounts</span>
</text>
</box>
</box>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<textarea
ref={(r: any) => {
textareaRef = r
setTimeout(() => r.focus(), 1)
}}
height={15}
placeholder='{"type": "service_account", ...}'
backgroundColor={theme.backgroundElement}
cursorColor={theme.primary}
keyBindings={[{ name: "return", action: "submit" }]}
onSubmit={handleSubmit}
/>
</box>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.textMuted}>Paste JSON and press Enter to submit</text>
<Show when={error()}>
<text fg={theme.error}>{error()}</text>
</Show>
</box>
</box>
)
}

function ServiceAccountLocationInput(props: { providerID: string; serviceAccountJson: string }) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const { theme } = useTheme()
const [error, setError] = createSignal("")

const defaultLocation = () => (props.providerID === "google-vertex-anthropic" ? "global" : "us-east5")

const handleLocationSubmit = async (location: string) => {
try {
const json = JSON.parse(props.serviceAccountJson)
const finalLocation = location || defaultLocation()

sdk.client.auth.set({
providerID: props.providerID,
auth: {
type: "api",
key: JSON.stringify({
client_email: json.client_email,
private_key: json.private_key,
project_id: json.project_id,
location: finalLocation,
}),
},
})
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel providerID={props.providerID} />)
} catch (e) {
setError(`Failed to save credentials: ${e instanceof Error ? e.message : "unknown error"}`)
}
}

return (
<DialogPrompt
title="Location"
placeholder={defaultLocation()}
description={() => (
<box gap={1}>
<text fg={theme.textMuted}>Enter the Vertex AI location (region).</text>
<text fg={theme.text}>
Press enter to use default: <span style={{ fg: theme.primary }}>{defaultLocation()}</span>
</text>
<Show when={error()}>
<text fg={theme.error}>{error()}</text>
</Show>
</box>
)}
onConfirm={handleLocationSubmit}
/>
)
}
6 changes: 5 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return modelStore.favorite
},
parsed: createMemo(() => {
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
Comment thread
sandys marked this conversation as resolved.
Outdated
"google-vertex": "Google Vertex AI",
"google-vertex-anthropic": "Google Vertex AI (Anthropic)",
}
const value = currentModel()
if (!value) {
return {
Expand All @@ -223,7 +227,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const provider = sync.data.provider.find((x) => x.id === value.providerID)
const info = provider?.models[value.modelID]
return {
provider: provider?.name ?? value.providerID,
provider: PROVIDER_DISPLAY_NAMES[value.providerID] ?? provider?.name ?? value.providerID,
model: info?.name ?? value.modelID,
}
}),
Expand Down
Loading