Files
zmo-cli/packages/opencode/src/cli/cmd/providers.ts
吴中洋 698ce2fa31
Some checks failed
compliance-close / close-non-compliant (push) Has been cancelled
beta / sync (push) Has been cancelled
feat: zmo providers login 支持填写自定义 base URL(中转地址)
login 填完 API key 后新增一步询问 base URL(留空=官方默认)。
若填写则写入 ~/.config/zmo/zmo.json 的 provider.<id>.options.baseURL,
后续请求自动走中转。key 仍存 auth.json,baseURL 走 config。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 20:54:26 +08:00

549 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Auth } from "../../auth"
import { cmd } from "./cmd"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import * as Prompt from "../effect/prompt"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import fsNode from "node:fs"
import { Config } from "@/config/config"
import { Global } from "@opencode-ai/core/global"
import { Plugin } from "../../plugin"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "@/util/process"
import { errorMessage } from "@/util/error"
import { text } from "node:stream/consumers"
import { Effect, Option } from "effect"
type PluginAuth = NonNullable<Hooks["auth"]>
const promptValue = <Value>(value: Option.Option<Value>) => {
if (Option.isNone(value)) return Effect.die(new UI.CancelledError())
return Effect.succeed(value.value)
}
const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) {
const auth = yield* Auth.Service
yield* Effect.orDie(auth.set(key, info))
})
const cliTry = <Value>(message: string, fn: () => PromiseLike<Value>) =>
Effect.tryPromise({
try: fn,
catch: (error) => new CliError({ message: message + errorMessage(error) }),
})
const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
plugin: { auth: PluginAuth },
provider: string,
methodName?: string,
) {
const index = yield* Effect.gen(function* () {
if (!methodName) {
if (plugin.auth.methods.length <= 1) return 0
return yield* promptValue(
yield* Prompt.select({
message: "Login method",
options: plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index,
})),
}),
)
}
const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase())
if (match === -1) {
return yield* fail(
`Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`,
)
}
return match
})
const method = plugin.auth.methods[index]
yield* Effect.sleep("10 millis")
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
if (prompt.when) {
const value = inputs[prompt.when.key]
if (value === undefined) continue
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
if (!matches) continue
}
if (prompt.condition && !prompt.condition(inputs)) continue
if (prompt.type === "select") {
const value = yield* Prompt.select({
message: prompt.message,
options: prompt.options,
})
inputs[prompt.key] = yield* promptValue(value)
continue
}
const value = yield* Prompt.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
})
inputs[prompt.key] = yield* promptValue(value)
}
}
if (method.type === "oauth") {
const authorize = yield* cliTry("Failed to authorize: ", () => method.authorize(inputs))
if (authorize.url) {
yield* Prompt.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
yield* Prompt.log.info(authorize.instructions)
}
const spinner = Prompt.spinner()
yield* spinner.start("Waiting for authorization...")
const result = yield* cliTry("Failed to authorize: ", () => authorize.callback())
if (result.type === "failed") {
yield* spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
yield* put(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
yield* put(saveProvider, {
type: "api",
key: result.key,
...(result.metadata ? { metadata: result.metadata } : {}),
})
}
yield* spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = yield* Prompt.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
const authorizationCode = yield* promptValue(code)
const result = yield* cliTry("Failed to authorize: ", () => authorize.callback(authorizationCode))
if (result.type === "failed") {
yield* Prompt.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
yield* put(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
yield* put(saveProvider, {
type: "api",
key: result.key,
...(result.metadata ? { metadata: result.metadata } : {}),
})
}
yield* Prompt.log.success("Login successful")
}
}
yield* Prompt.outro("Done")
return true
}
if (method.type === "api") {
const key = yield* Prompt.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
const apiKey = yield* promptValue(key)
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
const authorizeApi = method.authorize
if (!authorizeApi) {
yield* put(provider, {
type: "api",
key: apiKey,
...metadata,
})
yield* Prompt.outro("Done")
return true
}
const result = yield* cliTry("Failed to authorize: ", () => authorizeApi(inputs))
if (result.type === "failed") {
yield* Prompt.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
const merged = { ...(metadata.metadata ?? {}), ...(result.metadata ?? {}) }
yield* put(saveProvider, {
type: "api",
key: result.key ?? apiKey,
...(Object.keys(merged).length ? { metadata: merged } : {}),
})
yield* Prompt.log.success("Login successful")
}
yield* Prompt.outro("Done")
return true
}
return false
})
export function resolvePluginProviders(input: {
hooks: Hooks[]
existingProviders: Record<string, unknown>
disabled: Set<string>
enabled?: Set<string>
providerNames: Record<string, string | undefined>
}): Array<{ id: string; name: string }> {
const seen = new Set<string>()
const result: Array<{ id: string; name: string }> = []
for (const hook of input.hooks) {
if (!hook.auth) continue
const id = hook.auth.provider
if (seen.has(id)) continue
seen.add(id)
if (Object.hasOwn(input.existingProviders, id)) continue
if (input.disabled.has(id)) continue
if (input.enabled && !input.enabled.has(id)) continue
result.push({
id,
name: input.providerNames[id] ?? id,
})
}
return result
}
export const ProvidersCommand = cmd({
command: "providers",
aliases: ["auth"],
describe: "manage AI providers and credentials",
builder: (yargs) =>
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
async handler() {},
})
export const ProvidersListCommand = effectCmd({
command: "list",
aliases: ["ls"],
describe: "list providers and credentials",
// Lists global credentials + provider env vars; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.list")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
const database = yield* modelsDev.get()
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
yield* Prompt.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
}
if (activeEnvVars.length > 0) {
UI.empty()
yield* Prompt.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
yield* Prompt.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
yield* Prompt.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
}),
})
export const ProvidersLoginCommand = effectCmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
yargs
.positional("url", {
describe: "zmo auth provider",
type: "string",
})
.option("provider", {
alias: ["p"],
describe: "provider id or name to log in to (skips provider selection)",
type: "string",
})
.option("method", {
alias: ["m"],
describe: "login method label (skips method selection)",
type: "string",
}),
handler: Effect.fn("Cli.providers.login")(function* (args) {
const authSvc = yield* Auth.Service
UI.empty()
yield* Prompt.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
)) as {
auth: { command: string[]; env: string }
}
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const abort = new AbortController()
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
if (!proc.stdout) {
yield* Prompt.log.error("Failed")
yield* Prompt.outro("Done")
return
}
const [exit, token] = yield* cliTry("Failed to run auth provider command: ", () =>
Promise.all([proc.exited, text(proc.stdout!)]),
).pipe(Effect.ensuring(Effect.sync(() => abort.abort())))
if (exit !== 0) {
yield* Prompt.log.error("Failed")
yield* Prompt.outro("Done")
return
}
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
yield* Prompt.log.success("Logged into " + url)
yield* Prompt.outro("Done")
return
}
const cfgSvc = yield* Config.Service
const pluginSvc = yield* Plugin.Service
const modelsDev = yield* ModelsDev.Service
yield* Effect.ignore(modelsDev.refresh(true))
const config = yield* cfgSvc.get()
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const allProviders = yield* modelsDev.get()
const providers: Record<string, (typeof allProviders)[string]> = {}
for (const [key, value] of Object.entries(allProviders)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value
}
const hooks = yield* pluginSvc.list()
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
}
const pluginProviders = resolvePluginProviders({
hooks,
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
const options = [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
),
...pluginProviders.map((x) => ({
label: x.name,
value: x.id,
hint: "plugin",
})),
]
let provider: string
if (args.provider) {
const input = args.provider
const byID = options.find((x) => x.value === input)
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
return yield* fail(`Unknown provider "${input}"`)
}
provider = match.value
} else {
provider = yield* promptValue(
yield* Prompt.autocomplete({
message: "Select provider",
maxItems: 8,
options: [...options, { value: "other", label: "Other" }],
}),
)
}
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method)
if (handled) return
}
if (provider === "other") {
provider = (yield* promptValue(
yield* Prompt.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
}),
)).replace(/^@ai-sdk\//, "")
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = yield* handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method)
if (handled) return
}
yield* Prompt.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
yield* Prompt.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
if (provider === "opencode") {
yield* Prompt.log.info("Create an api key at https://opencode.ai/auth")
}
if (provider === "vercel") {
yield* Prompt.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
yield* Prompt.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",
)
}
const key = yield* Prompt.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
const apiKey = yield* promptValue(key)
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey }))
// zmo: 询问自定义 base URL中转地址留空则用官方默认。
// baseURL 不存 auth.json写入 config 文件的 provider.<id>.options.baseURL。
const baseURLInput = yield* Prompt.text({
message: "Base URL (中转地址,留空使用官方默认)",
placeholder: "https://your-proxy.example.com/v1",
})
const baseURL = (yield* promptValue(baseURLInput))?.trim()
if (baseURL) {
try {
const configDir = Global.Path.config
fsNode.mkdirSync(configDir, { recursive: true })
const file = path.join(configDir, "zmo.json")
let data: any = {}
if (fsNode.existsSync(file)) {
try {
data = JSON.parse(fsNode.readFileSync(file, "utf8")) ?? {}
} catch {
data = {}
}
}
if (!data.$schema) data.$schema = "https://zmo.dev/config.json"
data.provider ??= {}
data.provider[provider] ??= {}
data.provider[provider].options ??= {}
data.provider[provider].options.baseURL = baseURL
fsNode.writeFileSync(file, JSON.stringify(data, null, 2))
yield* Prompt.log.info(`Base URL 已保存到 ${file}`)
} catch {
yield* Prompt.log.warn("Base URL 保存失败,请手动写入 zmo.json")
}
}
yield* Prompt.outro("Done")
}),
})
export const ProvidersLogoutCommand = effectCmd({
command: "logout",
describe: "log out from a configured provider",
// Removes a global auth credential; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
UI.empty()
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
yield* Prompt.intro("Remove credential")
if (credentials.length === 0) {
yield* Prompt.log.error("No credentials found")
return
}
const database = yield* modelsDev.get()
const selected = yield* Prompt.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
yield* Effect.orDie(authSvc.remove(yield* promptValue(selected)))
yield* Prompt.outro("Logout successful")
}),
})