import type { Argv } from "yargs" import { Auth } from "../../auth" // altimate_change start — surface OAuth plan/account in `auth list` import { OAuthClaims } from "../../auth/oauth-claims" // altimate_change end 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 { 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 { FreeTier } from "@/altimate/free/client" import { AltimateApi } from "@/altimate/api/client" import { text } from "node:stream/consumers" import { Effect, Option } from "effect" type PluginAuth = NonNullable const promptValue = (value: Option.Option) => { 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 = (message: string, fn: () => PromiseLike) => Effect.tryPromise({ try: fn, catch: (error) => new CliError({ message: message + errorMessage(error) }), }) // altimate_change start — resolve a pending wait on Ctrl+C (SIGINT) instead of letting it run to timeout const SIGNIN_CANCELED = Symbol("signin-canceled") const untilSigint = (promise: Promise) => { let onSigint = () => {} const canceled = new Promise((resolve) => { onSigint = () => resolve(SIGNIN_CANCELED) process.once("SIGINT", onSigint) }) return Promise.race([promise, canceled]).finally(() => process.off("SIGINT", onSigint)) } // altimate_change end 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 = {} 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) } // altimate_change start — no clack spinner here: its raw-mode stdin turns Ctrl+C into // process.exit(0), so a canceled sign-in looked successful to `&&` chains and scripts. yield* Prompt.log.info("Waiting for authorization... (Ctrl+C to cancel)") const result = yield* cliTry("Failed to authorize: ", () => untilSigint(authorize.callback())) if (result === SIGNIN_CANCELED) return yield* fail("Sign-in canceled.", 130) // altimate_change end 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") } } 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 }) // altimate_change start — `auth login ` used to be fed straight into a // fetch() for well-known auth metadata, so the natural `auth login openai` died on // "fetch() URL is invalid". Only an actual http(s) URL takes the well-known path; // anything else is treated as a provider id, exactly like `--provider`. export function isAuthProviderUrl(value: string | undefined): value is string { if (!value) return false try { const { protocol } = new URL(value) return protocol === "http:" || protocol === "https:" } catch { return false } } export type LoginTarget = /** Self-hosted auth provider: fetch its `.well-known/opencode` metadata. */ | { kind: "url"; url: string } /** A provider id/name — same path the interactive picker resolves to. */ | { kind: "provider"; provider: string } /** Nothing specified: prompt with the provider list. */ | { kind: "picker" } /** * Decide which login path an invocation takes. A URL positional keeps the * well-known behavior it always had; anything else is a provider id, so * `auth login openai` works like `auth login --provider openai` rather than * being handed to fetch(). A URL positional always wins — it is the only way to * reach the well-known path, and it is what pre-existing invocations pass. When * the positional is not a URL, an explicit `--provider` flag takes precedence * over it, following the usual flag-beats-positional convention. */ export function resolveLoginTarget(args: { target?: string; provider?: string }): LoginTarget { if (isAuthProviderUrl(args.target)) return { kind: "url", url: args.target.replace(/\/+$/, "") } const provider = args.provider ?? args.target if (provider) return { kind: "provider", provider } return { kind: "picker" } } // altimate_change end export function resolvePluginProviders(input: { hooks: Hooks[] existingProviders: Record disabled: Set enabled?: Set providerNames: Record }): Array<{ id: string; name: string }> { const seen = new Set() 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 // altimate_change start — show which account/plan an OAuth credential belongs to, // so a wrong-account login is visible here instead of surfacing later as an // unexplained model error. Only non-secret claims are decoded, and the account id // is truncated; the token itself is never rendered. `result.accountId` (stored at // login) is the fallback for credentials whose access token never carries the // account claim — see `describeOAuthIdentity`. const identity = result.type === "oauth" ? OAuthClaims.describeOAuthIdentity(result.access, result.accountId) : undefined yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}${identity ? ` (${identity})` : ""}`) // altimate_change end } 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({ // altimate_change start — the positional accepts a provider id as well as a URL command: "login [target]", describe: "log in to a provider", // URL login skips instance bootstrap, which would load remote config with the stale token and crash before re-auth. // A provider id goes down the picker path instead, which needs the instance. instance: (args) => resolveLoginTarget(args).kind !== "url", builder: (yargs: Argv) => yargs .positional("target", { describe: "provider id (e.g. openai) or altimate auth provider URL", type: "string", }) // altimate_change end .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") // altimate_change start — only real http(s) URLs take the well-known path const target = resolveLoginTarget(args) if (target.kind === "url") { const url = target.url // altimate_change end 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 = {} 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 = { // altimate_change start — surface the Altimate LLM Gateway first "altimate-backend": -1, // altimate_change end 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", // altimate_change start — upstream_fix: PR #18186 reversion preserves the Anthropic provider; restore its API-key login hint dropped by the v1.17.9 merge anthropic: "API key", // altimate_change end openai: "ChatGPT Plus/Pro or API key", }[x.id], })), ), ...pluginProviders.map((x) => ({ label: x.name, value: x.id, hint: "plugin", })), ] let provider: string // altimate_change start — a non-URL positional is a provider id, same as --provider if (target.kind === "provider") { const input = target.provider // altimate_change end const byID = options.find((x) => x.value === input) const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) const match = byID ?? byName if (!match) { // altimate_change start — say what to do next instead of just rejecting return yield* fail( `Unknown provider "${input}". Run \`altimate-code auth login\` with no arguments to pick from the list, ` + `or pass a full auth provider URL including https://.`, ) // altimate_change end } 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") { // altimate_change start — branding yield* Prompt.log.info("Create an api key at https://altimate.ai/auth") // altimate_change end } 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( // altimate_change start — altimate docs URL "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://help.altimate.ai/code/configure/providers/", // altimate_change end ) } 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 })) yield* Prompt.outro("Done") }), }) export const ProvidersLogoutCommand = effectCmd({ command: "logout [provider]", describe: "log out from a configured provider", builder: (yargs) => yargs.positional("provider", { describe: "provider id or name to log out from", type: "string", }), // 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())) // altimate_change start — integrate the dedicated managed Base store with normal provider logout const requestedProvider = args.provider?.toLowerCase() const requestsAltimateBase = requestedProvider === FreeTier.PROVIDER_ID || requestedProvider === FreeTier.MODEL_ID || requestedProvider === "altimate base" const hasAltimateBaseCredential = yield* Effect.tryPromise(() => FreeTier.credentials()).pipe( Effect.map((value) => value !== undefined), // Keep malformed credential state visible so logout reports the storage error instead of // silently claiming there is nothing configured. Effect.orElseSucceed(() => true), ) const hasAltimateBaseState = requestsAltimateBase ? yield* Effect.tryPromise(() => FreeTier.hasStoredRegistrationState()).pipe( Effect.orElseSucceed(() => true), ) : false yield* Prompt.intro("Remove credential") const database = yield* modelsDev.get() const hasLegacyAltimateBaseCredential = credentials.some(([key]) => key === FreeTier.PROVIDER_ID) const options = credentials .filter(([key]) => key !== FreeTier.PROVIDER_ID) .map(([key, value]) => ({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })) // Credentials saved only to ~/.altimate/altimate.json (TUI paste dialog) have no auth-store entry. const hasAltimateBackendFile = yield* Effect.promise(() => AltimateApi.isConfigured()) if (hasAltimateBackendFile && !options.some((option) => option.value === "altimate-backend")) { options.push({ label: (database["altimate-backend"]?.name || "altimate-backend") + UI.Style.TEXT_DIM + " (api)", value: "altimate-backend", }) } if (hasAltimateBaseCredential || hasLegacyAltimateBaseCredential || hasAltimateBaseState) { options.push({ label: "Altimate Base" + UI.Style.TEXT_DIM + " (managed)", value: FreeTier.PROVIDER_ID, }) } if (options.length === 0) { yield* Prompt.log.error("No credentials found") return } const provider = args.provider ? options.find( (option) => option.value === args.provider || database[option.value]?.name?.toLowerCase() === requestedProvider || (option.value === FreeTier.PROVIDER_ID && (requestedProvider === FreeTier.MODEL_ID || requestedProvider === "altimate base")), )?.value : yield* promptValue( yield* Prompt.autocomplete({ message: "Select provider", maxItems: 8, options, }), ) if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) if (provider === FreeTier.PROVIDER_ID) { yield* cliTry("Failed to remove Altimate Base credential: ", () => FreeTier.logout()) // Remove any stale entry created by pre-managed Base builds without touching other providers. yield* Effect.orDie(authSvc.remove(FreeTier.PROVIDER_ID)) yield* Prompt.outro("Logout successful") return } if (provider === "altimate-backend") { // Browser sign-in persists the gateway key to ~/.altimate/altimate.json, which the provider // loader and every workspace call read first, so sign-out must remove it too. // On failure the saved key and the auth-store entry are both kept, so fixing the cause and // re-running logout can still match and remove every copy of the key. const removed = yield* cliTry("Failed to sign out of Altimate; fix the cause below and run logout again: ", () => AltimateApi.signOut(process.cwd()), ) for (const entry of removed) yield* Prompt.log.info(`Removed MCP server "${entry.name}" from ${entry.path}`) if (removed.length > 0) yield* Prompt.log.info("Add the datamate again after signing in to restore it.") } // altimate_change end yield* Effect.orDie(authSvc.remove(provider)) yield* Prompt.outro("Logout successful") }), })