summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-13 09:17:13 -0400
committerGitHub <[email protected]>2026-04-13 09:17:13 -0400
commit321bf1f8e1477dcca1b35173279d145e5fa5bf5a (patch)
tree4a57ea6759e977c4564ec3c655db6d2b7c235df2
parent62bd0230864910e3f32a3de54ff628a41e1b9ff9 (diff)
downloadopencode-321bf1f8e1477dcca1b35173279d145e5fa5bf5a.tar.gz
opencode-321bf1f8e1477dcca1b35173279d145e5fa5bf5a.zip
refactor: finish small effect service adoption cleanups (#22094)
-rw-r--r--packages/opencode/specs/effect-migration.md79
-rw-r--r--packages/opencode/src/config/config.ts152
-rw-r--r--packages/opencode/src/format/formatter.ts36
-rw-r--r--packages/opencode/src/provider/provider.ts1122
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader.test.ts5
-rw-r--r--packages/opencode/test/config/config.test.ts241
-rw-r--r--packages/opencode/test/provider/transform.test.ts10
-rw-r--r--packages/opencode/test/tool/edit.test.ts76
8 files changed, 906 insertions, 815 deletions
diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md
index 31fcac19b..26b4bc624 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`
+- [x] `SessionTodo` — `session/todo.ts`
-Still open:
+Still open at the service-shape level:
-- [x] `SessionTodo` — `session/todo.ts`
-- [ ] `SyncEvent` — `sync/index.ts`
-- [ ] `Workspace` — `control-plane/workspace.ts`
+- [ ] `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 ecce8fb8f..ab3abaf94 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<void>
}
- 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<string, string> }>(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<string, string>
}
function rel(item: string, patterns: string[]) {
@@ -1111,7 +1067,7 @@ export namespace Config {
type State = {
config: Info
directories: string[]
- deps: Promise<void>[]
+ deps: Fiber.Fiber<void, never>[]
consoleState: ConsoleState
}
@@ -1119,6 +1075,7 @@ export namespace Config {
readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState>
+ readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, AppFileSystem.Error>
readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@@ -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<void>[] = []
+ const deps: Fiber.Fiber<void, never>[] = []
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 a4cecd4f7..ada661eba 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 ef822739d..a26b254d5 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<Service, never, Config.Service | Auth.Service | Plugin.Service> = Layer.effect(
- Service,
- Effect.gen(function* () {
- const config = yield* Config.Service
- const auth = yield* Auth.Service
- const plugin = yield* Plugin.Service
-
- const state = yield* InstanceState.make<State>(() =>
- 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<ProviderID, Info> = {} as Record<ProviderID, Info>
- const languages = new Map<string, LanguageModelV3>()
- const modelLoaders: {
- [providerID: string]: CustomModelLoader
- } = {}
- const varsLoaders: {
- [providerID: string]: CustomVarsLoader
- } = {}
- const sdk = new Map<string, BundledSDK>()
- const discoveryLoaders: {
- [providerID: string]: CustomDiscoverModels
- } = {}
- const dep = {
- auth: (id: string) => auth.get(id).pipe(Effect.orDie),
- config: () => config.get(),
- }
+ const layer: Layer.Layer<Service, never, Config.Service | Auth.Service | Plugin.Service | AppFileSystem.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 state = yield* InstanceState.make<State>(() =>
+ 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<ProviderID, Info> = {} as Record<ProviderID, Info>
+ const languages = new Map<string, LanguageModelV3>()
+ const modelLoaders: {
+ [providerID: string]: CustomModelLoader
+ } = {}
+ const varsLoaders: {
+ [providerID: string]: CustomVarsLoader
+ } = {}
+ const sdk = new Map<string, BundledSDK>()
+ 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<Info>) {
- const existing = providers[providerID]
- if (existing) {
+ function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
+ 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(existing, provider)
- return
+ providers[providerID] = mergeDeep(match, provider)
}
- 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()
+ // 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
+ // 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 ?? {},
+ function isProviderAllowed(providerID: ProviderID): boolean {
+ if (enabled && !enabled.has(providerID)) return false
+ if (disabled.has(providerID)) return false
+ return true
}
- 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,
+ // 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,
},
- 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,
+ 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,
+ },
},
- 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,
},
- },
- 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: {},
+ 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
}
- 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
}
- 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 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") {
+ // 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<Info> = 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<Info> = 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<Info> = { 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<Info> = 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
+ // load config - re-apply with updated data
+ for (const [id, provider] of configProviders) {
+ const providerID = ProviderID.make(id)
+ const partial: Partial<Info> = { 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 providerID = ProviderID.make(p.id)
- if (disabled.has(providerID)) continue
+ 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 })
+ }
+ })
+ }
- const provider = providers[providerID]
- if (!provider) continue
- const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
+ 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,
+ },
+ ]),
+ )
+ })
+ }
- 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
+ }
- 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]
- 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]
- 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)
- 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 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"]),
- )
+ if (Object.keys(provider.models).length === 0) {
+ delete providers[providerID]
+ continue
}
- }
- if (Object.keys(provider.models).length === 0) {
- delete providers[providerID]
- continue
+ log.info("found", { providerID })
}
- log.info("found", { providerID })
- }
-
- return {
- models: languages,
- providers,
- sdk,
- modelLoaders,
- varsLoaders,
- }
- }),
- )
+ return {
+ models: languages,
+ providers,
+ sdk,
+ modelLoaders,
+ varsLoaders,
+ }
+ }),
+ )
- const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
+ 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 }
+ 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.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
- }
+ 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)
+ 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
+ url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
+ const val = Env.get(String(key))
+ return val ?? item
+ })
+ return url
})
- 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,
- }
+ 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 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)
}
- 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 res = await fetchFn(input, {
- ...opts,
- // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
- timeout: false,
- })
+ 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
+ }
- if (!chunkAbortCtl) return res
- return wrapSSE(res, chunkTimeout, chunkAbortCtl)
- }
+ 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 bundledFn = BUNDLED_PROVIDERS[model.api.npm]
- if (bundledFn) {
- log.info("using bundled provider", {
- providerID: model.providerID,
- pkg: model.api.npm,
- })
- const loaded = bundledFn({
+ 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 })
}
+ }
- 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 getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
+ InstanceState.use(state, (s) => s.providers[providerID]),
+ )
- const mod = await import(installedPath)
+ 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 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 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 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 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 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)
- 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
+ }
+ })
+ })
- 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 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()
+ 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
-
- 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 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 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)
+ return undefined
+ })
- 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 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,
- }
- })
+ 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 e9e62d2a7..119517b10 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 0ac61aee7..d6931975c 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 })
-
- const ticks: number[] = []
- let calls = 0
- let start = () => {}
- let done = () => {}
- let blocked = () => {}
- const ready = new Promise<void>((resolve) => {
- start = resolve
- })
- const gate = new Promise<void>((resolve) => {
- done = resolve
- })
- const waiting = new Promise<void>((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) {
+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 }))
+
+ let calls = 0
+ const online = spyOn(Network, "online").mockReturnValue(false)
+ const ready = Deferred.makeUnsafe<void>()
+ const blocked = Deferred.makeUnsafe<void>()
+ const hold = Deferred.makeUnsafe<void>()
+ 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" }),
+ 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" }),
+ )
+ })
+
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ online.mockRestore()
+ run.mockRestore()
+ }),
)
- 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 = () => {}
+ const first = yield* installDeps(dir).pipe(Effect.forkScoped)
+ yield* Deferred.await(ready)
+
+ let done = false
+ const second = yield* installDeps(dir, {
+ waitTick: () => {
+ Deferred.doneUnsafe(blocked, Effect.void)
},
- })
- await waiting
- done()
- await Promise.all([first, second])
- } finally {
- online.mockRestore()
- run.mockRestore()
- }
+ }).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ done = true
+ }),
+ ),
+ Effect.forkScoped,
+ )
- expect(calls).toBe(2)
- expect(ticks.length).toBeGreaterThan(0)
- expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
-})
+ yield* Deferred.await(blocked)
+ expect(done).toBe(false)
-test("serializes config dependency installs across dirs", async () => {
- if (process.platform !== "win32") return
+ yield* Deferred.succeed(hold, void 0)
+ yield* Fiber.join(first)
+ yield* Fiber.join(second)
- 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 })
+ expect(calls).toBe(1)
+ expect(yield* Effect.promise(() => Filesystem.exists(path.join(dir, "package.json")))).toBe(true)
+ }),
+)
- let calls = 0
- let open = 0
- let peak = 0
- let start = () => {}
- let done = () => {}
- const ready = new Promise<void>((resolve) => {
- start = resolve
- })
- const gate = new Promise<void>((resolve) => {
- done = resolve
- })
+it.live("serializes config dependency installs across dirs", () =>
+ Effect.gen(function* () {
+ if (process.platform !== "win32") return
- 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 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<void>()
+ const blocked = Deferred.makeUnsafe<void>()
+ const hold = Deferred.makeUnsafe<void>()
+
+ 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" }),
+ 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()
+ }),
)
- 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(a).pipe(Effect.forkScoped)
+ yield* Deferred.await(ready)
- expect(calls).toBe(2)
- expect(peak).toBe(1)
-})
+ 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 3a001e275..1b750d1b9 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 e3f28df35..9fe24b49b 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 = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
+async function onceBus<D extends BusEvent.Definition>(def: D) {
+ const result = Promise.withResolvers<void>()
+ 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()
+ }
},
})
})