From ad0ff9b651cce2d88c04f254bfc45e7289df1197 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 14 Apr 2026 08:18:15 +0800 Subject: [PATCH] formatting --- packages/opencode/src/config/config.ts | 915 ++++++++------- packages/opencode/src/provider/provider.ts | 1179 ++++++++++---------- 2 files changed, 1039 insertions(+), 1055 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ab23814ac0..9338bff01b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1161,501 +1161,496 @@ export namespace Config { }), ) - type Reqs = Env.Service - export const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const authSvc = yield* Auth.Service - const accountSvc = yield* Account.Service - const env = yield* Env.Service + export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | Auth.Service | Account.Service | Env.Service + > = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const authSvc = yield* Auth.Service + const accountSvc = yield* Account.Service + const env = yield* Env.Service - const readConfigFile = Effect.fnUntraced(function* (filepath: string) { - return yield* fs.readFileString(filepath).pipe( - Effect.catchIf( - (e) => e.reason._tag === "NotFound", - () => Effect.succeed(undefined), - ), - Effect.orDie, - ) - }) - - const loadConfig = Effect.fnUntraced(function* ( - text: string, - options: { path: string } | { dir: string; source: string }, - ) { - const original = text - const source = "path" in options ? options.path : options.source - const isFile = "path" in options - const data = yield* Effect.promise(() => - ConfigPaths.parseText( - text, - "path" in options ? options.path : { source: options.source, dir: options.dir }, - ), - ) - - const normalized = (() => { - if (!data || typeof data !== "object" || Array.isArray(data)) return data - const copy = { ...(data as Record) } - const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy - if (!hadLegacy) return copy - delete copy.theme - delete copy.keybinds - delete copy.tui - log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source }) - return copy - })() - - const parsed = Info.safeParse(normalized) - if (parsed.success) { - if (!parsed.data.$schema && isFile) { - parsed.data.$schema = "https://opencode.ai/config.json" - const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') - yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) - } - const data = parsed.data - if (data.plugin && isFile) { - const list = data.plugin - for (let i = 0; i < list.length; i++) { - list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path)) - } - } - return data - } - - throw new InvalidError({ - path: source, - issues: parsed.error.issues, - }) - }) - - const loadFile = Effect.fnUntraced(function* (filepath: string) { - log.info("loading", { path: filepath }) - const text = yield* readConfigFile(filepath) - if (!text) return {} as Info - return yield* loadConfig(text, { path: filepath }) - }) - - const loadGlobal = Effect.fnUntraced(function* () { - let result: Info = pipe( - {}, - mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))), - mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))), - mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))), - ) - - const legacy = path.join(Global.Path.config, "config") - if (existsSync(legacy)) { - yield* Effect.promise(() => - import(pathToFileURL(legacy).href, { with: { type: "toml" } }) - .then(async (mod) => { - const { provider, model, ...rest } = mod.default - if (provider && model) result.model = `${provider}/${model}` - result["$schema"] = "https://opencode.ai/config.json" - result = mergeDeep(result, rest) - await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) - await fsNode.unlink(legacy) - }) - .catch(() => {}), - ) - } - - return result - }) - - const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL( - loadGlobal().pipe( - Effect.tapError((error) => - Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })), - ), - Effect.orElseSucceed((): Info => ({})), + const readConfigFile = Effect.fnUntraced(function* (filepath: string) { + return yield* fs.readFileString(filepath).pipe( + Effect.catchIf( + (e) => e.reason._tag === "NotFound", + () => Effect.succeed(undefined), ), - Duration.infinity, + Effect.orDie, + ) + }) + + const loadConfig = Effect.fnUntraced(function* ( + text: string, + options: { path: string } | { dir: string; source: string }, + ) { + const original = text + const source = "path" in options ? options.path : options.source + const isFile = "path" in options + const data = yield* Effect.promise(() => + ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }), ) - const getGlobal = Effect.fn("Config.getGlobal")(function* () { - return yield* cachedGlobal + const normalized = (() => { + if (!data || typeof data !== "object" || Array.isArray(data)) return data + const copy = { ...(data as Record) } + const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy + if (!hadLegacy) return copy + delete copy.theme + delete copy.keybinds + delete copy.tui + log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source }) + return copy + })() + + const parsed = Info.safeParse(normalized) + if (parsed.success) { + if (!parsed.data.$schema && isFile) { + parsed.data.$schema = "https://opencode.ai/config.json" + const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') + yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) + } + const data = parsed.data + if (data.plugin && isFile) { + const list = data.plugin + for (let i = 0; i < list.length; i++) { + list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path)) + } + } + return data + } + + throw new InvalidError({ + path: source, + issues: parsed.error.issues, + }) + }) + + const loadFile = Effect.fnUntraced(function* (filepath: string) { + log.info("loading", { path: filepath }) + const text = yield* readConfigFile(filepath) + if (!text) return {} as Info + return yield* loadConfig(text, { path: filepath }) + }) + + const loadGlobal = Effect.fnUntraced(function* () { + let result: Info = pipe( + {}, + mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))), + mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))), + mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))), + ) + + const legacy = path.join(Global.Path.config, "config") + if (existsSync(legacy)) { + yield* Effect.promise(() => + import(pathToFileURL(legacy).href, { with: { type: "toml" } }) + .then(async (mod) => { + const { provider, model, ...rest } = mod.default + if (provider && model) result.model = `${provider}/${model}` + result["$schema"] = "https://opencode.ai/config.json" + result = mergeDeep(result, rest) + await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) + await fsNode.unlink(legacy) + }) + .catch(() => {}), + ) + } + + return result + }) + + const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL( + loadGlobal().pipe( + Effect.tapError((error) => + Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })), + ), + Effect.orElseSucceed((): Info => ({})), + ), + Duration.infinity, + ) + + const getGlobal = Effect.fn("Config.getGlobal")(function* () { + return yield* cachedGlobal + }) + + const install = Effect.fnUntraced(function* (dir: string) { + const pkg = path.join(dir, "package.json") + const gitignore = path.join(dir, ".gitignore") + const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json") + const target = Installation.isLocal() ? "*" : Installation.VERSION + const json = yield* fs.readJson(pkg).pipe( + Effect.catch(() => Effect.succeed({} satisfies Package)), + Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})), + ) + const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target + const hasIgnore = yield* fs.existsSafe(gitignore) + const hasPkg = yield* fs.existsSafe(plugin) + + if (!hasDep) { + yield* fs.writeJson(pkg, { + ...json, + dependencies: { + ...json.dependencies, + "@opencode-ai/plugin": target, + }, + }) + } + + if (!hasIgnore) { + yield* fs.writeFileString( + gitignore, + ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), + ) + } + + if (hasDep && hasIgnore && hasPkg) return + + yield* Effect.promise(() => Npm.install(dir)) + }) + + const installDependencies = Effect.fn("Config.installDependencies")(function* ( + dir: string, + input?: InstallInput, + ) { + if ( + !(yield* fs.access(dir, { writable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + )) + ) + return + + const key = + process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}` + + yield* Effect.acquireUseRelease( + Effect.promise((signal) => + Flock.acquire(key, { + signal, + onWait: (tick) => + input?.waitTick?.({ + dir, + attempt: tick.attempt, + delay: tick.delay, + waited: tick.waited, + }), + }), + ), + () => install(dir), + (lease) => Effect.promise(() => lease.release()), + ) + }) + + const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) { + const auth = yield* authSvc.all().pipe(Effect.orDie) + + let result: Info = {} + const consoleManagedProviders = new Set() + let activeOrgName: string | undefined + + const scope = Effect.fnUntraced(function* (source: string) { + if (source.startsWith("http://") || source.startsWith("https://")) return "global" + if (source === "OPENCODE_CONFIG_CONTENT") return "local" + if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" + return "global" }) - const install = Effect.fnUntraced(function* (dir: string) { - const pkg = path.join(dir, "package.json") - const gitignore = path.join(dir, ".gitignore") - const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json") - const target = Installation.isLocal() ? "*" : Installation.VERSION - const json = yield* fs.readJson(pkg).pipe( - Effect.catch(() => Effect.succeed({} satisfies Package)), - Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})), - ) - const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target - const hasIgnore = yield* fs.existsSafe(gitignore) - const hasPkg = yield* fs.existsSafe(plugin) + const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) { + if (!list?.length) return + const hit = kind ?? (yield* scope(source)) + const plugins = deduplicatePluginOrigins([ + ...(result.plugin_origins ?? []), + ...list.map((spec) => ({ spec, source, scope: hit })), + ]) + result.plugin = plugins.map((item) => item.spec) + result.plugin_origins = plugins + }) - if (!hasDep) { - yield* fs.writeJson(pkg, { - ...json, - dependencies: { - ...json.dependencies, - "@opencode-ai/plugin": target, - }, + const merge = (source: string, next: Info, kind?: PluginScope) => { + result = mergeConfigConcatArrays(result, next) + return track(source, next.plugin, kind) + } + + for (const [key, value] of Object.entries(auth)) { + if (value.type === "wellknown") { + const url = key.replace(/\/+$/, "") + process.env[value.key] = value.token + log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) + const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`)) + if (!response.ok) { + throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) + } + const wellknown = (yield* Effect.promise(() => response.json())) as any + const remoteConfig = wellknown.config ?? {} + if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" + const source = `${url}/.well-known/opencode` + const next = yield* loadConfig(JSON.stringify(remoteConfig), { + dir: path.dirname(source), + source, }) + yield* merge(source, next, "global") + log.debug("loaded remote config from well-known", { url }) + } + } + + const global = yield* getGlobal() + yield* merge(Global.Path.config, global, "global") + + if (Flag.OPENCODE_CONFIG) { + yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) + log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) + } + + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + for (const file of yield* Effect.promise(() => + ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree), + )) { + yield* merge(file, yield* loadFile(file), "local") + } + } + + result.agent = result.agent || {} + result.mode = result.mode || {} + result.plugin = result.plugin || [] + + const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree)) + + if (Flag.OPENCODE_CONFIG_DIR) { + log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) + } + + const deps: Fiber.Fiber[] = [] + + for (const dir of unique(directories)) { + if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { + for (const file of ["opencode.json", "opencode.jsonc"]) { + const source = path.join(dir, file) + log.debug(`loading config from ${source}`) + yield* merge(source, yield* loadFile(source)) + result.agent ??= {} + result.mode ??= {} + result.plugin ??= [] + } } - if (!hasIgnore) { - yield* fs.writeFileString( - gitignore, - ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), - ) - } - - if (hasDep && hasIgnore && hasPkg) return - - yield* Effect.promise(() => Npm.install(dir)) - }) - - const installDependencies = Effect.fn("Config.installDependencies")(function* ( - dir: string, - input?: InstallInput, - ) { - if ( - !(yield* fs.access(dir, { writable: true }).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - )) - ) - return - - const key = - process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}` - - yield* Effect.acquireUseRelease( - Effect.promise((signal) => - Flock.acquire(key, { - signal, - onWait: (tick) => - input?.waitTick?.({ - dir, - attempt: tick.attempt, - delay: tick.delay, - waited: tick.waited, - }), - }), + const dep = yield* installDependencies(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Exit.isFailure(exit) + ? Effect.sync(() => { + log.warn("background dependency install failed", { dir, error: String(exit.cause) }) + }) + : Effect.void, ), - () => install(dir), - (lease) => Effect.promise(() => lease.release()), + Effect.asVoid, + Effect.forkScoped, ) - }) + deps.push(dep) - const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) { - const auth = yield* authSvc.all().pipe(Effect.orDie) + result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir))) + result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir))) + result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir))) + const list = yield* Effect.promise(() => loadPlugin(dir)) + yield* track(dir, list) + } - let result: Info = {} - const consoleManagedProviders = new Set() - let activeOrgName: string | undefined - - const scope = Effect.fnUntraced(function* (source: string) { - if (source.startsWith("http://") || source.startsWith("https://")) return "global" - if (source === "OPENCODE_CONFIG_CONTENT") return "local" - if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" - return "global" + if (process.env.OPENCODE_CONFIG_CONTENT) { + const source = "OPENCODE_CONFIG_CONTENT" + const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { + dir: ctx.directory, + source, }) + yield* merge(source, next, "local") + log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") + } - const track = Effect.fnUntraced(function* ( - source: string, - list: PluginSpec[] | undefined, - kind?: PluginScope, - ) { - if (!list?.length) return - const hit = kind ?? (yield* scope(source)) - const plugins = deduplicatePluginOrigins([ - ...(result.plugin_origins ?? []), - ...list.map((spec) => ({ spec, source, scope: hit })), - ]) - result.plugin = plugins.map((item) => item.spec) - result.plugin_origins = plugins - }) + const activeOrg = Option.getOrUndefined( + yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))), + ) + if (activeOrg) { + yield* Effect.gen(function* () { + const [configOpt, tokenOpt] = yield* Effect.all( + [accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)], + { concurrency: 2 }, + ) + if (Option.isSome(tokenOpt)) { + process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value + yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) + } - const merge = (source: string, next: Info, kind?: PluginScope) => { - result = mergeConfigConcatArrays(result, next) - return track(source, next.plugin, kind) - } + activeOrgName = activeOrg.org.name - for (const [key, value] of Object.entries(auth)) { - if (value.type === "wellknown") { - const url = key.replace(/\/+$/, "") - process.env[value.key] = value.token - log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) - const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`)) - if (!response.ok) { - throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) - } - const wellknown = (yield* Effect.promise(() => response.json())) as any - const remoteConfig = wellknown.config ?? {} - if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" - const source = `${url}/.well-known/opencode` - const next = yield* loadConfig(JSON.stringify(remoteConfig), { + if (Option.isSome(configOpt)) { + const source = `${activeOrg.account.url}/api/config` + const next = yield* loadConfig(JSON.stringify(configOpt.value), { dir: path.dirname(source), source, }) + for (const providerID of Object.keys(next.provider ?? {})) { + consoleManagedProviders.add(providerID) + } yield* merge(source, next, "global") - log.debug("loaded remote config from well-known", { url }) } - } - - const global = yield* getGlobal() - yield* merge(Global.Path.config, global, "global") - - if (Flag.OPENCODE_CONFIG) { - yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) - log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) - } - - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - for (const file of yield* Effect.promise(() => - ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree), - )) { - yield* merge(file, yield* loadFile(file), "local") - } - } - - result.agent = result.agent || {} - result.mode = result.mode || {} - result.plugin = result.plugin || [] - - const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree)) - - if (Flag.OPENCODE_CONFIG_DIR) { - log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) - } - - const deps: Fiber.Fiber[] = [] - - for (const dir of unique(directories)) { - if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { - for (const file of ["opencode.json", "opencode.jsonc"]) { - const source = path.join(dir, file) - log.debug(`loading config from ${source}`) - yield* merge(source, yield* loadFile(source)) - result.agent ??= {} - result.mode ??= {} - result.plugin ??= [] - } - } - - const dep = yield* installDependencies(dir).pipe( - Effect.exit, - Effect.tap((exit) => - Exit.isFailure(exit) - ? Effect.sync(() => { - log.warn("background dependency install failed", { dir, error: String(exit.cause) }) - }) - : Effect.void, - ), - Effect.asVoid, - Effect.forkScoped, - ) - deps.push(dep) - - result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir))) - result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir))) - result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir))) - const list = yield* Effect.promise(() => loadPlugin(dir)) - yield* track(dir, list) - } - - if (process.env.OPENCODE_CONFIG_CONTENT) { - const source = "OPENCODE_CONFIG_CONTENT" - const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { - dir: ctx.directory, - source, - }) - yield* merge(source, next, "local") - log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") - } - - const activeOrg = Option.getOrUndefined( - yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))), + }).pipe( + Effect.catch((err) => { + log.debug("failed to fetch remote account config", { + error: err instanceof Error ? err.message : String(err), + }) + return Effect.void + }), ) - if (activeOrg) { - yield* Effect.gen(function* () { - const [configOpt, tokenOpt] = yield* Effect.all( - [accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)], - { concurrency: 2 }, - ) - if (Option.isSome(tokenOpt)) { - process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value - yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) - } + } - activeOrgName = activeOrg.org.name - - if (Option.isSome(configOpt)) { - const source = `${activeOrg.account.url}/api/config` - const next = yield* loadConfig(JSON.stringify(configOpt.value), { - dir: path.dirname(source), - source, - }) - for (const providerID of Object.keys(next.provider ?? {})) { - consoleManagedProviders.add(providerID) - } - yield* merge(source, next, "global") - } - }).pipe( - Effect.catch((err) => { - log.debug("failed to fetch remote account config", { - error: err instanceof Error ? err.message : String(err), - }) - return Effect.void - }), - ) + if (existsSync(managedDir)) { + for (const file of ["opencode.json", "opencode.jsonc"]) { + const source = path.join(managedDir, file) + yield* merge(source, yield* loadFile(source), "global") } + } - if (existsSync(managedDir)) { - for (const file of ["opencode.json", "opencode.jsonc"]) { - const source = path.join(managedDir, file) - yield* merge(source, yield* loadFile(source), "global") - } - } + // macOS managed preferences (.mobileconfig deployed via MDM) override everything + result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences())) - // macOS managed preferences (.mobileconfig deployed via MDM) override everything - result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences())) - - for (const [name, mode] of Object.entries(result.mode ?? {})) { - result.agent = mergeDeep(result.agent ?? {}, { - [name]: { - ...mode, - mode: "primary" as const, - }, - }) - } - - if (Flag.OPENCODE_PERMISSION) { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) - } - - if (result.tools) { - const perms: Record = {} - for (const [tool, enabled] of Object.entries(result.tools)) { - const action: Config.PermissionAction = enabled ? "allow" : "deny" - if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { - perms.edit = action - continue - } - perms[tool] = action - } - result.permission = mergeDeep(perms, result.permission ?? {}) - } - - if (!result.username) result.username = os.userInfo().username - - if (result.autoshare === true && !result.share) { - result.share = "auto" - } - - if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) { - result.compaction = { ...result.compaction, auto: false } - } - if (Flag.OPENCODE_DISABLE_PRUNE) { - result.compaction = { ...result.compaction, prune: false } - } - - return { - config: result, - directories, - deps, - consoleState: { - consoleManagedProviders: Array.from(consoleManagedProviders), - activeOrgName, - switchableOrgCount: 0, + for (const [name, mode] of Object.entries(result.mode ?? {})) { + result.agent = mergeDeep(result.agent ?? {}, { + [name]: { + ...mode, + mode: "primary" as const, }, + }) + } + + if (Flag.OPENCODE_PERMISSION) { + result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) + } + + if (result.tools) { + const perms: Record = {} + for (const [tool, enabled] of Object.entries(result.tools)) { + const action: Config.PermissionAction = enabled ? "allow" : "deny" + if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + perms.edit = action + continue + } + perms[tool] = action } - }) + result.permission = mergeDeep(perms, result.permission ?? {}) + } - const state = yield* InstanceState.make( - Effect.fn("Config.state")(function* (ctx) { - return yield* loadInstanceState(ctx) - }), - ) + if (!result.username) result.username = os.userInfo().username - const get = Effect.fn("Config.get")(function* () { - return yield* InstanceState.use(state, (s) => s.config) - }) + if (result.autoshare === true && !result.share) { + result.share = "auto" + } - const directories = Effect.fn("Config.directories")(function* () { - return yield* InstanceState.use(state, (s) => s.directories) - }) + if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) { + result.compaction = { ...result.compaction, auto: false } + } + if (Flag.OPENCODE_DISABLE_PRUNE) { + result.compaction = { ...result.compaction, prune: false } + } - const getConsoleState = Effect.fn("Config.getConsoleState")(function* () { - return yield* InstanceState.use(state, (s) => s.consoleState) - }) - - const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { - yield* InstanceState.useEffect(state, (s) => - Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), - ) - }) - - const update = Effect.fn("Config.update")(function* (config: Info) { - const dir = yield* InstanceState.directory - const file = path.join(dir, "config.json") - const existing = yield* loadFile(file) - yield* fs - .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) - .pipe(Effect.orDie) - yield* Effect.promise(() => Instance.dispose()) - }) - - const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { - yield* invalidateGlobal - const task = Instance.disposeAll() - .catch(() => undefined) - .finally(() => - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Event.Disposed.type, - properties: {}, - }, - }), - ) - if (wait) yield* Effect.promise(() => task) - else void task - }) - - const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { - const file = globalConfigFile() - const before = (yield* readConfigFile(file)) ?? "{}" - const input = writable(config) - - let next: Info - if (!file.endsWith(".jsonc")) { - const existing = parseConfig(before, file) - const merged = mergeDeep(writable(existing), input) - yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) - next = merged - } else { - const updated = patchJsonc(before, input) - next = parseConfig(updated, file) - yield* fs.writeFileString(file, updated).pipe(Effect.orDie) - } - - yield* invalidate() - return next - }) - - return Service.of({ - get, - getGlobal, - getConsoleState, - installDependencies, - update, - updateGlobal, - invalidate, + return { + config: result, directories, - waitForDependencies, - }) - }), - ) + deps, + consoleState: { + consoleManagedProviders: Array.from(consoleManagedProviders), + activeOrgName, + switchableOrgCount: 0, + }, + } + }) + + const state = yield* InstanceState.make( + Effect.fn("Config.state")(function* (ctx) { + return yield* loadInstanceState(ctx) + }), + ) + + const get = Effect.fn("Config.get")(function* () { + return yield* InstanceState.use(state, (s) => s.config) + }) + + const directories = Effect.fn("Config.directories")(function* () { + return yield* InstanceState.use(state, (s) => s.directories) + }) + + const getConsoleState = Effect.fn("Config.getConsoleState")(function* () { + return yield* InstanceState.use(state, (s) => s.consoleState) + }) + + const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { + yield* InstanceState.useEffect(state, (s) => + Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), + ) + }) + + const update = Effect.fn("Config.update")(function* (config: Info) { + const dir = yield* InstanceState.directory + const file = path.join(dir, "config.json") + const existing = yield* loadFile(file) + yield* fs + .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) + .pipe(Effect.orDie) + yield* Effect.promise(() => Instance.dispose()) + }) + + const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { + yield* invalidateGlobal + const task = Instance.disposeAll() + .catch(() => undefined) + .finally(() => + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }), + ) + if (wait) yield* Effect.promise(() => task) + else void task + }) + + const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { + const file = globalConfigFile() + const before = (yield* readConfigFile(file)) ?? "{}" + const input = writable(config) + + let next: Info + if (!file.endsWith(".jsonc")) { + const existing = parseConfig(before, file) + const merged = mergeDeep(writable(existing), input) + yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) + next = merged + } else { + const updated = patchJsonc(before, input) + next = parseConfig(updated, file) + yield* fs.writeFileString(file, updated).pipe(Effect.orDie) + } + + yield* invalidate() + return next + }) + + return Service.of({ + get, + getGlobal, + getConsoleState, + installDependencies, + update, + updateGlobal, + invalidate, + directories, + waitForDependencies, + }) + }), + ) export const defaultLayer = layer.pipe( Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 364ef5b7db..35d4296cbe 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1029,661 +1029,650 @@ export namespace Provider { } } - type Reqs = Config.Service - const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const config = yield* Config.Service - const auth = yield* Auth.Service - const plugin = yield* Plugin.Service - const env = yield* Env.Service + const layer: Layer.Layer< + Service, + never, + Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service + > = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const config = yield* Config.Service + const auth = yield* Auth.Service + const plugin = yield* Plugin.Service + const env = yield* Env.Service - const state = yield* InstanceState.make(() => - Effect.gen(function* () { - using _ = log.time("state") - const cfg = yield* config.get() - const modelsDev = yield* Effect.promise(() => ModelsDev.get()) - const database = mapValues(modelsDev, fromModelsDevProvider) + const state = yield* InstanceState.make(() => + Effect.gen(function* () { + using _ = log.time("state") + const cfg = yield* config.get() + const modelsDev = yield* Effect.promise(() => ModelsDev.get()) + const database = mapValues(modelsDev, fromModelsDevProvider) - const providers: Record = {} as Record - const languages = new Map() - const modelLoaders: { - [providerID: string]: CustomModelLoader - } = {} - const varsLoaders: { - [providerID: string]: CustomVarsLoader - } = {} - const sdk = new Map() - const discoveryLoaders: { - [providerID: string]: CustomDiscoverModels - } = {} - const dep = { - auth: (id: string) => auth.get(id).pipe(Effect.orDie), - config: () => config.get(), - } + const providers: Record = {} as Record + const languages = new Map() + const modelLoaders: { + [providerID: string]: CustomModelLoader + } = {} + const varsLoaders: { + [providerID: string]: CustomVarsLoader + } = {} + const sdk = new Map() + const discoveryLoaders: { + [providerID: string]: CustomDiscoverModels + } = {} + const dep = { + auth: (id: string) => auth.get(id).pipe(Effect.orDie), + config: () => config.get(), + } - log.info("init") + log.info("init") - function mergeProvider(providerID: ProviderID, provider: Partial) { - const existing = providers[providerID] - if (existing) { - // @ts-expect-error - providers[providerID] = mergeDeep(existing, provider) - return - } - const match = database[providerID] - if (!match) return + function mergeProvider(providerID: ProviderID, provider: Partial) { + const existing = providers[providerID] + if (existing) { // @ts-expect-error - providers[providerID] = mergeDeep(match, provider) + providers[providerID] = mergeDeep(existing, provider) + return + } + const match = database[providerID] + if (!match) return + // @ts-expect-error + providers[providerID] = mergeDeep(match, provider) + } + + // load plugins first so config() hook runs before reading cfg.provider + const plugins = yield* plugin.list() + + // now read config providers - includes any modifications from plugin config() hook + const configProviders = Object.entries(cfg.provider ?? {}) + const disabled = new Set(cfg.disabled_providers ?? []) + const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null + + function isProviderAllowed(providerID: ProviderID): boolean { + if (enabled && !enabled.has(providerID)) return false + if (disabled.has(providerID)) return false + return true + } + + // extend database from config + for (const [providerID, provider] of configProviders) { + const existing = database[providerID] + const parsed: Info = { + id: ProviderID.make(providerID), + name: provider.name ?? existing?.name ?? providerID, + env: provider.env ?? existing?.env ?? [], + options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), + source: "config", + models: existing?.models ?? {}, } - // load plugins first so config() hook runs before reading cfg.provider - const plugins = yield* plugin.list() - - // now read config providers - includes any modifications from plugin config() hook - const configProviders = Object.entries(cfg.provider ?? {}) - const disabled = new Set(cfg.disabled_providers ?? []) - const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null - - function isProviderAllowed(providerID: ProviderID): boolean { - if (enabled && !enabled.has(providerID)) return false - if (disabled.has(providerID)) return false - return true - } - - // extend database from config - for (const [providerID, provider] of configProviders) { - const existing = database[providerID] - const parsed: Info = { - id: ProviderID.make(providerID), - name: provider.name ?? existing?.name ?? providerID, - env: provider.env ?? existing?.env ?? [], - options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), - source: "config", - models: existing?.models ?? {}, + for (const [modelID, model] of Object.entries(provider.models ?? {})) { + const existingModel = parsed.models[model.id ?? modelID] + const name = iife(() => { + if (model.name) return model.name + if (model.id && model.id !== modelID) return modelID + return existingModel?.name ?? modelID + }) + const parsedModel: Model = { + id: ModelID.make(modelID), + api: { + id: model.id ?? existingModel?.api.id ?? modelID, + npm: + model.provider?.npm ?? + provider.npm ?? + existingModel?.api.npm ?? + modelsDev[providerID]?.npm ?? + "@ai-sdk/openai-compatible", + url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, + }, + status: model.status ?? existingModel?.status ?? "active", + name, + providerID: ProviderID.make(providerID), + capabilities: { + temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, + reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, + attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, + toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, + input: { + text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, + audio: + model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, + image: + model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false, + video: + model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false, + pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false, + }, + output: { + text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true, + audio: + model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false, + image: + model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false, + video: + model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false, + pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, + }, + interleaved: model.interleaved ?? false, + }, + cost: { + input: model?.cost?.input ?? existingModel?.cost?.input ?? 0, + output: model?.cost?.output ?? existingModel?.cost?.output ?? 0, + cache: { + read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, + write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, + }, + }, + options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), + limit: { + context: model.limit?.context ?? existingModel?.limit?.context ?? 0, + input: model.limit?.input ?? existingModel?.limit?.input, + output: model.limit?.output ?? existingModel?.limit?.output ?? 0, + }, + headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), + family: model.family ?? existingModel?.family ?? "", + release_date: model.release_date ?? existingModel?.release_date ?? "", + variants: {}, } - - for (const [modelID, model] of Object.entries(provider.models ?? {})) { - const existingModel = parsed.models[model.id ?? modelID] - const name = iife(() => { - if (model.name) return model.name - if (model.id && model.id !== modelID) return modelID - return existingModel?.name ?? modelID - }) - const parsedModel: Model = { - id: ModelID.make(modelID), - api: { - id: model.id ?? existingModel?.api.id ?? modelID, - npm: - model.provider?.npm ?? - provider.npm ?? - existingModel?.api.npm ?? - modelsDev[providerID]?.npm ?? - "@ai-sdk/openai-compatible", - url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, - }, - status: model.status ?? existingModel?.status ?? "active", - name, - providerID: ProviderID.make(providerID), - capabilities: { - temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, - reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, - attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, - toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, - input: { - text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, - audio: - model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, - image: - model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false, - video: - model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false, - pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false, - }, - output: { - text: - model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true, - audio: - model.modalities?.output?.includes("audio") ?? - existingModel?.capabilities.output.audio ?? - false, - image: - model.modalities?.output?.includes("image") ?? - existingModel?.capabilities.output.image ?? - false, - video: - model.modalities?.output?.includes("video") ?? - existingModel?.capabilities.output.video ?? - false, - pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, - }, - interleaved: model.interleaved ?? false, - }, - cost: { - input: model?.cost?.input ?? existingModel?.cost?.input ?? 0, - output: model?.cost?.output ?? existingModel?.cost?.output ?? 0, - cache: { - read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, - write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, - }, - }, - options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), - limit: { - context: model.limit?.context ?? existingModel?.limit?.context ?? 0, - input: model.limit?.input ?? existingModel?.limit?.input, - output: model.limit?.output ?? existingModel?.limit?.output ?? 0, - }, - headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), - family: model.family ?? existingModel?.family ?? "", - release_date: model.release_date ?? existingModel?.release_date ?? "", - variants: {}, - } - const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) - parsedModel.variants = mapValues( - pickBy(merged, (v) => !v.disabled), - (v) => omit(v, ["disabled"]), - ) - parsed.models[modelID] = parsedModel - } - database[providerID] = parsed + const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) + parsedModel.variants = mapValues( + pickBy(merged, (v) => !v.disabled), + (v) => omit(v, ["disabled"]), + ) + parsed.models[modelID] = parsedModel } + database[providerID] = parsed + } - // load env - const vals = yield* env.all() - for (const [id, provider] of Object.entries(database)) { - const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue - const apiKey = provider.env.map((item) => vals[item]).find(Boolean) - if (!apiKey) continue + // load env + const vals = yield* env.all() + for (const [id, provider] of Object.entries(database)) { + const providerID = ProviderID.make(id) + if (disabled.has(providerID)) continue + const apiKey = provider.env.map((item) => vals[item]).find(Boolean) + if (!apiKey) continue + mergeProvider(providerID, { + source: "env", + key: provider.env.length === 1 ? apiKey : undefined, + }) + } + + // load apikeys + const auths = yield* auth.all().pipe(Effect.orDie) + for (const [id, provider] of Object.entries(auths)) { + const providerID = ProviderID.make(id) + if (disabled.has(providerID)) continue + if (provider.type === "api") { mergeProvider(providerID, { - source: "env", - key: provider.env.length === 1 ? apiKey : undefined, + source: "api", + key: provider.key, }) } + } - // load apikeys - const auths = yield* auth.all().pipe(Effect.orDie) - for (const [id, provider] of Object.entries(auths)) { - const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue - if (provider.type === "api") { - mergeProvider(providerID, { - source: "api", - key: provider.key, - }) - } + // plugin auth loader - database now has entries for config providers + for (const plugin of plugins) { + if (!plugin.auth) continue + const providerID = ProviderID.make(plugin.auth.provider) + if (disabled.has(providerID)) continue + + const stored = yield* auth.get(providerID).pipe(Effect.orDie) + if (!stored) continue + if (!plugin.auth.loader) continue + + const options = yield* Effect.promise(() => + plugin.auth!.loader!( + () => + Effect.runPromise(auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer))) as any, + database[plugin.auth!.provider], + ), + ) + const opts = options ?? {} + const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } + mergeProvider(providerID, patch) + } + + for (const [id, fn] of Object.entries(custom(dep, env))) { + const providerID = ProviderID.make(id) + if (disabled.has(providerID)) continue + const data = database[providerID] + if (!data) { + log.error("Provider does not exist in model list " + providerID) + continue } - - // plugin auth loader - database now has entries for config providers - for (const plugin of plugins) { - if (!plugin.auth) continue - const providerID = ProviderID.make(plugin.auth.provider) - if (disabled.has(providerID)) continue - - const stored = yield* auth.get(providerID).pipe(Effect.orDie) - if (!stored) continue - if (!plugin.auth.loader) continue - - const options = yield* Effect.promise(() => - plugin.auth!.loader!( - () => - Effect.runPromise( - auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer)), - ) as any, - database[plugin.auth!.provider], - ), - ) - const opts = options ?? {} + const result = yield* fn(data) + if (result && (result.autoload || providers[providerID])) { + if (result.getModel) modelLoaders[providerID] = result.getModel + if (result.vars) varsLoaders[providerID] = result.vars + if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels + const opts = result.options ?? {} const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } mergeProvider(providerID, patch) } + } - for (const [id, fn] of Object.entries(custom(dep, env))) { - const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue - const data = database[providerID] - if (!data) { - log.error("Provider does not exist in model list " + providerID) - continue - } - const result = yield* fn(data) - if (result && (result.autoload || providers[providerID])) { - if (result.getModel) modelLoaders[providerID] = result.getModel - if (result.vars) varsLoaders[providerID] = result.vars - if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels - const opts = result.options ?? {} - const patch: Partial = providers[providerID] - ? { options: opts } - : { source: "custom", options: opts } - mergeProvider(providerID, patch) - } - } + // load config - re-apply with updated data + for (const [id, provider] of configProviders) { + const providerID = ProviderID.make(id) + const partial: Partial = { source: "config" } + if (provider.env) partial.env = provider.env + if (provider.name) partial.name = provider.name + if (provider.options) partial.options = provider.options + mergeProvider(providerID, partial) + } - // load config - re-apply with updated data - for (const [id, provider] of configProviders) { - const providerID = ProviderID.make(id) - const partial: Partial = { source: "config" } - if (provider.env) partial.env = provider.env - if (provider.name) partial.name = provider.name - if (provider.options) partial.options = provider.options - mergeProvider(providerID, partial) - } - - const gitlab = ProviderID.make("gitlab") - if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { - yield* Effect.promise(async () => { - try { - const discovered = await discoveryLoaders[gitlab]() - for (const [modelID, model] of Object.entries(discovered)) { - if (!providers[gitlab].models[modelID]) { - providers[gitlab].models[modelID] = model - } + const gitlab = ProviderID.make("gitlab") + if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { + yield* Effect.promise(async () => { + try { + const discovered = await discoveryLoaders[gitlab]() + for (const [modelID, model] of Object.entries(discovered)) { + if (!providers[gitlab].models[modelID]) { + providers[gitlab].models[modelID] = model } - } catch (e) { - log.warn("state discovery error", { id: "gitlab", error: e }) } - }) - } - - for (const hook of plugins) { - const p = hook.provider - const models = p?.models - if (!p || !models) continue - - const providerID = ProviderID.make(p.id) - if (disabled.has(providerID)) continue - - const provider = providers[providerID] - if (!provider) continue - const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) - - provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) - return Object.fromEntries( - Object.entries(next).map(([id, model]) => [ - id, - { - ...model, - id: ModelID.make(id), - providerID, - }, - ]), - ) - }) - } - - for (const [id, provider] of Object.entries(providers)) { - const providerID = ProviderID.make(id) - if (!isProviderAllowed(providerID)) { - delete providers[providerID] - continue + } catch (e) { + log.warn("state discovery error", { id: "gitlab", error: e }) } + }) + } - const configProvider = cfg.provider?.[providerID] + for (const hook of plugins) { + const p = hook.provider + const models = p?.models + if (!p || !models) continue - for (const [modelID, model] of Object.entries(provider.models)) { - model.api.id = model.api.id ?? model.id ?? modelID - if ( - modelID === "gpt-5-chat-latest" || - (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat") - ) - delete provider.models[modelID] - if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) - delete provider.models[modelID] - if (model.status === "deprecated") delete provider.models[modelID] - if ( - (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) || - (configProvider?.whitelist && !configProvider.whitelist.includes(modelID)) - ) - delete provider.models[modelID] + const providerID = ProviderID.make(p.id) + if (disabled.has(providerID)) continue - model.variants = mapValues(ProviderTransform.variants(model), (v) => v) + const provider = providers[providerID] + if (!provider) continue + const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) - const configVariants = configProvider?.models?.[modelID]?.variants - if (configVariants && model.variants) { - const merged = mergeDeep(model.variants, configVariants) - model.variants = mapValues( - pickBy(merged, (v) => !v.disabled), - (v) => omit(v, ["disabled"]), - ) - } - } + provider.models = yield* Effect.promise(async () => { + const next = await models(provider, { auth: pluginAuth }) + return Object.fromEntries( + Object.entries(next).map(([id, model]) => [ + id, + { + ...model, + id: ModelID.make(id), + providerID, + }, + ]), + ) + }) + } - if (Object.keys(provider.models).length === 0) { - delete providers[providerID] - continue - } - - log.info("found", { providerID }) + for (const [id, provider] of Object.entries(providers)) { + const providerID = ProviderID.make(id) + if (!isProviderAllowed(providerID)) { + delete providers[providerID] + continue } - return { - models: languages, - providers, - sdk, - modelLoaders, - varsLoaders, + const configProvider = cfg.provider?.[providerID] + + for (const [modelID, model] of Object.entries(provider.models)) { + model.api.id = model.api.id ?? model.id ?? modelID + if ( + modelID === "gpt-5-chat-latest" || + (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat") + ) + delete provider.models[modelID] + if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID] + if (model.status === "deprecated") delete provider.models[modelID] + if ( + (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) || + (configProvider?.whitelist && !configProvider.whitelist.includes(modelID)) + ) + delete provider.models[modelID] + + model.variants = mapValues(ProviderTransform.variants(model), (v) => v) + + const configVariants = configProvider?.models?.[modelID]?.variants + if (configVariants && model.variants) { + const merged = mergeDeep(model.variants, configVariants) + model.variants = mapValues( + pickBy(merged, (v) => !v.disabled), + (v) => omit(v, ["disabled"]), + ) + } } - }), - ) - const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) + if (Object.keys(provider.models).length === 0) { + delete providers[providerID] + continue + } - async function resolveSDK(model: Model, s: State, envs: Record) { - try { - using _ = log.time("getSDK", { + log.info("found", { providerID }) + } + + return { + models: languages, + providers, + sdk, + modelLoaders, + varsLoaders, + } + }), + ) + + const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) + + async function resolveSDK(model: Model, s: State, envs: Record) { + try { + using _ = log.time("getSDK", { + providerID: model.providerID, + }) + const provider = s.providers[model.providerID] + const options = { ...provider.options } + + if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) { + delete options.fetch + } + + if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { + options["includeUsage"] = true + } + + const baseURL = iife(() => { + let url = + typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url + if (!url) return + + const loader = s.varsLoaders[model.providerID] + if (loader) { + const vars = loader(options) + for (const [key, value] of Object.entries(vars)) { + const field = "${" + key + "}" + url = url.replaceAll(field, value) + } + } + + url = url.replace(/\$\{([^}]+)\}/g, (item, key) => { + const val = envs[String(key)] + return val ?? item + }) + return url + }) + + if (baseURL !== undefined) options["baseURL"] = baseURL + if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key + if (model.headers) + options["headers"] = { + ...options["headers"], + ...model.headers, + } + + const key = Hash.fast( + JSON.stringify({ providerID: model.providerID, - }) - const provider = s.providers[model.providerID] - const options = { ...provider.options } + npm: model.api.npm, + options, + }), + ) + const existing = s.sdk.get(key) + if (existing) return existing - if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) { - delete options.fetch - } + const customFetch = options["fetch"] + const chunkTimeout = options["chunkTimeout"] + delete options["chunkTimeout"] - if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { - options["includeUsage"] = true - } + options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { + const fetchFn = customFetch ?? fetch + const opts = init ?? {} + const chunkAbortCtl = + typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined + const signals: AbortSignal[] = [] - const baseURL = iife(() => { - let url = - typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url - if (!url) return + if (opts.signal) signals.push(opts.signal) + if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) + if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false) + signals.push(AbortSignal.timeout(options["timeout"])) - const loader = s.varsLoaders[model.providerID] - if (loader) { - const vars = loader(options) - for (const [key, value] of Object.entries(vars)) { - const field = "${" + key + "}" - url = url.replaceAll(field, value) - } - } + const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) + if (combined) opts.signal = combined - url = url.replace(/\$\{([^}]+)\}/g, (item, key) => { - const val = envs[String(key)] - return val ?? item - }) - return url - }) - - if (baseURL !== undefined) options["baseURL"] = baseURL - if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key - if (model.headers) - options["headers"] = { - ...options["headers"], - ...model.headers, - } - - const key = Hash.fast( - JSON.stringify({ - providerID: model.providerID, - npm: model.api.npm, - options, - }), - ) - const existing = s.sdk.get(key) - if (existing) return existing - - const customFetch = options["fetch"] - const chunkTimeout = options["chunkTimeout"] - delete options["chunkTimeout"] - - options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { - const fetchFn = customFetch ?? fetch - const opts = init ?? {} - const chunkAbortCtl = - typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined - const signals: AbortSignal[] = [] - - if (opts.signal) signals.push(opts.signal) - if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) - if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false) - signals.push(AbortSignal.timeout(options["timeout"])) - - const combined = - signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) - if (combined) opts.signal = combined - - // Strip openai itemId metadata following what codex does - if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { - const body = JSON.parse(opts.body as string) - const isAzure = model.providerID.includes("azure") - const keepIds = isAzure && body.store === true - if (!keepIds && Array.isArray(body.input)) { - for (const item of body.input) { - if ("id" in item) { - delete item.id - } + // Strip openai itemId metadata following what codex does + if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { + const body = JSON.parse(opts.body as string) + const isAzure = model.providerID.includes("azure") + const keepIds = isAzure && body.store === true + if (!keepIds && Array.isArray(body.input)) { + for (const item of body.input) { + if ("id" in item) { + delete item.id } - opts.body = JSON.stringify(body) } + opts.body = JSON.stringify(body) } - - const res = await fetchFn(input, { - ...opts, - // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 - timeout: false, - }) - - if (!chunkAbortCtl) return res - return wrapSSE(res, chunkTimeout, chunkAbortCtl) } - const bundledFn = BUNDLED_PROVIDERS[model.api.npm] - if (bundledFn) { - log.info("using bundled provider", { - providerID: model.providerID, - pkg: model.api.npm, - }) - const loaded = bundledFn({ - name: model.providerID, - ...options, - }) - s.sdk.set(key, loaded) - return loaded as SDK - } + const res = await fetchFn(input, { + ...opts, + // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 + timeout: false, + }) - let installedPath: string - if (!model.api.npm.startsWith("file://")) { - const item = await Npm.add(model.api.npm) - if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`) - installedPath = item.entrypoint - } else { - log.info("loading local provider", { pkg: model.api.npm }) - installedPath = model.api.npm - } + if (!chunkAbortCtl) return res + return wrapSSE(res, chunkTimeout, chunkAbortCtl) + } - const mod = await import(installedPath) - - const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] - const loaded = fn({ + const bundledFn = BUNDLED_PROVIDERS[model.api.npm] + if (bundledFn) { + log.info("using bundled provider", { + providerID: model.providerID, + pkg: model.api.npm, + }) + const loaded = bundledFn({ name: model.providerID, ...options, }) s.sdk.set(key, loaded) return loaded as SDK + } + + let installedPath: string + if (!model.api.npm.startsWith("file://")) { + const item = await Npm.add(model.api.npm) + if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`) + installedPath = item.entrypoint + } else { + log.info("loading local provider", { pkg: model.api.npm }) + installedPath = model.api.npm + } + + const mod = await import(installedPath) + + const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] + const loaded = fn({ + name: model.providerID, + ...options, + }) + s.sdk.set(key, loaded) + return loaded as SDK + } catch (e) { + throw new InitError({ providerID: model.providerID }, { cause: e }) + } + } + + const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) => + InstanceState.use(state, (s) => s.providers[providerID]), + ) + + const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) { + const s = yield* InstanceState.get(state) + const provider = s.providers[providerID] + if (!provider) { + const available = Object.keys(s.providers) + const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 }) + throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) }) + } + + const info = provider.models[modelID] + if (!info) { + const available = Object.keys(provider.models) + const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }) + throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) }) + } + return info + }) + + const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) { + const s = yield* InstanceState.get(state) + const key = `${model.providerID}/${model.id}` + if (s.models.has(key)) return s.models.get(key)! + const vals = yield* env.all() + + return yield* Effect.promise(async () => { + const url = e2eURL(vals) + if (url) { + const language = createOpenAICompatible({ + name: model.providerID, + apiKey: "test-key", + baseURL: url, + }).chatModel(model.api.id) + s.models.set(key, language) + return language + } + + const provider = s.providers[model.providerID] + const sdk = await resolveSDK(model, s, vals) + + try { + const language = s.modelLoaders[model.providerID] + ? await s.modelLoaders[model.providerID](sdk, model.api.id, { + ...provider.options, + ...model.options, + }) + : sdk.languageModel(model.api.id) + s.models.set(key, language) + return language } catch (e) { - throw new InitError({ providerID: model.providerID }, { cause: e }) + if (e instanceof NoSuchModelError) + throw new ModelNotFoundError( + { + modelID: model.id, + providerID: model.providerID, + }, + { cause: e }, + ) + throw e + } + }) + }) + + const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) { + const s = yield* InstanceState.get(state) + const provider = s.providers[providerID] + if (!provider) return undefined + for (const item of query) { + for (const modelID of Object.keys(provider.models)) { + if (modelID.includes(item)) return { providerID, modelID } + } + } + return undefined + }) + + const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) { + const cfg = yield* config.get() + + if (cfg.small_model) { + const parsed = parseModel(cfg.small_model) + return yield* getModel(parsed.providerID, parsed.modelID) + } + + const s = yield* InstanceState.get(state) + const provider = s.providers[providerID] + if (!provider) return undefined + + let priority = [ + "claude-haiku-4-5", + "claude-haiku-4.5", + "3-5-haiku", + "3.5-haiku", + "gemini-3-flash", + "gemini-2.5-flash", + "gpt-5-nano", + ] + if (providerID.startsWith("opencode")) { + priority = ["gpt-5-nano"] + } + if (providerID.startsWith("github-copilot")) { + priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority] + } + for (const item of priority) { + if (providerID === ProviderID.amazonBedrock) { + const crossRegionPrefixes = ["global.", "us.", "eu."] + const candidates = Object.keys(provider.models).filter((m) => m.includes(item)) + + const globalMatch = candidates.find((m) => m.startsWith("global.")) + if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch)) + + const region = provider.options?.region + if (region) { + const regionPrefix = region.split("-")[0] + if (regionPrefix === "us" || regionPrefix === "eu") { + const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) + if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch)) + } + } + + const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p))) + if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed)) + } else { + for (const model of Object.keys(provider.models)) { + if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model)) + } } } - const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) => - InstanceState.use(state, (s) => s.providers[providerID]), + return undefined + }) + + const defaultModel = Effect.fn("Provider.defaultModel")(function* () { + const cfg = yield* config.get() + if (cfg.model) return parseModel(cfg.model) + + const s = yield* InstanceState.get(state) + const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe( + Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => { + if (!isRecord(x) || !Array.isArray(x.recent)) return [] + return x.recent.flatMap((item) => { + if (!isRecord(item)) return [] + if (typeof item.providerID !== "string") return [] + if (typeof item.modelID !== "string") return [] + return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }] + }) + }), + Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])), ) + for (const entry of recent) { + const provider = s.providers[entry.providerID] + if (!provider) continue + if (!provider.models[entry.modelID]) continue + return { providerID: entry.providerID, modelID: entry.modelID } + } - const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) { - const s = yield* InstanceState.get(state) - const provider = s.providers[providerID] - if (!provider) { - const available = Object.keys(s.providers) - const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 }) - throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) }) - } + const provider = Object.values(s.providers).find( + (p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id), + ) + if (!provider) throw new Error("no providers found") + const [model] = sort(Object.values(provider.models)) + if (!model) throw new Error("no models found") + return { + providerID: provider.id, + modelID: model.id, + } + }) - const info = provider.models[modelID] - if (!info) { - const available = Object.keys(provider.models) - const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }) - throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) }) - } - return info - }) - - const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) { - const s = yield* InstanceState.get(state) - const key = `${model.providerID}/${model.id}` - if (s.models.has(key)) return s.models.get(key)! - const vals = yield* env.all() - - return yield* Effect.promise(async () => { - const url = e2eURL(vals) - if (url) { - const language = createOpenAICompatible({ - name: model.providerID, - apiKey: "test-key", - baseURL: url, - }).chatModel(model.api.id) - s.models.set(key, language) - return language - } - - const provider = s.providers[model.providerID] - const sdk = await resolveSDK(model, s, vals) - - try { - const language = s.modelLoaders[model.providerID] - ? await s.modelLoaders[model.providerID](sdk, model.api.id, { - ...provider.options, - ...model.options, - }) - : sdk.languageModel(model.api.id) - s.models.set(key, language) - return language - } catch (e) { - if (e instanceof NoSuchModelError) - throw new ModelNotFoundError( - { - modelID: model.id, - providerID: model.providerID, - }, - { cause: e }, - ) - throw e - } - }) - }) - - const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) { - const s = yield* InstanceState.get(state) - const provider = s.providers[providerID] - if (!provider) return undefined - for (const item of query) { - for (const modelID of Object.keys(provider.models)) { - if (modelID.includes(item)) return { providerID, modelID } - } - } - return undefined - }) - - const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) { - const cfg = yield* config.get() - - if (cfg.small_model) { - const parsed = parseModel(cfg.small_model) - return yield* getModel(parsed.providerID, parsed.modelID) - } - - const s = yield* InstanceState.get(state) - const provider = s.providers[providerID] - if (!provider) return undefined - - let priority = [ - "claude-haiku-4-5", - "claude-haiku-4.5", - "3-5-haiku", - "3.5-haiku", - "gemini-3-flash", - "gemini-2.5-flash", - "gpt-5-nano", - ] - if (providerID.startsWith("opencode")) { - priority = ["gpt-5-nano"] - } - if (providerID.startsWith("github-copilot")) { - priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority] - } - for (const item of priority) { - if (providerID === ProviderID.amazonBedrock) { - const crossRegionPrefixes = ["global.", "us.", "eu."] - const candidates = Object.keys(provider.models).filter((m) => m.includes(item)) - - const globalMatch = candidates.find((m) => m.startsWith("global.")) - if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch)) - - const region = provider.options?.region - if (region) { - const regionPrefix = region.split("-")[0] - if (regionPrefix === "us" || regionPrefix === "eu") { - const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) - if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch)) - } - } - - const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p))) - if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed)) - } else { - for (const model of Object.keys(provider.models)) { - if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model)) - } - } - } - - return undefined - }) - - const defaultModel = Effect.fn("Provider.defaultModel")(function* () { - const cfg = yield* config.get() - if (cfg.model) return parseModel(cfg.model) - - const s = yield* InstanceState.get(state) - const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe( - Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => { - if (!isRecord(x) || !Array.isArray(x.recent)) return [] - return x.recent.flatMap((item) => { - if (!isRecord(item)) return [] - if (typeof item.providerID !== "string") return [] - if (typeof item.modelID !== "string") return [] - return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }] - }) - }), - Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])), - ) - for (const entry of recent) { - const provider = s.providers[entry.providerID] - if (!provider) continue - if (!provider.models[entry.modelID]) continue - return { providerID: entry.providerID, modelID: entry.modelID } - } - - const provider = Object.values(s.providers).find( - (p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id), - ) - if (!provider) throw new Error("no providers found") - const [model] = sort(Object.values(provider.models)) - if (!model) throw new Error("no models found") - return { - providerID: provider.id, - modelID: model.id, - } - }) - - return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel }) - }), - ) + return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel }) + }), + ) export const defaultLayer = Layer.suspend(() => layer.pipe(