refactor(cli/providers): flatten — Effect-native handlers end-to-end (#25537)
This commit is contained in:
parent
df7dd06a0f
commit
40dc2fa3c1
1 changed files with 189 additions and 202 deletions
|
|
@ -6,8 +6,6 @@ import * as prompts from "@clack/prompts"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { ModelsDev } from "@/provider/models"
|
import { ModelsDev } from "@/provider/models"
|
||||||
|
|
||||||
const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get()))
|
|
||||||
const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true)))
|
|
||||||
import { map, pipe, sortBy, values } from "remeda"
|
import { map, pipe, sortBy, values } from "remeda"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
|
|
@ -241,46 +239,45 @@ export const ProvidersListCommand = effectCmd({
|
||||||
handler: Effect.fn("Cli.providers.list")(function* (_args) {
|
handler: Effect.fn("Cli.providers.list")(function* (_args) {
|
||||||
const authSvc = yield* Auth.Service
|
const authSvc = yield* Auth.Service
|
||||||
const modelsDev = yield* ModelsDev.Service
|
const modelsDev = yield* ModelsDev.Service
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
|
UI.empty()
|
||||||
|
const authPath = path.join(Global.Path.data, "auth.json")
|
||||||
|
const homedir = os.homedir()
|
||||||
|
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||||
|
prompts.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
|
||||||
|
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts.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()
|
UI.empty()
|
||||||
const authPath = path.join(Global.Path.data, "auth.json")
|
prompts.intro("Environment")
|
||||||
const homedir = os.homedir()
|
|
||||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
|
||||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
|
||||||
const results = Object.entries(await Effect.runPromise(authSvc.all()))
|
|
||||||
const database = await Effect.runPromise(modelsDev.get())
|
|
||||||
|
|
||||||
for (const [providerID, result] of results) {
|
for (const { provider, envVar } of activeEnvVars) {
|
||||||
const name = database[providerID]?.name || providerID
|
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
||||||
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts.outro(`${results.length} credentials`)
|
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
||||||
|
}
|
||||||
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()
|
|
||||||
prompts.intro("Environment")
|
|
||||||
|
|
||||||
for (const { provider, envVar } of activeEnvVars) {
|
|
||||||
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -306,185 +303,174 @@ export const ProvidersLoginCommand = effectCmd({
|
||||||
handler: Effect.fn("Cli.providers.login")(function* (args) {
|
handler: Effect.fn("Cli.providers.login")(function* (args) {
|
||||||
const cfgSvc = yield* Config.Service
|
const cfgSvc = yield* Config.Service
|
||||||
const pluginSvc = yield* Plugin.Service
|
const pluginSvc = yield* Plugin.Service
|
||||||
yield* Effect.promise(async () => {
|
const modelsDev = yield* ModelsDev.Service
|
||||||
UI.empty()
|
const authSvc = yield* Auth.Service
|
||||||
prompts.intro("Add credential")
|
|
||||||
if (args.url) {
|
UI.empty()
|
||||||
const url = args.url.replace(/\/+$/, "")
|
prompts.intro("Add credential")
|
||||||
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
|
if (args.url) {
|
||||||
auth: { command: string[]; env: string }
|
const url = args.url.replace(/\/+$/, "")
|
||||||
}
|
const wellknown = (yield* Effect.promise(() =>
|
||||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
|
||||||
const proc = Process.spawn(wellknown.auth.command, {
|
)) as { auth: { command: string[]; env: string } }
|
||||||
stdout: "pipe",
|
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||||
stderr: "inherit",
|
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" })
|
||||||
})
|
if (!proc.stdout) {
|
||||||
if (!proc.stdout) {
|
prompts.log.error("Failed")
|
||||||
prompts.log.error("Failed")
|
|
||||||
prompts.outro("Done")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
|
|
||||||
if (exit !== 0) {
|
|
||||||
prompts.log.error("Failed")
|
|
||||||
prompts.outro("Done")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await put(url, {
|
|
||||||
type: "wellknown",
|
|
||||||
key: wellknown.auth.env,
|
|
||||||
token: token.trim(),
|
|
||||||
})
|
|
||||||
prompts.log.success("Logged into " + url)
|
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await refreshModels().catch(() => {})
|
const [exit, token] = yield* Effect.promise(() => Promise.all([proc.exited, text(proc.stdout!)]))
|
||||||
|
if (exit !== 0) {
|
||||||
const config = await Effect.runPromise(cfgSvc.get())
|
prompts.log.error("Failed")
|
||||||
|
prompts.outro("Done")
|
||||||
const disabled = new Set(config.disabled_providers ?? [])
|
return
|
||||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
|
||||||
|
|
||||||
const providers = await getModels().then((x) => {
|
|
||||||
const filtered: Record<string, (typeof x)[string]> = {}
|
|
||||||
for (const [key, value] of Object.entries(x)) {
|
|
||||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
|
||||||
filtered[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
})
|
|
||||||
const hooks = await Effect.runPromise(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({
|
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
|
||||||
hooks,
|
prompts.log.success("Logged into " + url)
|
||||||
existingProviders: providers,
|
prompts.outro("Done")
|
||||||
disabled,
|
return
|
||||||
enabled,
|
}
|
||||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
yield* Effect.ignore(modelsDev.refresh(true))
|
||||||
})
|
|
||||||
const options = [
|
const config = yield* cfgSvc.get()
|
||||||
...pipe(
|
|
||||||
providers,
|
const disabled = new Set(config.disabled_providers ?? [])
|
||||||
values(),
|
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||||
sortBy(
|
|
||||||
(x) => priority[x.id] ?? 99,
|
const allProviders = yield* modelsDev.get()
|
||||||
(x) => x.name ?? x.id,
|
const providers: Record<string, (typeof allProviders)[string]> = {}
|
||||||
),
|
for (const [key, value] of Object.entries(allProviders)) {
|
||||||
map((x) => ({
|
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value
|
||||||
label: x.name,
|
}
|
||||||
value: x.id,
|
const hooks = yield* pluginSvc.list()
|
||||||
hint: {
|
|
||||||
opencode: "recommended",
|
const priority: Record<string, number> = {
|
||||||
openai: "ChatGPT Plus/Pro or API key",
|
opencode: 0,
|
||||||
}[x.id],
|
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,
|
||||||
),
|
),
|
||||||
...pluginProviders.map((x) => ({
|
map((x) => ({
|
||||||
label: x.name,
|
label: x.name,
|
||||||
value: x.id,
|
value: x.id,
|
||||||
hint: "plugin",
|
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
|
let provider: string
|
||||||
if (args.provider) {
|
if (args.provider) {
|
||||||
const input = args.provider
|
const input = args.provider
|
||||||
const byID = options.find((x) => x.value === input)
|
const byID = options.find((x) => x.value === input)
|
||||||
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
||||||
const match = byID ?? byName
|
const match = byID ?? byName
|
||||||
if (!match) {
|
if (!match) {
|
||||||
prompts.log.error(`Unknown provider "${input}"`)
|
prompts.log.error(`Unknown provider "${input}"`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
provider = match.value
|
provider = match.value
|
||||||
} else {
|
} else {
|
||||||
const selected = await prompts.autocomplete({
|
const selected = yield* Effect.promise(() =>
|
||||||
|
prompts.autocomplete({
|
||||||
message: "Select provider",
|
message: "Select provider",
|
||||||
maxItems: 8,
|
maxItems: 8,
|
||||||
options: [
|
options: [...options, { value: "other", label: "Other" }],
|
||||||
...options,
|
}),
|
||||||
{
|
)
|
||||||
value: "other",
|
if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
|
||||||
label: "Other",
|
provider = selected as string
|
||||||
},
|
}
|
||||||
],
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
|
||||||
provider = selected as string
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||||
if (plugin && plugin.auth) {
|
if (plugin && plugin.auth) {
|
||||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
const handled = yield* Effect.promise(() => handlePluginAuth({ auth: plugin.auth! }, provider, args.method))
|
||||||
|
if (handled) return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "other") {
|
||||||
|
const custom = yield* Effect.promise(() =>
|
||||||
|
prompts.text({
|
||||||
|
message: "Enter provider id",
|
||||||
|
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (prompts.isCancel(custom)) yield* Effect.die(new UI.CancelledError())
|
||||||
|
provider = (custom as string).replace(/^@ai-sdk\//, "")
|
||||||
|
|
||||||
|
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||||
|
if (customPlugin && customPlugin.auth) {
|
||||||
|
const handled = yield* Effect.promise(() =>
|
||||||
|
handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method),
|
||||||
|
)
|
||||||
if (handled) return
|
if (handled) return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === "other") {
|
prompts.log.warn(
|
||||||
const custom = await prompts.text({
|
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||||
message: "Enter provider id",
|
)
|
||||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
}
|
||||||
})
|
|
||||||
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
|
||||||
provider = custom.replace(/^@ai-sdk\//, "")
|
|
||||||
|
|
||||||
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
if (provider === "amazon-bedrock") {
|
||||||
if (customPlugin && customPlugin.auth) {
|
prompts.log.info(
|
||||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
"Amazon Bedrock authentication priority:\n" +
|
||||||
if (handled) return
|
" 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).",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
prompts.log.warn(
|
if (provider === "opencode") {
|
||||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "amazon-bedrock") {
|
if (provider === "vercel") {
|
||||||
prompts.log.info(
|
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||||
"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") {
|
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
||||||
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === "vercel") {
|
const key = yield* Effect.promise(() =>
|
||||||
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
prompts.password({
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = await prompts.password({
|
|
||||||
message: "Enter your API key",
|
message: "Enter your API key",
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
})
|
}),
|
||||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
)
|
||||||
await put(provider, {
|
if (prompts.isCancel(key)) yield* Effect.die(new UI.CancelledError())
|
||||||
type: "api",
|
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: key as string }))
|
||||||
key,
|
|
||||||
})
|
|
||||||
|
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -496,26 +482,27 @@ export const ProvidersLogoutCommand = effectCmd({
|
||||||
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
|
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
|
||||||
const authSvc = yield* Auth.Service
|
const authSvc = yield* Auth.Service
|
||||||
const modelsDev = yield* ModelsDev.Service
|
const modelsDev = yield* ModelsDev.Service
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
UI.empty()
|
UI.empty()
|
||||||
const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all()))
|
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||||
prompts.intro("Remove credential")
|
prompts.intro("Remove credential")
|
||||||
if (credentials.length === 0) {
|
if (credentials.length === 0) {
|
||||||
prompts.log.error("No credentials found")
|
prompts.log.error("No credentials found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const database = await Effect.runPromise(modelsDev.get())
|
const database = yield* modelsDev.get()
|
||||||
const selected = await prompts.select({
|
const selected = yield* Effect.promise(() =>
|
||||||
|
prompts.select({
|
||||||
message: "Select provider",
|
message: "Select provider",
|
||||||
options: credentials.map(([key, value]) => ({
|
options: credentials.map(([key, value]) => ({
|
||||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||||
value: key,
|
value: key,
|
||||||
})),
|
})),
|
||||||
})
|
}),
|
||||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
)
|
||||||
const providerID = selected as string
|
if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
|
||||||
await Effect.runPromise(authSvc.remove(providerID))
|
const providerID = selected as string
|
||||||
prompts.outro("Logout successful")
|
yield* Effect.orDie(authSvc.remove(providerID))
|
||||||
})
|
prompts.outro("Logout successful")
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue