diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 31fcac19b9..26b4bc624c 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -178,7 +178,9 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow ## Migration checklist -Fully migrated (single namespace, InstanceState where needed, flattened facade): +Service-shape migrated (single namespace, traced methods, `InstanceState` where needed). + +This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in [Destroying the facades](#destroying-the-facades). - [x] `Account` — `account/index.ts` - [x] `Agent` — `agent/agent.ts` @@ -221,20 +223,22 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade): - [x] `Provider` — `provider/provider.ts` - [x] `Storage` — `storage/storage.ts` - [x] `ShareNext` — `share/share-next.ts` - -Still open: - - [x] `SessionTodo` — `session/todo.ts` -- [ ] `SyncEvent` — `sync/index.ts` -- [ ] `Workspace` — `control-plane/workspace.ts` + +Still open at the service-shape level: + +- [ ] `SyncEvent` — `sync/index.ts` (deferred pending sync with James) +- [ ] `Workspace` — `control-plane/workspace.ts` (deferred pending sync with James) ## Tool interface → Effect -`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is: +`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch, and the current tools in `src/tool/*.ts` have been migrated to the Effect-native `Tool.define(...)` shape. -1. Migrate each tool body to return Effects -2. Keep `Tool.define()` inputs Effect-native -3. Update remaining callers to `yield*` tool initialization instead of `await`ing +The remaining work here is follow-on cleanup rather than the top-level tool interface migration: + +1. Remove internal `Effect.promise(...)` bridges where practical +2. Keep replacing raw platform helpers with Effect services inside tool bodies +3. Update remaining callers and tests to prefer `yield* info.init()` / `Tool.init(...)` over older Promise-oriented patterns ### Tool migration details @@ -254,26 +258,27 @@ This keeps migrated tool tests aligned with the production service graph today, Individual tools, ordered by value: -- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events -- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture -- [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream -- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock -- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling -- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events -- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout -- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient -- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient -- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all -- [ ] `task.ts` — MEDIUM: task state management -- [ ] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal -- [ ] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts` -- [ ] `glob.ts` — LOW: simple async generator -- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations -- [ ] `question.ts` — LOW: prompt wrapper -- [ ] `skill.ts` — LOW: skill tool adapter -- [ ] `todo.ts` — LOW: todo persistence wrapper -- [ ] `invalid.ts` — LOW: invalid-tool fallback -- [ ] `plan.ts` — LOW: plan file operations +- [x] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events +- [x] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture +- [x] `read.ts` — HIGH: effectful interface migrated; still has raw fs/readline internals tracked below +- [x] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock +- [x] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling +- [x] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events +- [x] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout +- [x] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient +- [x] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient +- [x] `task.ts` — MEDIUM: task state management +- [x] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal +- [x] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts` +- [x] `glob.ts` — LOW: simple async generator +- [x] `lsp.ts` — LOW: dispatch switch over LSP operations +- [x] `question.ts` — LOW: prompt wrapper +- [x] `skill.ts` — LOW: skill tool adapter +- [x] `todo.ts` — LOW: todo persistence wrapper +- [x] `invalid.ts` — LOW: invalid-tool fallback +- [x] `plan.ts` — LOW: plan file operations + +`batch.ts` was removed from `src/tool/` and is no longer tracked here. ## Effect service adoption in already-migrated code @@ -281,25 +286,21 @@ Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` i ### `Filesystem.*` → `AppFileSystem.Service` (yield in layer) -- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling -- [ ] `config/config.ts` — 5 remaining `Filesystem.*` calls in `installDependencies()` -- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state +- [x] `config/config.ts` — `installDependencies()` now uses `AppFileSystem` +- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service` ### `Process.spawn` → `ChildProcessSpawner` (yield in layer) -- [ ] `format/formatter.ts` — 2 remaining `Process.spawn()` checks (`air`, `uv`) +- [x] `format/formatter.ts` — direct `Process.spawn()` checks removed (`air`, `uv`) - [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers ## Filesystem consolidation -`util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort. - -Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched. +`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep. Current raw fs users that will convert during tool migration: - `tool/read.ts` — fs.createReadStream, readline -- `tool/apply_patch.ts` — fs/promises - `file/ripgrep.ts` — fs/promises - `patch/index.ts` — fs, fs/promises @@ -312,7 +313,9 @@ Current raw fs users that will convert during tool migration: ## Destroying the facades -Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them. +This phase is still broadly open. As of 2026-04-11 there are still 31 `makeRuntime(...)` call sites under `src/`, and many service namespaces still export async facade helpers like `export async function read(...) { return runPromise(...) }`. + +These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them. ### Process diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ecce8fb8f8..ab3abaf94e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -22,21 +22,19 @@ import { Instance, type InstanceContext } from "../project/instance" import { LSPServer } from "../lsp/server" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" -import { constants, existsSync } from "fs" +import { existsSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" import { Glob } from "../util/glob" -import { iife } from "@/util/iife" import { Account } from "@/account" import { isRecord } from "@/util/record" import { ConfigPaths } from "./paths" -import { Filesystem } from "@/util/filesystem" import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@/filesystem" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" -import { Duration, Effect, Layer, Option, Context } from "effect" +import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { Flock } from "@/util/flock" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { Npm } from "@/npm" @@ -140,53 +138,11 @@ export namespace Config { } export type InstallInput = { - signal?: AbortSignal waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise } - export async function installDependencies(dir: string, input?: InstallInput) { - if (!(await isWritable(dir))) return - await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, { - signal: input?.signal, - onWait: (tick) => - input?.waitTick?.({ - dir, - attempt: tick.attempt, - delay: tick.delay, - waited: tick.waited, - }), - }) - input?.signal?.throwIfAborted() - - const pkg = path.join(dir, "package.json") - const target = Installation.isLocal() ? "*" : Installation.VERSION - const json = await Filesystem.readJson<{ dependencies?: Record }>(pkg).catch(() => ({ - dependencies: {}, - })) - json.dependencies = { - ...json.dependencies, - "@opencode-ai/plugin": target, - } - await Filesystem.writeJson(pkg, json) - - const gitignore = path.join(dir, ".gitignore") - const ignore = await Filesystem.exists(gitignore) - if (!ignore) { - await Filesystem.write( - gitignore, - ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), - ) - } - await Npm.install(dir) - } - - async function isWritable(dir: string) { - try { - await fsNode.access(dir, constants.W_OK) - return true - } catch { - return false - } + type Package = { + dependencies?: Record } function rel(item: string, patterns: string[]) { @@ -1111,7 +1067,7 @@ export namespace Config { type State = { config: Info directories: string[] - deps: Promise[] + deps: Fiber.Fiber[] consoleState: ConsoleState } @@ -1119,6 +1075,7 @@ export namespace Config { readonly get: () => Effect.Effect readonly getGlobal: () => Effect.Effect readonly getConsoleState: () => Effect.Effect + readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect readonly update: (config: Info) => Effect.Effect readonly updateGlobal: (config: Info) => Effect.Effect readonly invalidate: (wait?: boolean) => Effect.Effect @@ -1320,6 +1277,74 @@ export namespace Config { 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) @@ -1402,7 +1427,7 @@ export namespace Config { log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) } - const deps: Promise[] = [] + const deps: Fiber.Fiber[] = [] for (const dir of unique(directories)) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { @@ -1416,12 +1441,18 @@ export namespace Config { } } - const dep = iife(async () => { - await installDependencies(dir) - }) - void dep.catch((err) => { - log.warn("background dependency install failed", { dir, error: err }) - }) + 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))) @@ -1558,7 +1589,9 @@ export namespace Config { }) const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { - yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined))) + 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) { @@ -1613,6 +1646,7 @@ export namespace Config { get, getGlobal, getConsoleState, + installDependencies, update, updateGlobal, invalidate, @@ -1642,6 +1676,10 @@ export namespace Config { return runPromise((svc) => svc.getConsoleState()) } + export async function installDependencies(dir: string, input?: InstallInput) { + return runPromise((svc) => svc.installDependencies(dir, input)) + } + export async function update(config: Info) { return runPromise((svc) => svc.update(config)) } diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index a4cecd4f71..ada661ebab 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,4 +1,3 @@ -import { text } from "node:stream/consumers" import { Npm } from "@/npm" import { Instance } from "../project/instance" import { Filesystem } from "../util/filesystem" @@ -217,26 +216,16 @@ export const rlang: Info = { name: "air", extensions: [".R"], async enabled() { - const airPath = which("air") - if (airPath == null) return false + const air = which("air") + if (air == null) return false - try { - const proc = Process.spawn(["air", "--help"], { - stdout: "pipe", - stderr: "pipe", - }) - await proc.exited - if (!proc.stdout) return false - const output = await text(proc.stdout) + const output = await Process.text([air, "--help"], { nothrow: true }) - // Check for "Air: An R language server and formatter" - const firstLine = output.split("\n")[0] - const hasR = firstLine.includes("R language") - const hasFormatter = firstLine.includes("formatter") - if (hasR && hasFormatter) return ["air", "format", "$FILE"] - } catch { - return false - } + // Check for "Air: An R language server and formatter" + const firstLine = output.text.split("\n")[0] + const hasR = firstLine.includes("R language") + const hasFormatter = firstLine.includes("formatter") + if (output.code === 0 && hasR && hasFormatter) return [air, "format", "$FILE"] return false }, } @@ -246,11 +235,10 @@ export const uvformat: Info = { extensions: [".py", ".pyi"], async enabled() { if (await ruff.enabled()) return false - if (which("uv") !== null) { - const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) - const code = await proc.exited - if (code === 0) return ["uv", "format", "--", "$FILE"] - } + const uv = which("uv") + if (uv == null) return false + const output = await Process.run([uv, "format", "--help"], { nothrow: true }) + if (output.code === 0) return [uv, "format", "--", "$FILE"] return false }, } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index ef822739dd..a26b254d5a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -18,11 +18,12 @@ import { Flag } from "../flag/flag" import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" -import { Filesystem } from "../util/filesystem" import { Effect, Layer, Context } from "effect" import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { AppFileSystem } from "@/filesystem" +import { isRecord } from "@/util/record" // Direct imports for bundled providers import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -1030,641 +1031,662 @@ export namespace Provider { } } - const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - const auth = yield* Auth.Service - const plugin = yield* Plugin.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 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(), - } - - 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 - // @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 ?? {}, + 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(), } - 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: {}, + 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 merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) - parsedModel.variants = mapValues( - pickBy(merged, (v) => !v.disabled), - (v) => omit(v, ["disabled"]), - ) - parsed.models[modelID] = parsedModel + const match = database[providerID] + if (!match) return + // @ts-expect-error + providers[providerID] = mergeDeep(match, provider) } - database[providerID] = parsed - } - // load env - const env = 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) => env[item]).find(Boolean) - if (!apiKey) continue - mergeProvider(providerID, { - source: "env", - key: provider.env.length === 1 ? apiKey : undefined, - }) - } + // load plugins first so config() hook runs before reading cfg.provider + const plugins = yield* plugin.list() - // 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") { + // 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: {}, + } + 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 env = 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) => env[item]).find(Boolean) + if (!apiKey) continue mergeProvider(providerID, { - source: "api", - key: provider.key, + source: "env", + key: provider.env.length === 1 ? apiKey : undefined, }) } - } - // 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))) { - 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 + // 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, + }) + } } - 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 ?? {} + + // 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) } - } - // 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 - } - } - } catch (e) { - log.warn("state discovery error", { id: "gitlab", error: e }) + for (const [id, fn] of Object.entries(custom(dep))) { + 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) } - }) - } - - 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 } - const configProvider = cfg.provider?.[providerID] + // 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) + } - 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 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 }) + } + }) + } - model.variants = mapValues(ProviderTransform.variants(model), (v) => v) + for (const hook of plugins) { + const p = hook.provider + const models = p?.models + if (!p || !models) continue - 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 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 } - } - if (Object.keys(provider.models).length === 0) { - delete providers[providerID] - continue - } + const configProvider = cfg.provider?.[providerID] - log.info("found", { 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] - return { - models: languages, - providers, - sdk, - modelLoaders, - varsLoaders, - } - }), - ) + model.variants = mapValues(ProviderTransform.variants(model), (v) => v) - const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) - - async function resolveSDK(model: Model, s: State) { - 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 = Env.get(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 - } + 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"]), + ) } - opts.body = JSON.stringify(body) } + + if (Object.keys(provider.models).length === 0) { + delete providers[providerID] + continue + } + + log.info("found", { providerID }) } - const res = await fetchFn(input, { - ...opts, - // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 - timeout: false, - }) + return { + models: languages, + providers, + sdk, + modelLoaders, + varsLoaders, + } + }), + ) - if (!chunkAbortCtl) return res - return wrapSSE(res, chunkTimeout, chunkAbortCtl) - } + const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) - const bundledFn = BUNDLED_PROVIDERS[model.api.npm] - if (bundledFn) { - log.info("using bundled provider", { + async function resolveSDK(model: Model, s: State) { + try { + using _ = log.time("getSDK", { providerID: model.providerID, - pkg: model.api.npm, }) - const loaded = bundledFn({ + 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 = Env.get(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 + } + } + 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 + } + + 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 - } - - 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)! - - return yield* Effect.promise(async () => { - const url = e2eURL() - 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) - - 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 + 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 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 } + 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)! + + return yield* Effect.promise(async () => { + const url = e2eURL() + 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) + + 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 - }) + return undefined + }) - const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) { - const cfg = yield* config.get() + 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) - } + 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 + 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)) + 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 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 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 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 - }) + return undefined + }) - const defaultModel = Effect.fn("Provider.defaultModel")(function* () { - const cfg = yield* config.get() - if (cfg.model) return parseModel(cfg.model) + 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* Effect.promise(() => - Filesystem.readJson<{ - recent?: { providerID: ProviderID; modelID: ModelID }[] - }>(path.join(Global.Path.state, "model.json")) - .then((x): { providerID: ProviderID; modelID: ModelID }[] => (Array.isArray(x.recent) ? x.recent : [])) - .catch((): { 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 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, - } - }) + 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( + Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer), diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index e9e62d2a70..119517b10c 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -6,7 +6,6 @@ import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { Global } from "../../../src/global" import { TuiConfig } from "../../../src/config/tui" -import { Config } from "../../../src/config/config" import { Filesystem } from "../../../src/util/filesystem" const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") @@ -325,7 +324,6 @@ export default { }) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() - const install = spyOn(Config, "installDependencies").mockResolvedValue() try { expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true) @@ -407,7 +405,6 @@ export default { await TuiPluginRuntime.dispose() cwd.mockRestore() wait.mockRestore() - install.mockRestore() if (backup === undefined) { await fs.rm(globalConfigPath, { force: true }) } else { @@ -701,7 +698,6 @@ test("updates installed theme when plugin metadata changes", async () => { process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() - const install = spyOn(Config, "installDependencies").mockResolvedValue() const api = () => createTuiPluginApi({ @@ -746,7 +742,6 @@ test("updates installed theme when plugin metadata changes", async () => { await TuiPluginRuntime.dispose() cwd.mockRestore() wait.mockRestore() - install.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 0ac61aee71..d6931975c1 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test" -import { Effect, Layer, Option } from "effect" +import { Deferred, Effect, Fiber, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" @@ -7,8 +7,9 @@ import { Auth } from "../../src/auth" import { AccessToken, Account, AccountID, OrgID } from "../../src/account" import { AppFileSystem } from "../../src/filesystem" import { provideTmpdirInstance } from "../fixture/fixture" -import { tmpdir } from "../fixture/fixture" +import { tmpdir, tmpdirScoped } from "../fixture/fixture" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { testEffect } from "../lib/effect" /** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ const infra = CrossSpawnSpawner.defaultLayer.pipe( @@ -32,6 +33,18 @@ const emptyAuth = Layer.mock(Auth.Service)({ all: () => Effect.succeed({}), }) +const it = testEffect( + Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(emptyAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), + ), +) + +const installDeps = (dir: string, input?: Config.InstallInput) => + Config.Service.use((svc) => svc.installDependencies(dir, input)) + // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! @@ -817,128 +830,134 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { } }) -test("dedupes concurrent config dependency installs for the same dir", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "a") - await fs.mkdir(dir, { recursive: true }) +it.live("dedupes concurrent config dependency installs for the same dir", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const dir = path.join(tmp, "a") + yield* Effect.promise(() => fs.mkdir(dir, { recursive: true })) - const ticks: number[] = [] - let calls = 0 - let start = () => {} - let done = () => {} - let blocked = () => {} - const ready = new Promise((resolve) => { - start = resolve - }) - const gate = new Promise((resolve) => { - done = resolve - }) - const waiting = new Promise((resolve) => { - blocked = resolve - }) - const online = spyOn(Network, "online").mockReturnValue(false) - const targetDir = dir - const run = spyOn(Npm, "install").mockImplementation(async (d: string) => { - const hit = path.normalize(d) === path.normalize(targetDir) - if (hit) { + let calls = 0 + const online = spyOn(Network, "online").mockReturnValue(false) + const ready = Deferred.makeUnsafe() + const blocked = Deferred.makeUnsafe() + const hold = Deferred.makeUnsafe() + const target = path.normalize(dir) + const run = spyOn(Npm, "install").mockImplementation(async (d: string) => { + if (path.normalize(d) !== target) return calls += 1 - start() - await gate - } - const mod = path.join(d, "node_modules", "@opencode-ai", "plugin") - await fs.mkdir(mod, { recursive: true }) - await Filesystem.write( - path.join(mod, "package.json"), - JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }), - ) - if (hit) { - start() - await gate - } - }) - - try { - const first = Config.installDependencies(dir) - await ready - const second = Config.installDependencies(dir, { - waitTick: (tick) => { - ticks.push(tick.attempt) - blocked() - blocked = () => {} - }, + Deferred.doneUnsafe(ready, Effect.void) + await Effect.runPromise(Deferred.await(hold)) + const mod = path.join(d, "node_modules", "@opencode-ai", "plugin") + await fs.mkdir(mod, { recursive: true }) + await Filesystem.write( + path.join(mod, "package.json"), + JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }), + ) }) - await waiting - done() - await Promise.all([first, second]) - } finally { - online.mockRestore() - run.mockRestore() - } - expect(calls).toBe(2) - expect(ticks.length).toBeGreaterThan(0) - expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true) -}) - -test("serializes config dependency installs across dirs", async () => { - if (process.platform !== "win32") return - - await using tmp = await tmpdir() - const a = path.join(tmp.path, "a") - const b = path.join(tmp.path, "b") - await fs.mkdir(a, { recursive: true }) - await fs.mkdir(b, { recursive: true }) - - let calls = 0 - let open = 0 - let peak = 0 - let start = () => {} - let done = () => {} - const ready = new Promise((resolve) => { - start = resolve - }) - const gate = new Promise((resolve) => { - done = resolve - }) - - const online = spyOn(Network, "online").mockReturnValue(false) - const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => { - const cwd = path.normalize(dir) - const hit = cwd === path.normalize(a) || cwd === path.normalize(b) - if (hit) { - calls += 1 - open += 1 - peak = Math.max(peak, open) - if (calls === 1) { - start() - await gate - } - } - const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin") - await fs.mkdir(mod, { recursive: true }) - await Filesystem.write( - path.join(mod, "package.json"), - JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }), + yield* Effect.addFinalizer(() => + Effect.sync(() => { + online.mockRestore() + run.mockRestore() + }), ) - if (hit) { - open -= 1 - } - }) - try { - const first = Config.installDependencies(a) - await ready - const second = Config.installDependencies(b) - done() - await Promise.all([first, second]) - } finally { - online.mockRestore() - run.mockRestore() - } + const first = yield* installDeps(dir).pipe(Effect.forkScoped) + yield* Deferred.await(ready) - expect(calls).toBe(2) - expect(peak).toBe(1) -}) + let done = false + const second = yield* installDeps(dir, { + waitTick: () => { + Deferred.doneUnsafe(blocked, Effect.void) + }, + }).pipe( + Effect.tap(() => + Effect.sync(() => { + done = true + }), + ), + Effect.forkScoped, + ) + + yield* Deferred.await(blocked) + expect(done).toBe(false) + + yield* Deferred.succeed(hold, void 0) + yield* Fiber.join(first) + yield* Fiber.join(second) + + expect(calls).toBe(1) + expect(yield* Effect.promise(() => Filesystem.exists(path.join(dir, "package.json")))).toBe(true) + }), +) + +it.live("serializes config dependency installs across dirs", () => + Effect.gen(function* () { + if (process.platform !== "win32") return + + const tmp = yield* tmpdirScoped() + const a = path.join(tmp, "a") + const b = path.join(tmp, "b") + yield* Effect.promise(() => fs.mkdir(a, { recursive: true })) + yield* Effect.promise(() => fs.mkdir(b, { recursive: true })) + + let calls = 0 + let open = 0 + let peak = 0 + const ready = Deferred.makeUnsafe() + const blocked = Deferred.makeUnsafe() + const hold = Deferred.makeUnsafe() + + const online = spyOn(Network, "online").mockReturnValue(false) + const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => { + const cwd = path.normalize(dir) + const hit = cwd === path.normalize(a) || cwd === path.normalize(b) + if (hit) { + calls += 1 + open += 1 + peak = Math.max(peak, open) + if (calls === 1) { + Deferred.doneUnsafe(ready, Effect.void) + await Effect.runPromise(Deferred.await(hold)) + } + } + const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin") + await fs.mkdir(mod, { recursive: true }) + await Filesystem.write( + path.join(mod, "package.json"), + JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }), + ) + if (hit) { + open -= 1 + } + }) + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + online.mockRestore() + run.mockRestore() + }), + ) + + const first = yield* installDeps(a).pipe(Effect.forkScoped) + yield* Deferred.await(ready) + + const second = yield* installDeps(b, { + waitTick: () => { + Deferred.doneUnsafe(blocked, Effect.void) + }, + }).pipe(Effect.forkScoped) + yield* Deferred.await(blocked) + expect(peak).toBe(1) + + yield* Deferred.succeed(hold, void 0) + yield* Fiber.join(first) + yield* Fiber.join(second) + + expect(calls).toBe(2) + expect(peak).toBe(1) + }), +) test("resolves scoped npm plugins in config", async () => { await using tmp = await tmpdir({ diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 3a001e2756..1b750d1b93 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1842,6 +1842,11 @@ describe("ProviderTransform.message - cache control on gateway", () => { type: "ephemeral", }, }, + alibaba: { + cacheControl: { + type: "ephemeral", + }, + }, }) }) @@ -1894,6 +1899,11 @@ describe("ProviderTransform.message - cache control on gateway", () => { type: "ephemeral", }, }, + alibaba: { + cacheControl: { + type: "ephemeral", + }, + }, }) }) }) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index e3f28df35d..9fe24b49b2 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -65,6 +65,18 @@ const readFileTime = (sessionID: SessionID, filepath: string) => const subscribeBus = (def: D, callback: () => unknown) => runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback))) +async function onceBus(def: D) { + const result = Promise.withResolvers() + const unsub = await subscribeBus(def, () => { + unsub() + result.resolve() + }) + return { + wait: result.promise, + unsub, + } +} + describe("tool.edit", () => { describe("creating new files", () => { test("creates new file when oldString is empty", async () => { @@ -128,23 +140,25 @@ describe("tool.edit", () => { fn: async () => { const { FileWatcher } = await import("../../src/file/watcher") - const events: string[] = [] - const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated")) + const updated = await onceBus(FileWatcher.Event.Updated) - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "content", - }, - ctx, - ), - ) + try { + const edit = await resolve() + await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "", + newString: "content", + }, + ctx, + ), + ) - expect(events).toContain("updated") - unsubUpdated() + await updated.wait + } finally { + updated.unsub() + } }, }) }) @@ -359,23 +373,25 @@ describe("tool.edit", () => { const { FileWatcher } = await import("../../src/file/watcher") - const events: string[] = [] - const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated")) + const updated = await onceBus(FileWatcher.Event.Updated) - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "original", - newString: "modified", - }, - ctx, - ), - ) + try { + const edit = await resolve() + await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "original", + newString: "modified", + }, + ctx, + ), + ) - expect(events).toContain("updated") - unsubUpdated() + await updated.wait + } finally { + updated.unsub() + } }, }) })