summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-13 22:28:16 -0400
committerGitHub <[email protected]>2026-04-13 22:28:16 -0400
commit6a99079012f8be6c9bde5a042e4c72f2cd5cd0cc (patch)
tree51ee26ce98026868f6b5d60c0d58654fdd382bc6
parent0a8b6298cd830cd55862814ba333a02c41dd6254 (diff)
downloadopencode-6a99079012f8be6c9bde5a042e4c72f2cd5cd0cc.tar.gz
opencode-6a99079012f8be6c9bde5a042e4c72f2cd5cd0cc.zip
kit/env instance state (#22383)
-rw-r--r--packages/opencode/src/config/config.ts858
-rw-r--r--packages/opencode/src/env/index.ts54
-rw-r--r--packages/opencode/src/provider/provider.ts1240
-rw-r--r--packages/opencode/src/tool/registry.ts6
-rw-r--r--packages/opencode/test/config/config.test.ts5
-rw-r--r--packages/opencode/test/session/prompt-effect.test.ts2
-rw-r--r--packages/opencode/test/session/snapshot-tool-race.test.ts2
7 files changed, 1101 insertions, 1066 deletions
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 6aa79e309..f9ca88341 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -1161,502 +1161,500 @@ export namespace Config {
}),
)
- export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> =
- Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const authSvc = yield* Auth.Service
- const accountSvc = yield* Account.Service
-
- const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
- return yield* fs.readFileString(filepath).pipe(
- Effect.catchIf(
- (e) => e.reason._tag === "NotFound",
- () => Effect.succeed(undefined),
- ),
- Effect.orDie,
- )
- })
+ export const layer: Layer.Layer<
+ Service,
+ never,
+ AppFileSystem.Service | Auth.Service | Account.Service | Env.Service
+ > = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const authSvc = yield* Auth.Service
+ const accountSvc = yield* Account.Service
+ const env = yield* Env.Service
+
+ const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
+ return yield* fs.readFileString(filepath).pipe(
+ Effect.catchIf(
+ (e) => e.reason._tag === "NotFound",
+ () => Effect.succeed(undefined),
+ ),
+ Effect.orDie,
+ )
+ })
- const loadConfig = Effect.fnUntraced(function* (
- text: string,
- options: { path: string } | { dir: string; source: string },
- ) {
- const original = text
- const source = "path" in options ? options.path : options.source
- const isFile = "path" in options
- const data = yield* Effect.promise(() =>
- ConfigPaths.parseText(
- text,
- "path" in options ? options.path : { source: options.source, dir: options.dir },
- ),
- )
+ const loadConfig = Effect.fnUntraced(function* (
+ text: string,
+ options: { path: string } | { dir: string; source: string },
+ ) {
+ const original = text
+ const source = "path" in options ? options.path : options.source
+ const isFile = "path" in options
+ const data = yield* Effect.promise(() =>
+ ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
+ )
- const normalized = (() => {
- if (!data || typeof data !== "object" || Array.isArray(data)) return data
- const copy = { ...(data as Record<string, unknown>) }
- const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
- if (!hadLegacy) return copy
- delete copy.theme
- delete copy.keybinds
- delete copy.tui
- log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
- return copy
- })()
-
- const parsed = Info.safeParse(normalized)
- if (parsed.success) {
- if (!parsed.data.$schema && isFile) {
- parsed.data.$schema = "https://opencode.ai/config.json"
- const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
- yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
- }
- const data = parsed.data
- if (data.plugin && isFile) {
- const list = data.plugin
- for (let i = 0; i < list.length; i++) {
- list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
- }
+ const normalized = (() => {
+ if (!data || typeof data !== "object" || Array.isArray(data)) return data
+ const copy = { ...(data as Record<string, unknown>) }
+ const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
+ if (!hadLegacy) return copy
+ delete copy.theme
+ delete copy.keybinds
+ delete copy.tui
+ log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
+ return copy
+ })()
+
+ const parsed = Info.safeParse(normalized)
+ if (parsed.success) {
+ if (!parsed.data.$schema && isFile) {
+ parsed.data.$schema = "https://opencode.ai/config.json"
+ const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
+ yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
+ }
+ const data = parsed.data
+ if (data.plugin && isFile) {
+ const list = data.plugin
+ for (let i = 0; i < list.length; i++) {
+ list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
}
- return data
}
+ return data
+ }
- throw new InvalidError({
- path: source,
- issues: parsed.error.issues,
- })
- })
-
- const loadFile = Effect.fnUntraced(function* (filepath: string) {
- log.info("loading", { path: filepath })
- const text = yield* readConfigFile(filepath)
- if (!text) return {} as Info
- return yield* loadConfig(text, { path: filepath })
+ throw new InvalidError({
+ path: source,
+ issues: parsed.error.issues,
})
+ })
- const loadGlobal = Effect.fnUntraced(function* () {
- let result: Info = pipe(
- {},
- mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
- mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
- mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
- )
-
- const legacy = path.join(Global.Path.config, "config")
- if (existsSync(legacy)) {
- yield* Effect.promise(() =>
- import(pathToFileURL(legacy).href, { with: { type: "toml" } })
- .then(async (mod) => {
- const { provider, model, ...rest } = mod.default
- if (provider && model) result.model = `${provider}/${model}`
- result["$schema"] = "https://opencode.ai/config.json"
- result = mergeDeep(result, rest)
- await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
- await fsNode.unlink(legacy)
- })
- .catch(() => {}),
- )
- }
-
- return result
- })
+ const loadFile = Effect.fnUntraced(function* (filepath: string) {
+ log.info("loading", { path: filepath })
+ const text = yield* readConfigFile(filepath)
+ if (!text) return {} as Info
+ return yield* loadConfig(text, { path: filepath })
+ })
- const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
- loadGlobal().pipe(
- Effect.tapError((error) =>
- Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
- ),
- Effect.orElseSucceed((): Info => ({})),
- ),
- Duration.infinity,
+ const loadGlobal = Effect.fnUntraced(function* () {
+ let result: Info = pipe(
+ {},
+ mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
+ mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
+ mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)
- const getGlobal = Effect.fn("Config.getGlobal")(function* () {
- return yield* cachedGlobal
- })
-
- const install = Effect.fnUntraced(function* (dir: string) {
- const pkg = path.join(dir, "package.json")
- const gitignore = path.join(dir, ".gitignore")
- const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
- const target = Installation.isLocal() ? "*" : Installation.VERSION
- const json = yield* fs.readJson(pkg).pipe(
- Effect.catch(() => Effect.succeed({} satisfies Package)),
- Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
+ const legacy = path.join(Global.Path.config, "config")
+ if (existsSync(legacy)) {
+ yield* Effect.promise(() =>
+ import(pathToFileURL(legacy).href, { with: { type: "toml" } })
+ .then(async (mod) => {
+ const { provider, model, ...rest } = mod.default
+ if (provider && model) result.model = `${provider}/${model}`
+ result["$schema"] = "https://opencode.ai/config.json"
+ result = mergeDeep(result, rest)
+ await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
+ await fsNode.unlink(legacy)
+ })
+ .catch(() => {}),
)
- 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"),
- )
- }
+ return result
+ })
- if (hasDep && hasIgnore && hasPkg) return
+ const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
+ loadGlobal().pipe(
+ Effect.tapError((error) =>
+ Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
+ ),
+ Effect.orElseSucceed((): Info => ({})),
+ ),
+ Duration.infinity,
+ )
- yield* Effect.promise(() => Npm.install(dir))
- })
+ const getGlobal = Effect.fn("Config.getGlobal")(function* () {
+ return yield* cachedGlobal
+ })
- 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 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"),
)
- })
+ }
- const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
- const auth = yield* authSvc.all().pipe(Effect.orDie)
+ if (hasDep && hasIgnore && hasPkg) return
- let result: Info = {}
- const consoleManagedProviders = new Set<string>()
- let activeOrgName: string | undefined
+ yield* Effect.promise(() => Npm.install(dir))
+ })
- const scope = Effect.fnUntraced(function* (source: string) {
- if (source.startsWith("http://") || source.startsWith("https://")) return "global"
- if (source === "OPENCODE_CONFIG_CONTENT") return "local"
- if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
- return "global"
- })
+ const 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 track = Effect.fnUntraced(function* (
- source: string,
- list: PluginSpec[] | undefined,
- kind?: PluginScope,
- ) {
- if (!list?.length) return
- const hit = kind ?? (yield* scope(source))
- const plugins = deduplicatePluginOrigins([
- ...(result.plugin_origins ?? []),
- ...list.map((spec) => ({ spec, source, scope: hit })),
- ])
- result.plugin = plugins.map((item) => item.spec)
- result.plugin_origins = plugins
- })
+ const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
+ const auth = yield* authSvc.all().pipe(Effect.orDie)
- const merge = (source: string, next: Info, kind?: PluginScope) => {
- result = mergeConfigConcatArrays(result, next)
- return track(source, next.plugin, kind)
- }
+ let result: Info = {}
+ const consoleManagedProviders = new Set<string>()
+ let activeOrgName: string | undefined
- for (const [key, value] of Object.entries(auth)) {
- if (value.type === "wellknown") {
- const url = key.replace(/\/+$/, "")
- process.env[value.key] = value.token
- log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
- const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
- if (!response.ok) {
- throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
- }
- const wellknown = (yield* Effect.promise(() => response.json())) as any
- const remoteConfig = wellknown.config ?? {}
- if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
- const source = `${url}/.well-known/opencode`
- const next = yield* loadConfig(JSON.stringify(remoteConfig), {
- dir: path.dirname(source),
- source,
- })
- yield* merge(source, next, "global")
- log.debug("loaded remote config from well-known", { url })
- }
- }
+ const scope = Effect.fnUntraced(function* (source: string) {
+ if (source.startsWith("http://") || source.startsWith("https://")) return "global"
+ if (source === "OPENCODE_CONFIG_CONTENT") return "local"
+ if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
+ return "global"
+ })
- const global = yield* getGlobal()
- yield* merge(Global.Path.config, global, "global")
+ const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) {
+ if (!list?.length) return
+ const hit = kind ?? (yield* scope(source))
+ const plugins = deduplicatePluginOrigins([
+ ...(result.plugin_origins ?? []),
+ ...list.map((spec) => ({ spec, source, scope: hit })),
+ ])
+ result.plugin = plugins.map((item) => item.spec)
+ result.plugin_origins = plugins
+ })
- if (Flag.OPENCODE_CONFIG) {
- yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
- log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
- }
+ const merge = (source: string, next: Info, kind?: PluginScope) => {
+ result = mergeConfigConcatArrays(result, next)
+ return track(source, next.plugin, kind)
+ }
- if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
- for (const file of yield* Effect.promise(() =>
- ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
- )) {
- yield* merge(file, yield* loadFile(file), "local")
+ for (const [key, value] of Object.entries(auth)) {
+ if (value.type === "wellknown") {
+ const url = key.replace(/\/+$/, "")
+ process.env[value.key] = value.token
+ log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
+ const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
+ if (!response.ok) {
+ throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
+ const wellknown = (yield* Effect.promise(() => response.json())) as any
+ const remoteConfig = wellknown.config ?? {}
+ if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
+ const source = `${url}/.well-known/opencode`
+ const next = yield* loadConfig(JSON.stringify(remoteConfig), {
+ dir: path.dirname(source),
+ source,
+ })
+ yield* merge(source, next, "global")
+ log.debug("loaded remote config from well-known", { url })
}
+ }
- result.agent = result.agent || {}
- result.mode = result.mode || {}
- result.plugin = result.plugin || []
+ const global = yield* getGlobal()
+ yield* merge(Global.Path.config, global, "global")
- const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
+ if (Flag.OPENCODE_CONFIG) {
+ yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
+ log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
+ }
- if (Flag.OPENCODE_CONFIG_DIR) {
- log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
+ if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+ for (const file of yield* Effect.promise(() =>
+ ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
+ )) {
+ yield* merge(file, yield* loadFile(file), "local")
}
+ }
- const deps: Fiber.Fiber<void, never>[] = []
-
- for (const dir of unique(directories)) {
- if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
- for (const file of ["opencode.json", "opencode.jsonc"]) {
- const source = path.join(dir, file)
- log.debug(`loading config from ${source}`)
- yield* merge(source, yield* loadFile(source))
- result.agent ??= {}
- result.mode ??= {}
- result.plugin ??= []
- }
- }
+ result.agent = result.agent || {}
+ result.mode = result.mode || {}
+ result.plugin = result.plugin || []
- const dep = yield* installDependencies(dir).pipe(
- Effect.exit,
- Effect.tap((exit) =>
- Exit.isFailure(exit)
- ? Effect.sync(() => {
- log.warn("background dependency install failed", { dir, error: String(exit.cause) })
- })
- : Effect.void,
- ),
- Effect.asVoid,
- Effect.forkScoped,
- )
- deps.push(dep)
+ const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
- result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
- result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
- result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
- const list = yield* Effect.promise(() => loadPlugin(dir))
- yield* track(dir, list)
- }
+ if (Flag.OPENCODE_CONFIG_DIR) {
+ log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
+ }
- if (process.env.OPENCODE_CONFIG_CONTENT) {
- const source = "OPENCODE_CONFIG_CONTENT"
- const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
- dir: ctx.directory,
- source,
- })
- yield* merge(source, next, "local")
- log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
+ const deps: Fiber.Fiber<void, never>[] = []
+
+ for (const dir of unique(directories)) {
+ if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
+ for (const file of ["opencode.json", "opencode.jsonc"]) {
+ const source = path.join(dir, file)
+ log.debug(`loading config from ${source}`)
+ yield* merge(source, yield* loadFile(source))
+ result.agent ??= {}
+ result.mode ??= {}
+ result.plugin ??= []
+ }
}
- const activeOrg = Option.getOrUndefined(
- yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
+ 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,
)
- if (activeOrg) {
- yield* Effect.gen(function* () {
- const [configOpt, tokenOpt] = yield* Effect.all(
- [accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
- { concurrency: 2 },
- )
- if (Option.isSome(tokenOpt)) {
- process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
- Env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
- }
+ deps.push(dep)
- activeOrgName = activeOrg.org.name
-
- if (Option.isSome(configOpt)) {
- const source = `${activeOrg.account.url}/api/config`
- const next = yield* loadConfig(JSON.stringify(configOpt.value), {
- dir: path.dirname(source),
- source,
- })
- for (const providerID of Object.keys(next.provider ?? {})) {
- consoleManagedProviders.add(providerID)
- }
- yield* merge(source, next, "global")
- }
- }).pipe(
- Effect.catch((err) => {
- log.debug("failed to fetch remote account config", {
- error: err instanceof Error ? err.message : String(err),
- })
- return Effect.void
- }),
+ result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
+ result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
+ result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
+ const list = yield* Effect.promise(() => loadPlugin(dir))
+ yield* track(dir, list)
+ }
+
+ if (process.env.OPENCODE_CONFIG_CONTENT) {
+ const source = "OPENCODE_CONFIG_CONTENT"
+ const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
+ dir: ctx.directory,
+ source,
+ })
+ yield* merge(source, next, "local")
+ log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
+ }
+
+ const activeOrg = Option.getOrUndefined(
+ yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
+ )
+ if (activeOrg) {
+ yield* Effect.gen(function* () {
+ const [configOpt, tokenOpt] = yield* Effect.all(
+ [accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
+ { concurrency: 2 },
)
- }
+ if (Option.isSome(tokenOpt)) {
+ process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
+ yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
+ }
- if (existsSync(managedDir)) {
- for (const file of ["opencode.json", "opencode.jsonc"]) {
- const source = path.join(managedDir, file)
- yield* merge(source, yield* loadFile(source), "global")
+ activeOrgName = activeOrg.org.name
+
+ if (Option.isSome(configOpt)) {
+ const source = `${activeOrg.account.url}/api/config`
+ const next = yield* loadConfig(JSON.stringify(configOpt.value), {
+ dir: path.dirname(source),
+ source,
+ })
+ for (const providerID of Object.keys(next.provider ?? {})) {
+ consoleManagedProviders.add(providerID)
+ }
+ yield* merge(source, next, "global")
}
+ }).pipe(
+ Effect.catch((err) => {
+ log.debug("failed to fetch remote account config", {
+ error: err instanceof Error ? err.message : String(err),
+ })
+ return Effect.void
+ }),
+ )
+ }
+
+ if (existsSync(managedDir)) {
+ for (const file of ["opencode.json", "opencode.jsonc"]) {
+ const source = path.join(managedDir, file)
+ yield* merge(source, yield* loadFile(source), "global")
}
+ }
- // macOS managed preferences (.mobileconfig deployed via MDM) override everything
- result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
+ // macOS managed preferences (.mobileconfig deployed via MDM) override everything
+ result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
- for (const [name, mode] of Object.entries(result.mode ?? {})) {
- result.agent = mergeDeep(result.agent ?? {}, {
- [name]: {
- ...mode,
- mode: "primary" as const,
- },
- })
- }
+ for (const [name, mode] of Object.entries(result.mode ?? {})) {
+ result.agent = mergeDeep(result.agent ?? {}, {
+ [name]: {
+ ...mode,
+ mode: "primary" as const,
+ },
+ })
+ }
- if (Flag.OPENCODE_PERMISSION) {
- result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
- }
+ if (Flag.OPENCODE_PERMISSION) {
+ result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
+ }
- if (result.tools) {
- const perms: Record<string, Config.PermissionAction> = {}
- for (const [tool, enabled] of Object.entries(result.tools)) {
- const action: Config.PermissionAction = enabled ? "allow" : "deny"
- if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
- perms.edit = action
- continue
- }
- perms[tool] = action
+ if (result.tools) {
+ const perms: Record<string, Config.PermissionAction> = {}
+ for (const [tool, enabled] of Object.entries(result.tools)) {
+ const action: Config.PermissionAction = enabled ? "allow" : "deny"
+ if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+ perms.edit = action
+ continue
}
- result.permission = mergeDeep(perms, result.permission ?? {})
+ perms[tool] = action
}
+ result.permission = mergeDeep(perms, result.permission ?? {})
+ }
- if (!result.username) result.username = os.userInfo().username
+ if (!result.username) result.username = os.userInfo().username
- if (result.autoshare === true && !result.share) {
- result.share = "auto"
- }
+ if (result.autoshare === true && !result.share) {
+ result.share = "auto"
+ }
- if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
- result.compaction = { ...result.compaction, auto: false }
- }
- if (Flag.OPENCODE_DISABLE_PRUNE) {
- result.compaction = { ...result.compaction, prune: false }
- }
+ if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
+ result.compaction = { ...result.compaction, auto: false }
+ }
+ if (Flag.OPENCODE_DISABLE_PRUNE) {
+ result.compaction = { ...result.compaction, prune: false }
+ }
- return {
- config: result,
- directories,
- deps,
- consoleState: {
- consoleManagedProviders: Array.from(consoleManagedProviders),
- activeOrgName,
- switchableOrgCount: 0,
- },
- }
- })
+ return {
+ config: result,
+ directories,
+ deps,
+ consoleState: {
+ consoleManagedProviders: Array.from(consoleManagedProviders),
+ activeOrgName,
+ switchableOrgCount: 0,
+ },
+ }
+ })
- const state = yield* InstanceState.make<State>(
- Effect.fn("Config.state")(function* (ctx) {
- return yield* loadInstanceState(ctx)
- }),
- )
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("Config.state")(function* (ctx) {
+ return yield* loadInstanceState(ctx)
+ }),
+ )
- const get = Effect.fn("Config.get")(function* () {
- return yield* InstanceState.use(state, (s) => s.config)
- })
+ const get = Effect.fn("Config.get")(function* () {
+ return yield* InstanceState.use(state, (s) => s.config)
+ })
- const directories = Effect.fn("Config.directories")(function* () {
- return yield* InstanceState.use(state, (s) => s.directories)
- })
+ const directories = Effect.fn("Config.directories")(function* () {
+ return yield* InstanceState.use(state, (s) => s.directories)
+ })
- const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
- return yield* InstanceState.use(state, (s) => s.consoleState)
- })
+ const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
+ return yield* InstanceState.use(state, (s) => s.consoleState)
+ })
- const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
- yield* InstanceState.useEffect(state, (s) =>
- Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
- )
- })
+ const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
+ yield* InstanceState.useEffect(state, (s) =>
+ Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
+ )
+ })
- const update = Effect.fn("Config.update")(function* (config: Info) {
- const dir = yield* InstanceState.directory
- const file = path.join(dir, "config.json")
- const existing = yield* loadFile(file)
- yield* fs
- .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
- .pipe(Effect.orDie)
- yield* Effect.promise(() => Instance.dispose())
- })
+ const update = Effect.fn("Config.update")(function* (config: Info) {
+ const dir = yield* InstanceState.directory
+ const file = path.join(dir, "config.json")
+ const existing = yield* loadFile(file)
+ yield* fs
+ .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
+ .pipe(Effect.orDie)
+ yield* Effect.promise(() => Instance.dispose())
+ })
- const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
- yield* invalidateGlobal
- const task = Instance.disposeAll()
- .catch(() => undefined)
- .finally(() =>
- GlobalBus.emit("event", {
- directory: "global",
- payload: {
- type: Event.Disposed.type,
- properties: {},
- },
- }),
- )
- if (wait) yield* Effect.promise(() => task)
- else void task
- })
+ const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
+ yield* invalidateGlobal
+ const task = Instance.disposeAll()
+ .catch(() => undefined)
+ .finally(() =>
+ GlobalBus.emit("event", {
+ directory: "global",
+ payload: {
+ type: Event.Disposed.type,
+ properties: {},
+ },
+ }),
+ )
+ if (wait) yield* Effect.promise(() => task)
+ else void task
+ })
- const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
- const file = globalConfigFile()
- const before = (yield* readConfigFile(file)) ?? "{}"
- const input = writable(config)
-
- let next: Info
- if (!file.endsWith(".jsonc")) {
- const existing = parseConfig(before, file)
- const merged = mergeDeep(writable(existing), input)
- yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
- next = merged
- } else {
- const updated = patchJsonc(before, input)
- next = parseConfig(updated, file)
- yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
- }
+ const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
+ const file = globalConfigFile()
+ const before = (yield* readConfigFile(file)) ?? "{}"
+ const input = writable(config)
+
+ let next: Info
+ if (!file.endsWith(".jsonc")) {
+ const existing = parseConfig(before, file)
+ const merged = mergeDeep(writable(existing), input)
+ yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
+ next = merged
+ } else {
+ const updated = patchJsonc(before, input)
+ next = parseConfig(updated, file)
+ yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
+ }
- yield* invalidate()
- return next
- })
+ yield* invalidate()
+ return next
+ })
- return Service.of({
- get,
- getGlobal,
- getConsoleState,
- installDependencies,
- update,
- updateGlobal,
- invalidate,
- directories,
- waitForDependencies,
- })
- }),
- )
+ return Service.of({
+ get,
+ getGlobal,
+ getConsoleState,
+ installDependencies,
+ update,
+ updateGlobal,
+ invalidate,
+ directories,
+ waitForDependencies,
+ })
+ }),
+ )
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(Env.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
)
diff --git a/packages/opencode/src/env/index.ts b/packages/opencode/src/env/index.ts
index 003b59fc7..930287899 100644
--- a/packages/opencode/src/env/index.ts
+++ b/packages/opencode/src/env/index.ts
@@ -1,28 +1,56 @@
-import { Instance } from "../project/instance"
+import { Context, Effect, Layer } from "effect"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRuntime } from "@/effect/run-service"
export namespace Env {
- const state = Instance.state(() => {
- // Create a shallow copy to isolate environment per instance
- // Prevents parallel tests from interfering with each other's env vars
- return { ...process.env } as Record<string, string | undefined>
- })
+ type State = Record<string, string | undefined>
+
+ export interface Interface {
+ readonly get: (key: string) => Effect.Effect<string | undefined>
+ readonly all: () => Effect.Effect<State>
+ readonly set: (key: string, value: string) => Effect.Effect<void>
+ readonly remove: (key: string) => Effect.Effect<void>
+ }
+
+ export class Service extends Context.Service<Service, Interface>()("@opencode/Env") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make<State>(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env })))
+
+ const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key]))
+ const all = Effect.fn("Env.all")(() => InstanceState.get(state))
+ const set = Effect.fn("Env.set")(function* (key: string, value: string) {
+ const env = yield* InstanceState.get(state)
+ env[key] = value
+ })
+ const remove = Effect.fn("Env.remove")(function* (key: string) {
+ const env = yield* InstanceState.get(state)
+ delete env[key]
+ })
+
+ return Service.of({ get, all, set, remove })
+ }),
+ )
+
+ export const defaultLayer = layer
+
+ const rt = makeRuntime(Service, defaultLayer)
export function get(key: string) {
- const env = state()
- return env[key]
+ return rt.runSync((svc) => svc.get(key))
}
export function all() {
- return state()
+ return rt.runSync((svc) => svc.all())
}
export function set(key: string, value: string) {
- const env = state()
- env[key] = value
+ return rt.runSync((svc) => svc.set(key, value))
}
export function remove(key: string) {
- const env = state()
- delete env[key]
+ return rt.runSync((svc) => svc.remove(key))
}
}
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index bf27f090a..31d88f1f9 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -116,12 +116,6 @@ export namespace Provider {
})
}
- function e2eURL() {
- const url = Env.get("OPENCODE_E2E_LLM_URL")
- if (typeof url !== "string" || url === "") return
- return url
- }
-
type BundledSDK = {
languageModel(modelId: string): LanguageModelV3
}
@@ -166,6 +160,8 @@ export namespace Provider {
type CustomDep = {
auth: (id: string) => Effect.Effect<Auth.Info | undefined>
config: () => Effect.Effect<Config.Info>
+ env: () => Effect.Effect<Record<string, string | undefined>>
+ get: (key: string) => Effect.Effect<string | undefined>
}
function useLanguageModel(sdk: any) {
@@ -184,7 +180,7 @@ export namespace Provider {
},
}),
opencode: Effect.fnUntraced(function* (input: Info) {
- const env = Env.all()
+ const env = yield* dep.env()
const hasKey = iife(() => {
if (input.env.some((item) => env[item])) return true
return false
@@ -231,14 +227,15 @@ export namespace Provider {
},
options: {},
}),
- azure: (provider) => {
+ azure: Effect.fnUntraced(function* (provider: Info) {
+ const env = yield* dep.env()
const resource = iife(() => {
const name = provider.options?.resourceName
if (typeof name === "string" && name.trim() !== "") return name
- return Env.get("AZURE_RESOURCE_NAME")
+ return env["AZURE_RESOURCE_NAME"]
})
- return Effect.succeed({
+ return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
@@ -254,11 +251,11 @@ export namespace Provider {
...(resource && { AZURE_RESOURCE_NAME: resource }),
}
},
- })
- },
- "azure-cognitive-services": () => {
- const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
- return Effect.succeed({
+ }
+ }),
+ "azure-cognitive-services": Effect.fnUntraced(function* () {
+ const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
+ return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
@@ -271,23 +268,24 @@ export namespace Provider {
options: {
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
},
- })
- },
+ }
+ }),
"amazon-bedrock": Effect.fnUntraced(function* () {
const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
const auth = yield* dep.auth("amazon-bedrock")
+ const env = yield* dep.env()
// Region precedence: 1) config file, 2) env var, 3) default
const configRegion = providerConfig?.options?.region
- const envRegion = Env.get("AWS_REGION")
+ const envRegion = env["AWS_REGION"]
const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
// Profile: config file takes precedence over env var
const configProfile = providerConfig?.options?.profile
- const envProfile = Env.get("AWS_PROFILE")
+ const envProfile = env["AWS_PROFILE"]
const profile = configProfile ?? envProfile
- const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
+ const awsAccessKeyId = env["AWS_ACCESS_KEY_ID"]
// TODO: Using process.env directly because Env.set only updates a process.env shallow copy,
// until the scope of the Env API is clarified (test only or runtime?)
@@ -301,7 +299,7 @@ export namespace Provider {
return undefined
})
- const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
+ const awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"]
const containerCreds = Boolean(
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
@@ -439,24 +437,22 @@ export namespace Provider {
},
},
}),
- "google-vertex": (provider) => {
+ "google-vertex": Effect.fnUntraced(function* (provider: Info) {
+ const env = yield* dep.env()
const project =
- provider.options?.project ??
- Env.get("GOOGLE_CLOUD_PROJECT") ??
- Env.get("GCP_PROJECT") ??
- Env.get("GCLOUD_PROJECT")
+ provider.options?.project ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
const location = String(
provider.options?.location ??
- Env.get("GOOGLE_VERTEX_LOCATION") ??
- Env.get("GOOGLE_CLOUD_LOCATION") ??
- Env.get("VERTEX_LOCATION") ??
+ env["GOOGLE_VERTEX_LOCATION"] ??
+ env["GOOGLE_CLOUD_LOCATION"] ??
+ env["VERTEX_LOCATION"] ??
"us-central1",
)
const autoload = Boolean(project)
- if (!autoload) return Effect.succeed({ autoload: false })
- return Effect.succeed({
+ if (!autoload) return { autoload: false }
+ return {
autoload: true,
vars(_options: Record<string, any>) {
const endpoint =
@@ -485,14 +481,15 @@ export namespace Provider {
const id = String(modelID).trim()
return sdk.languageModel(id)
},
- })
- },
- "google-vertex-anthropic": () => {
- const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
- const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "global"
+ }
+ }),
+ "google-vertex-anthropic": Effect.fnUntraced(function* () {
+ const env = yield* dep.env()
+ const project = env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
+ const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global"
const autoload = Boolean(project)
- if (!autoload) return Effect.succeed({ autoload: false })
- return Effect.succeed({
+ if (!autoload) return { autoload: false }
+ return {
autoload: true,
options: {
project,
@@ -502,8 +499,8 @@ export namespace Provider {
const id = String(modelID).trim()
return sdk.languageModel(id)
},
- })
- },
+ }
+ }),
"sap-ai-core": Effect.fnUntraced(function* () {
const auth = yield* dep.auth("sap-ai-core")
// TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env),
@@ -539,14 +536,15 @@ export namespace Provider {
},
}),
gitlab: Effect.fnUntraced(function* (input: Info) {
- const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
+ const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
const auth = yield* dep.auth(input.id)
const apiKey = yield* Effect.sync(() => {
if (auth?.type === "oauth") return auth.access
if (auth?.type === "api") return auth.key
- return Env.get("GITLAB_TOKEN")
+ return undefined
})
+ const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN"))
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
@@ -563,10 +561,10 @@ export namespace Provider {
}
return {
- autoload: !!apiKey,
+ autoload: !!token,
options: {
instanceUrl,
- apiKey,
+ apiKey: token,
aiGatewayHeaders,
featureFlags,
},
@@ -681,8 +679,8 @@ export namespace Provider {
if (input.options?.baseURL) return { autoload: false }
const auth = yield* dep.auth(input.id)
- const accountId =
- Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
+ const env = yield* dep.env()
+ const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
if (!accountId)
return {
autoload: false,
@@ -694,7 +692,7 @@ export namespace Provider {
}
const apiKey = yield* Effect.gen(function* () {
- const envToken = Env.get("CLOUDFLARE_API_KEY")
+ const envToken = env["CLOUDFLARE_API_KEY"]
if (envToken) return envToken
if (auth?.type === "api") return auth.key
return undefined
@@ -723,10 +721,9 @@ export namespace Provider {
if (input.options?.baseURL) return { autoload: false }
const auth = yield* dep.auth(input.id)
- const accountId =
- Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
- const gateway =
- Env.get("CLOUDFLARE_GATEWAY_ID") || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
+ const env = yield* dep.env()
+ const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
+ const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
if (!accountId || !gateway) {
const missing = [
@@ -745,7 +742,7 @@ export namespace Provider {
// Get API token from env or auth - required for authenticated gateways
const apiToken = yield* Effect.gen(function* () {
- const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
+ const envToken = env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"]
if (envToken) return envToken
if (auth?.type === "api") return auth.key
return undefined
@@ -1030,662 +1027,661 @@ export namespace Provider {
}
}
- 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(),
- }
+ const layer: Layer.Layer<
+ Service,
+ never,
+ Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service
+ > = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const config = yield* Config.Service
+ const auth = yield* Auth.Service
+ const env = yield* Env.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(),
+ env: () => env.all(),
+ get: (key: string) => env.get(key),
+ }
- log.info("init")
+ log.info("init")
- 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
+ function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
+ const existing = providers[providerID]
+ if (existing) {
// @ts-expect-error
- providers[providerID] = mergeDeep(match, provider)
+ providers[providerID] = mergeDeep(existing, provider)
+ return
}
+ const match = database[providerID]
+ if (!match) return
+ // @ts-expect-error
+ providers[providerID] = mergeDeep(match, provider)
+ }
- // load plugins first so config() hook runs before reading cfg.provider
- const plugins = yield* plugin.list()
+ // 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
- }
+ 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 ?? {},
- }
+ // 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,
+ 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,
},
- 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,
+ 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,
},
- 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,
- },
- 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
+ },
+ 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: {},
}
- database[providerID] = parsed
+ const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
+ parsedModel.variants = mapValues(
+ pickBy(merged, (v) => !v.disabled),
+ (v) => omit(v, ["disabled"]),
+ )
+ parsed.models[modelID] = parsedModel
}
+ database[providerID] = parsed
+ }
+
+ // load env
+ const envs = yield* env.all()
+ for (const [id, provider] of Object.entries(database)) {
+ const providerID = ProviderID.make(id)
+ if (disabled.has(providerID)) continue
+ const apiKey = provider.env.map((item) => envs[item]).find(Boolean)
+ if (!apiKey) continue
+ mergeProvider(providerID, {
+ source: "env",
+ key: provider.env.length === 1 ? apiKey : undefined,
+ })
+ }
- // 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
+ // load apikeys
+ const auths = yield* auth.all().pipe(Effect.orDie)
+ for (const [id, provider] of Object.entries(auths)) {
+ const providerID = ProviderID.make(id)
+ if (disabled.has(providerID)) continue
+ if (provider.type === "api") {
mergeProvider(providerID, {
- source: "env",
- key: provider.env.length === 1 ? apiKey : undefined,
+ source: "api",
+ key: provider.key,
})
}
+ }
- // load apikeys
- const auths = yield* auth.all().pipe(Effect.orDie)
- for (const [id, provider] of Object.entries(auths)) {
- const providerID = ProviderID.make(id)
- if (disabled.has(providerID)) continue
- if (provider.type === "api") {
- mergeProvider(providerID, {
- source: "api",
- key: provider.key,
- })
- }
- }
+ // plugin auth loader - database now has entries for config providers
+ for (const plugin of plugins) {
+ if (!plugin.auth) continue
+ const providerID = ProviderID.make(plugin.auth.provider)
+ if (disabled.has(providerID)) continue
+
+ const stored = yield* auth.get(providerID).pipe(Effect.orDie)
+ if (!stored) continue
+ if (!plugin.auth.loader) continue
+
+ const options = yield* Effect.promise(() =>
+ plugin.auth!.loader!(
+ () =>
+ Effect.runPromise(auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer))) as any,
+ database[plugin.auth!.provider],
+ ),
+ )
+ const opts = options ?? {}
+ const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
+ mergeProvider(providerID, patch)
+ }
- // 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 ?? {}
+ 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 [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)
- }
- }
-
- // 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)
- }
+ // 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
- }
+ 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 })
}
- })
- }
+ } catch (e) {
+ log.warn("state discovery error", { id: "gitlab", error: e })
+ }
+ })
+ }
- for (const hook of plugins) {
- const p = hook.provider
- const models = p?.models
- if (!p || !models) continue
-
- const providerID = ProviderID.make(p.id)
- if (disabled.has(providerID)) continue
-
- const provider = providers[providerID]
- if (!provider) continue
- const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
-
- provider.models = yield* Effect.promise(async () => {
- const next = await models(provider, { auth: pluginAuth })
- return Object.fromEntries(
- Object.entries(next).map(([id, model]) => [
- id,
- {
- ...model,
- id: ModelID.make(id),
- providerID,
- },
- ]),
- )
- })
- }
+ for (const hook of plugins) {
+ const p = hook.provider
+ const models = p?.models
+ if (!p || !models) continue
- for (const [id, provider] of Object.entries(providers)) {
- const providerID = ProviderID.make(id)
- if (!isProviderAllowed(providerID)) {
- delete providers[providerID]
- continue
- }
+ const providerID = ProviderID.make(p.id)
+ if (disabled.has(providerID)) continue
- const configProvider = cfg.provider?.[providerID]
+ const provider = providers[providerID]
+ if (!provider) continue
+ const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
- 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]
+ 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,
+ },
+ ]),
+ )
+ })
+ }
- model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
+ for (const [id, provider] of Object.entries(providers)) {
+ const providerID = ProviderID.make(id)
+ if (!isProviderAllowed(providerID)) {
+ delete providers[providerID]
+ 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 configProvider = cfg.provider?.[providerID]
- if (Object.keys(provider.models).length === 0) {
- delete providers[providerID]
- continue
- }
+ for (const [modelID, model] of Object.entries(provider.models)) {
+ model.api.id = model.api.id ?? model.id ?? modelID
+ if (
+ modelID === "gpt-5-chat-latest" ||
+ (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
+ )
+ delete provider.models[modelID]
+ if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
+ if (model.status === "deprecated") delete provider.models[modelID]
+ if (
+ (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
+ (configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
+ )
+ delete provider.models[modelID]
+
+ model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
- log.info("found", { providerID })
+ 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"]),
+ )
+ }
}
- return {
- models: languages,
- providers,
- sdk,
- modelLoaders,
- varsLoaders,
+ if (Object.keys(provider.models).length === 0) {
+ delete providers[providerID]
+ continue
}
- }),
- )
- const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
+ log.info("found", { providerID })
+ }
- 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 }
+ return {
+ models: languages,
+ providers,
+ sdk,
+ modelLoaders,
+ varsLoaders,
+ }
+ }),
+ )
- if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
- delete options.fetch
- }
+ const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
- if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
- options["includeUsage"] = true
- }
+ async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
+ try {
+ using _ = log.time("getSDK", {
+ providerID: model.providerID,
+ })
+ const provider = s.providers[model.providerID]
+ const options = { ...provider.options }
- 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)
- }
+ 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
+ url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
+ const val = envs[String(key)]
+ return val ?? item
})
+ return url
+ })
- if (baseURL !== undefined) options["baseURL"] = baseURL
- if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
- if (model.headers)
- options["headers"] = {
- ...options["headers"],
- ...model.headers,
- }
+ 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 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 res = await fetchFn(input, {
+ ...opts,
+ // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
+ timeout: false,
+ })
- const mod = await import(installedPath)
+ if (!chunkAbortCtl) return res
+ return wrapSSE(res, chunkTimeout, chunkAbortCtl)
+ }
- const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
- const loaded = fn({
+ const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
+ if (bundledFn) {
+ log.info("using bundled provider", {
+ providerID: model.providerID,
+ pkg: model.api.npm,
+ })
+ const loaded = bundledFn({
name: model.providerID,
...options,
})
s.sdk.set(key, loaded)
return loaded as SDK
- } 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) })
+ 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 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 mod = await import(installedPath)
- 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 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 provider = s.providers[model.providerID]
- const sdk = await resolveSDK(model, s)
+ 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) })
+ }
- 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 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 envs = yield* env.all()
+ const key = `${model.providerID}/${model.id}`
+ if (s.models.has(key)) return s.models.get(key)!
+
+ return yield* Effect.promise(async () => {
+ const url = (() => {
+ const item = envs["OPENCODE_E2E_LLM_URL"]
+ if (typeof item !== "string" || item === "") return
+ return item
+ })()
+ 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 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 provider = s.providers[model.providerID]
+ const sdk = await resolveSDK(model, s, envs)
+
+ 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
}
- 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)
-
- 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 }
- }
+ return undefined
+ })
+
+ const defaultModel = Effect.fn("Provider.defaultModel")(function* () {
+ const cfg = yield* config.get()
+ if (cfg.model) return parseModel(cfg.model)
+
+ const s = yield* InstanceState.get(state)
+ const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
+ Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
+ if (!isRecord(x) || !Array.isArray(x.recent)) return []
+ return x.recent.flatMap((item) => {
+ if (!isRecord(item)) return []
+ if (typeof item.providerID !== "string") return []
+ if (typeof item.modelID !== "string") return []
+ return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
+ })
+ }),
+ Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
+ )
+ for (const entry of recent) {
+ const provider = s.providers[entry.providerID]
+ if (!provider) continue
+ if (!provider.models[entry.modelID]) continue
+ return { providerID: entry.providerID, modelID: entry.modelID }
+ }
- const provider = Object.values(s.providers).find(
- (p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id),
- )
- if (!provider) throw new Error("no providers found")
- const [model] = sort(Object.values(provider.models))
- if (!model) throw new Error("no models found")
- return {
- providerID: provider.id,
- modelID: model.id,
- }
- })
+ 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(Env.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Plugin.defaultLayer),
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index d6daa87f5..07dc8eb20 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -78,6 +78,7 @@ export namespace ToolRegistry {
Service,
never,
| Config.Service
+ | Env.Service
| Plugin.Service
| Question.Service
| Todo.Service
@@ -99,6 +100,7 @@ export namespace ToolRegistry {
Service,
Effect.gen(function* () {
const config = yield* Config.Service
+ const env = yield* Env.Service
const plugin = yield* Plugin.Service
const agents = yield* Agent.Service
const skill = yield* Skill.Service
@@ -272,13 +274,14 @@ export namespace ToolRegistry {
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
+ const e2e = !!(yield* env.get("OPENCODE_E2E_LLM_URL"))
const filtered = (yield* all()).filter((tool) => {
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
- !!Env.get("OPENCODE_E2E_LLM_URL") ||
+ e2e ||
(input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4"))
if (tool.id === ApplyPatchTool.id) return usePatch
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
@@ -325,6 +328,7 @@ export namespace ToolRegistry {
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Config.defaultLayer),
+ Layer.provide(Env.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index ce3566a0c..e759985fe 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -6,6 +6,7 @@ import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { AppFileSystem } from "../../src/filesystem"
+import { Env } from "../../src/env"
import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir, tmpdirScoped } from "../fixture/fixture"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -35,6 +36,7 @@ const emptyAuth = Layer.mock(Auth.Service)({
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
@@ -332,6 +334,7 @@ test("resolves env templates in account config with account token", async () =>
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(fakeAccount),
Layer.provideMerge(infra),
@@ -1824,6 +1827,7 @@ test("project config overrides remote well-known config", async () => {
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
@@ -1879,6 +1883,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts
index 4e5b39942..98b1fde00 100644
--- a/packages/opencode/test/session/prompt-effect.test.ts
+++ b/packages/opencode/test/session/prompt-effect.test.ts
@@ -14,6 +14,7 @@ import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
+import { Env } from "../../src/env"
import type { Provider } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question"
@@ -167,6 +168,7 @@ function makeHttp() {
Session.defaultLayer,
Snapshot.defaultLayer,
LLM.defaultLayer,
+ Env.defaultLayer,
AgentSvc.defaultLayer,
Command.defaultLayer,
Permission.defaultLayer,
diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts
index 2723e362d..464182395 100644
--- a/packages/opencode/test/session/snapshot-tool-race.test.ts
+++ b/packages/opencode/test/session/snapshot-tool-race.test.ts
@@ -39,6 +39,7 @@ import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
+import { Env } from "../../src/env"
import { Question } from "../../src/question"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
@@ -121,6 +122,7 @@ function makeHttp() {
Session.defaultLayer,
Snapshot.defaultLayer,
LLM.defaultLayer,
+ Env.defaultLayer,
AgentSvc.defaultLayer,
Command.defaultLayer,
Permission.defaultLayer,