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>
549 lines
18 KiB
TypeScript
549 lines
18 KiB
TypeScript
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")
|
||
}),
|
||
})
|