Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Binary file added packages/opencode/opencode-linux-x64.tar.gz
Binary file not shown.
57 changes: 54 additions & 3 deletions packages/opencode/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hooks["auth"]>
Expand Down Expand Up @@ -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",
Expand All @@ -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],
})),
),
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 5 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 @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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() {
Expand Down
173 changes: 172 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -20,6 +21,9 @@ const PROVIDER_PRIORITY: Record<string, number> = {
"github-copilot": 2,
openai: 3,
google: 4,
"google-vertex": 5,
"google-vertex-anthropic": 6,
openrouter: 7,
}

export function createDialogProviderOptions() {
Expand All @@ -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,
Expand Down Expand Up @@ -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(() => <ServiceAccountMethod providerID={provider.id} />)
}
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
Expand Down Expand Up @@ -254,3 +264,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: { 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(() => (
<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: { plainText: string; focus: () => void }) => {
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}
/>
)
}
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { iife } from "@/util/iife"
import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast"
import { Provider } from "@/provider/provider"
import { PROVIDER_DISPLAY_NAMES } from "@/provider/display-names"
import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
Expand Down Expand Up @@ -220,7 +221,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,
reasoning: info?.capabilities?.reasoning ?? false,
}
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/provider/display-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Display name overrides for providers.
* Used to show user-friendly names instead of provider IDs.
*/
export const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
"google-vertex": "Google Vertex AI",
"google-vertex-anthropic": "Google Vertex AI (Anthropic)",
}

/**
* Get the display name for a provider, falling back to the provider's name or ID.
*/
export function getProviderDisplayName(provider: { id: string; name?: string }): string {
return PROVIDER_DISPLAY_NAMES[provider.id] ?? provider.name ?? provider.id
}
Loading