Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a1ed81828 | |||
| 4234326d19 | |||
| 07d502450c | |||
| 3f0d6d009d | |||
| fdd277502a | |||
| aae90fb3a4 | |||
| 6a9b69b10b | |||
| 1147a24bad | |||
| efc4954ea6 | |||
| f6537333a5 | |||
| 872b148bfe | |||
| ac3668db8f | |||
| c98ce87eb8 |
@@ -178,7 +178,9 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
||||
|
||||
## Migration checklist
|
||||
|
||||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
|
||||
|
||||
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in [Destroying the facades](#destroying-the-facades).
|
||||
|
||||
- [x] `Account` — `account/index.ts`
|
||||
- [x] `Agent` — `agent/agent.ts`
|
||||
@@ -221,20 +223,22 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
- [x] `Provider` — `provider/provider.ts`
|
||||
- [x] `Storage` — `storage/storage.ts`
|
||||
- [x] `ShareNext` — `share/share-next.ts`
|
||||
|
||||
Still open:
|
||||
|
||||
- [x] `SessionTodo` — `session/todo.ts`
|
||||
- [ ] `SyncEvent` — `sync/index.ts`
|
||||
- [ ] `Workspace` — `control-plane/workspace.ts`
|
||||
|
||||
Still open at the service-shape level:
|
||||
|
||||
- [ ] `SyncEvent` — `sync/index.ts` (deferred pending sync with James)
|
||||
- [ ] `Workspace` — `control-plane/workspace.ts` (deferred pending sync with James)
|
||||
|
||||
## Tool interface → Effect
|
||||
|
||||
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is:
|
||||
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch, and the current tools in `src/tool/*.ts` have been migrated to the Effect-native `Tool.define(...)` shape.
|
||||
|
||||
1. Migrate each tool body to return Effects
|
||||
2. Keep `Tool.define()` inputs Effect-native
|
||||
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
|
||||
The remaining work here is follow-on cleanup rather than the top-level tool interface migration:
|
||||
|
||||
1. Remove internal `Effect.promise(...)` bridges where practical
|
||||
2. Keep replacing raw platform helpers with Effect services inside tool bodies
|
||||
3. Update remaining callers and tests to prefer `yield* info.init()` / `Tool.init(...)` over older Promise-oriented patterns
|
||||
|
||||
### Tool migration details
|
||||
|
||||
@@ -254,26 +258,27 @@ This keeps migrated tool tests aligned with the production service graph today,
|
||||
|
||||
Individual tools, ordered by value:
|
||||
|
||||
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
|
||||
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
|
||||
- [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
|
||||
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
|
||||
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
|
||||
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
|
||||
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
|
||||
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
|
||||
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
|
||||
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
|
||||
- [ ] `task.ts` — MEDIUM: task state management
|
||||
- [ ] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
|
||||
- [ ] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
|
||||
- [ ] `glob.ts` — LOW: simple async generator
|
||||
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
|
||||
- [ ] `question.ts` — LOW: prompt wrapper
|
||||
- [ ] `skill.ts` — LOW: skill tool adapter
|
||||
- [ ] `todo.ts` — LOW: todo persistence wrapper
|
||||
- [ ] `invalid.ts` — LOW: invalid-tool fallback
|
||||
- [ ] `plan.ts` — LOW: plan file operations
|
||||
- [x] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
|
||||
- [x] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
|
||||
- [x] `read.ts` — HIGH: effectful interface migrated; still has raw fs/readline internals tracked below
|
||||
- [x] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
|
||||
- [x] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
|
||||
- [x] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
|
||||
- [x] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
|
||||
- [x] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
|
||||
- [x] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
|
||||
- [x] `task.ts` — MEDIUM: task state management
|
||||
- [x] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
|
||||
- [x] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
|
||||
- [x] `glob.ts` — LOW: simple async generator
|
||||
- [x] `lsp.ts` — LOW: dispatch switch over LSP operations
|
||||
- [x] `question.ts` — LOW: prompt wrapper
|
||||
- [x] `skill.ts` — LOW: skill tool adapter
|
||||
- [x] `todo.ts` — LOW: todo persistence wrapper
|
||||
- [x] `invalid.ts` — LOW: invalid-tool fallback
|
||||
- [x] `plan.ts` — LOW: plan file operations
|
||||
|
||||
`batch.ts` was removed from `src/tool/` and is no longer tracked here.
|
||||
|
||||
## Effect service adoption in already-migrated code
|
||||
|
||||
@@ -281,25 +286,21 @@ Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` i
|
||||
|
||||
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
||||
|
||||
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling
|
||||
- [ ] `config/config.ts` — 5 remaining `Filesystem.*` calls in `installDependencies()`
|
||||
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
|
||||
- [x] `config/config.ts` — `installDependencies()` now uses `AppFileSystem`
|
||||
- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
|
||||
|
||||
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
||||
|
||||
- [ ] `format/formatter.ts` — 2 remaining `Process.spawn()` checks (`air`, `uv`)
|
||||
- [x] `format/formatter.ts` — direct `Process.spawn()` checks removed (`air`, `uv`)
|
||||
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
|
||||
|
||||
## Filesystem consolidation
|
||||
|
||||
`util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
|
||||
|
||||
Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
|
||||
`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep.
|
||||
|
||||
Current raw fs users that will convert during tool migration:
|
||||
|
||||
- `tool/read.ts` — fs.createReadStream, readline
|
||||
- `tool/apply_patch.ts` — fs/promises
|
||||
- `file/ripgrep.ts` — fs/promises
|
||||
- `patch/index.ts` — fs, fs/promises
|
||||
|
||||
@@ -312,7 +313,9 @@ Current raw fs users that will convert during tool migration:
|
||||
|
||||
## Destroying the facades
|
||||
|
||||
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
|
||||
This phase is still broadly open. As of 2026-04-11 there are still 31 `makeRuntime(...)` call sites under `src/`, and many service namespaces still export async facade helpers like `export async function read(...) { return runPromise(...) }`.
|
||||
|
||||
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
|
||||
|
||||
### Process
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ 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"
|
||||
@@ -31,12 +31,11 @@ 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 { Duration, Effect, Fiber, Layer, Option, Context } from "effect"
|
||||
import { Flock } from "@/util/flock"
|
||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||
import { Npm } from "@/npm"
|
||||
@@ -139,54 +138,8 @@ export namespace Config {
|
||||
return merged
|
||||
}
|
||||
|
||||
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 +1064,7 @@ export namespace Config {
|
||||
type State = {
|
||||
config: Info
|
||||
directories: string[]
|
||||
deps: Promise<void>[]
|
||||
deps: Fiber.Fiber<void, never>[]
|
||||
consoleState: ConsoleState
|
||||
}
|
||||
|
||||
@@ -1119,6 +1072,7 @@ export namespace Config {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly getGlobal: () => Effect.Effect<Info>
|
||||
readonly getConsoleState: () => Effect.Effect<ConsoleState>
|
||||
readonly installDependencies: (dir: string) => 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 +1274,61 @@ 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) {
|
||||
if (
|
||||
!(yield* fs.access(dir, { writable: true }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.orElseSucceed(() => false),
|
||||
))
|
||||
)
|
||||
return
|
||||
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.promise((signal) =>
|
||||
Flock.acquire(`config-install:${AppFileSystem.resolve(dir)}`, {
|
||||
signal,
|
||||
}),
|
||||
),
|
||||
() => install(dir),
|
||||
(lease) => Effect.promise(() => lease.release()),
|
||||
)
|
||||
})
|
||||
|
||||
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||
|
||||
@@ -1402,7 +1411,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 +1425,14 @@ 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.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("background dependency install failed", { dir, error: String(cause) })
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
deps.push(dep)
|
||||
|
||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
||||
@@ -1558,7 +1569,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 +1626,7 @@ export namespace Config {
|
||||
get,
|
||||
getGlobal,
|
||||
getConsoleState,
|
||||
installDependencies,
|
||||
update,
|
||||
updateGlobal,
|
||||
invalidate,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@/global"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
export namespace TuiConfig {
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
@@ -51,7 +52,7 @@ export namespace TuiConfig {
|
||||
}
|
||||
|
||||
function installDeps(dir: string): Promise<void> {
|
||||
return Config.installDependencies(dir)
|
||||
return AppRuntime.runPromise(Config.Service.use((svc) => svc.installDependencies(dir)))
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string) {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ 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"
|
||||
@@ -1028,641 +1030,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 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 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")
|
||||
|
||||
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(match, provider)
|
||||
}
|
||||
|
||||
// load plugins first so config() hook runs before reading cfg.provider
|
||||
const plugins = yield* plugin.list()
|
||||
|
||||
// now read config providers - includes any modifications from plugin config() hook
|
||||
const configProviders = Object.entries(cfg.provider ?? {})
|
||||
const disabled = new Set(cfg.disabled_providers ?? [])
|
||||
const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null
|
||||
|
||||
function isProviderAllowed(providerID: ProviderID): boolean {
|
||||
if (enabled && !enabled.has(providerID)) return false
|
||||
if (disabled.has(providerID)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// extend database from config
|
||||
for (const [providerID, provider] of configProviders) {
|
||||
const existing = database[providerID]
|
||||
const parsed: Info = {
|
||||
id: ProviderID.make(providerID),
|
||||
name: provider.name ?? existing?.name ?? providerID,
|
||||
env: provider.env ?? existing?.env ?? [],
|
||||
options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
|
||||
source: "config",
|
||||
models: existing?.models ?? {},
|
||||
const providers: Record<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(),
|
||||
}
|
||||
|
||||
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
|
||||
const existingModel = parsed.models[model.id ?? modelID]
|
||||
const name = iife(() => {
|
||||
if (model.name) return model.name
|
||||
if (model.id && model.id !== modelID) return modelID
|
||||
return existingModel?.name ?? modelID
|
||||
})
|
||||
const parsedModel: Model = {
|
||||
id: ModelID.make(modelID),
|
||||
api: {
|
||||
id: model.id ?? existingModel?.api.id ?? modelID,
|
||||
npm:
|
||||
model.provider?.npm ??
|
||||
provider.npm ??
|
||||
existingModel?.api.npm ??
|
||||
modelsDev[providerID]?.npm ??
|
||||
"@ai-sdk/openai-compatible",
|
||||
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
|
||||
},
|
||||
status: model.status ?? existingModel?.status ?? "active",
|
||||
name,
|
||||
providerID: ProviderID.make(providerID),
|
||||
capabilities: {
|
||||
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
|
||||
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
|
||||
attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
|
||||
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
|
||||
input: {
|
||||
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
|
||||
audio:
|
||||
model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
|
||||
image:
|
||||
model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
|
||||
video:
|
||||
model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
|
||||
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
|
||||
},
|
||||
output: {
|
||||
text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
|
||||
audio:
|
||||
model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
|
||||
image:
|
||||
model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
|
||||
video:
|
||||
model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
|
||||
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
|
||||
},
|
||||
interleaved: model.interleaved ?? false,
|
||||
},
|
||||
cost: {
|
||||
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
|
||||
output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
|
||||
cache: {
|
||||
read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
|
||||
write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
|
||||
},
|
||||
},
|
||||
options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
|
||||
limit: {
|
||||
context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
|
||||
input: model.limit?.input ?? existingModel?.limit?.input,
|
||||
output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
|
||||
},
|
||||
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
|
||||
family: model.family ?? existingModel?.family ?? "",
|
||||
release_date: model.release_date ?? existingModel?.release_date ?? "",
|
||||
variants: {},
|
||||
log.info("init")
|
||||
|
||||
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
|
||||
const existing = providers[providerID]
|
||||
if (existing) {
|
||||
// @ts-expect-error
|
||||
providers[providerID] = mergeDeep(existing, provider)
|
||||
return
|
||||
}
|
||||
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
|
||||
parsedModel.variants = mapValues(
|
||||
pickBy(merged, (v) => !v.disabled),
|
||||
(v) => omit(v, ["disabled"]),
|
||||
)
|
||||
parsed.models[modelID] = parsedModel
|
||||
const match = database[providerID]
|
||||
if (!match) return
|
||||
// @ts-expect-error
|
||||
providers[providerID] = mergeDeep(match, provider)
|
||||
}
|
||||
database[providerID] = parsed
|
||||
}
|
||||
|
||||
// load env
|
||||
const env = Env.all()
|
||||
for (const [id, provider] of Object.entries(database)) {
|
||||
const providerID = ProviderID.make(id)
|
||||
if (disabled.has(providerID)) continue
|
||||
const apiKey = provider.env.map((item) => env[item]).find(Boolean)
|
||||
if (!apiKey) continue
|
||||
mergeProvider(providerID, {
|
||||
source: "env",
|
||||
key: provider.env.length === 1 ? apiKey : undefined,
|
||||
})
|
||||
}
|
||||
// load plugins first so config() hook runs before reading cfg.provider
|
||||
const plugins = yield* plugin.list()
|
||||
|
||||
// load apikeys
|
||||
const auths = yield* auth.all().pipe(Effect.orDie)
|
||||
for (const [id, provider] of Object.entries(auths)) {
|
||||
const providerID = ProviderID.make(id)
|
||||
if (disabled.has(providerID)) continue
|
||||
if (provider.type === "api") {
|
||||
// now read config providers - includes any modifications from plugin config() hook
|
||||
const configProviders = Object.entries(cfg.provider ?? {})
|
||||
const disabled = new Set(cfg.disabled_providers ?? [])
|
||||
const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null
|
||||
|
||||
function isProviderAllowed(providerID: ProviderID): boolean {
|
||||
if (enabled && !enabled.has(providerID)) return false
|
||||
if (disabled.has(providerID)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// extend database from config
|
||||
for (const [providerID, provider] of configProviders) {
|
||||
const existing = database[providerID]
|
||||
const parsed: Info = {
|
||||
id: ProviderID.make(providerID),
|
||||
name: provider.name ?? existing?.name ?? providerID,
|
||||
env: provider.env ?? existing?.env ?? [],
|
||||
options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
|
||||
source: "config",
|
||||
models: existing?.models ?? {},
|
||||
}
|
||||
|
||||
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
|
||||
const existingModel = parsed.models[model.id ?? modelID]
|
||||
const name = iife(() => {
|
||||
if (model.name) return model.name
|
||||
if (model.id && model.id !== modelID) return modelID
|
||||
return existingModel?.name ?? modelID
|
||||
})
|
||||
const parsedModel: Model = {
|
||||
id: ModelID.make(modelID),
|
||||
api: {
|
||||
id: model.id ?? existingModel?.api.id ?? modelID,
|
||||
npm:
|
||||
model.provider?.npm ??
|
||||
provider.npm ??
|
||||
existingModel?.api.npm ??
|
||||
modelsDev[providerID]?.npm ??
|
||||
"@ai-sdk/openai-compatible",
|
||||
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
|
||||
},
|
||||
status: model.status ?? existingModel?.status ?? "active",
|
||||
name,
|
||||
providerID: ProviderID.make(providerID),
|
||||
capabilities: {
|
||||
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
|
||||
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
|
||||
attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
|
||||
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
|
||||
input: {
|
||||
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
|
||||
audio:
|
||||
model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
|
||||
image:
|
||||
model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
|
||||
video:
|
||||
model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
|
||||
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
|
||||
},
|
||||
output: {
|
||||
text:
|
||||
model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
|
||||
audio:
|
||||
model.modalities?.output?.includes("audio") ??
|
||||
existingModel?.capabilities.output.audio ??
|
||||
false,
|
||||
image:
|
||||
model.modalities?.output?.includes("image") ??
|
||||
existingModel?.capabilities.output.image ??
|
||||
false,
|
||||
video:
|
||||
model.modalities?.output?.includes("video") ??
|
||||
existingModel?.capabilities.output.video ??
|
||||
false,
|
||||
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
|
||||
},
|
||||
interleaved: model.interleaved ?? false,
|
||||
},
|
||||
cost: {
|
||||
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
|
||||
output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
|
||||
cache: {
|
||||
read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
|
||||
write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
|
||||
},
|
||||
},
|
||||
options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
|
||||
limit: {
|
||||
context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
|
||||
input: model.limit?.input ?? existingModel?.limit?.input,
|
||||
output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
|
||||
},
|
||||
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
|
||||
family: model.family ?? existingModel?.family ?? "",
|
||||
release_date: model.release_date ?? existingModel?.release_date ?? "",
|
||||
variants: {},
|
||||
}
|
||||
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
|
||||
parsedModel.variants = mapValues(
|
||||
pickBy(merged, (v) => !v.disabled),
|
||||
(v) => omit(v, ["disabled"]),
|
||||
)
|
||||
parsed.models[modelID] = parsedModel
|
||||
}
|
||||
database[providerID] = parsed
|
||||
}
|
||||
|
||||
// load env
|
||||
const env = Env.all()
|
||||
for (const [id, provider] of Object.entries(database)) {
|
||||
const providerID = ProviderID.make(id)
|
||||
if (disabled.has(providerID)) continue
|
||||
const apiKey = provider.env.map((item) => env[item]).find(Boolean)
|
||||
if (!apiKey) continue
|
||||
mergeProvider(providerID, {
|
||||
source: "api",
|
||||
key: provider.key,
|
||||
source: "env",
|
||||
key: provider.env.length === 1 ? apiKey : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// plugin auth loader - database now has entries for config providers
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.auth) continue
|
||||
const providerID = ProviderID.make(plugin.auth.provider)
|
||||
if (disabled.has(providerID)) continue
|
||||
|
||||
const stored = yield* auth.get(providerID).pipe(Effect.orDie)
|
||||
if (!stored) continue
|
||||
if (!plugin.auth.loader) continue
|
||||
|
||||
const options = yield* Effect.promise(() =>
|
||||
plugin.auth!.loader!(
|
||||
() =>
|
||||
Effect.runPromise(auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer))) as any,
|
||||
database[plugin.auth!.provider],
|
||||
),
|
||||
)
|
||||
const opts = options ?? {}
|
||||
const patch: Partial<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
|
||||
|
||||
const providerID = ProviderID.make(p.id)
|
||||
if (disabled.has(providerID)) continue
|
||||
|
||||
const provider = providers[providerID]
|
||||
if (!provider) continue
|
||||
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
|
||||
|
||||
provider.models = yield* Effect.promise(async () => {
|
||||
const next = await models(provider, { auth: pluginAuth })
|
||||
return Object.fromEntries(
|
||||
Object.entries(next).map(([id, model]) => [
|
||||
id,
|
||||
{
|
||||
...model,
|
||||
id: ModelID.make(id),
|
||||
providerID,
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
for (const [id, provider] of Object.entries(providers)) {
|
||||
const providerID = ProviderID.make(id)
|
||||
if (!isProviderAllowed(providerID)) {
|
||||
delete providers[providerID]
|
||||
continue
|
||||
}
|
||||
|
||||
const configProvider = cfg.provider?.[providerID]
|
||||
// load config - re-apply with updated data
|
||||
for (const [id, provider] of configProviders) {
|
||||
const providerID = ProviderID.make(id)
|
||||
const partial: Partial<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)
|
||||
}
|
||||
|
||||
for (const [modelID, model] of Object.entries(provider.models)) {
|
||||
model.api.id = model.api.id ?? model.id ?? modelID
|
||||
if (
|
||||
modelID === "gpt-5-chat-latest" ||
|
||||
(providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
|
||||
)
|
||||
delete provider.models[modelID]
|
||||
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
|
||||
if (model.status === "deprecated") delete provider.models[modelID]
|
||||
if (
|
||||
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
|
||||
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
|
||||
)
|
||||
delete provider.models[modelID]
|
||||
const gitlab = ProviderID.make("gitlab")
|
||||
if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) {
|
||||
yield* Effect.promise(async () => {
|
||||
try {
|
||||
const discovered = await discoveryLoaders[gitlab]()
|
||||
for (const [modelID, model] of Object.entries(discovered)) {
|
||||
if (!providers[gitlab].models[modelID]) {
|
||||
providers[gitlab].models[modelID] = model
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn("state discovery error", { id: "gitlab", error: e })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
|
||||
for (const hook of plugins) {
|
||||
const p = hook.provider
|
||||
const models = p?.models
|
||||
if (!p || !models) continue
|
||||
|
||||
const configVariants = configProvider?.models?.[modelID]?.variants
|
||||
if (configVariants && model.variants) {
|
||||
const merged = mergeDeep(model.variants, configVariants)
|
||||
model.variants = mapValues(
|
||||
pickBy(merged, (v) => !v.disabled),
|
||||
(v) => omit(v, ["disabled"]),
|
||||
const providerID = ProviderID.make(p.id)
|
||||
if (disabled.has(providerID)) continue
|
||||
|
||||
const provider = providers[providerID]
|
||||
if (!provider) continue
|
||||
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
|
||||
|
||||
provider.models = yield* Effect.promise(async () => {
|
||||
const next = await models(provider, { auth: pluginAuth })
|
||||
return Object.fromEntries(
|
||||
Object.entries(next).map(([id, model]) => [
|
||||
id,
|
||||
{
|
||||
...model,
|
||||
id: ModelID.make(id),
|
||||
providerID,
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
for (const [id, provider] of Object.entries(providers)) {
|
||||
const providerID = ProviderID.make(id)
|
||||
if (!isProviderAllowed(providerID)) {
|
||||
delete providers[providerID]
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(provider.models).length === 0) {
|
||||
delete providers[providerID]
|
||||
continue
|
||||
}
|
||||
const configProvider = cfg.provider?.[providerID]
|
||||
|
||||
log.info("found", { providerID })
|
||||
}
|
||||
for (const [modelID, model] of Object.entries(provider.models)) {
|
||||
model.api.id = model.api.id ?? model.id ?? modelID
|
||||
if (
|
||||
modelID === "gpt-5-chat-latest" ||
|
||||
(providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
|
||||
)
|
||||
delete provider.models[modelID]
|
||||
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS)
|
||||
delete provider.models[modelID]
|
||||
if (model.status === "deprecated") delete provider.models[modelID]
|
||||
if (
|
||||
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
|
||||
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
|
||||
)
|
||||
delete provider.models[modelID]
|
||||
|
||||
return {
|
||||
models: languages,
|
||||
providers,
|
||||
sdk,
|
||||
modelLoaders,
|
||||
varsLoaders,
|
||||
}
|
||||
}),
|
||||
)
|
||||
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
|
||||
|
||||
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
|
||||
|
||||
async function resolveSDK(model: Model, s: State) {
|
||||
try {
|
||||
using _ = log.time("getSDK", {
|
||||
providerID: model.providerID,
|
||||
})
|
||||
const provider = s.providers[model.providerID]
|
||||
const options = { ...provider.options }
|
||||
|
||||
if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
|
||||
delete options.fetch
|
||||
}
|
||||
|
||||
if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
|
||||
options["includeUsage"] = true
|
||||
}
|
||||
|
||||
const baseURL = iife(() => {
|
||||
let url =
|
||||
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
|
||||
if (!url) return
|
||||
|
||||
const loader = s.varsLoaders[model.providerID]
|
||||
if (loader) {
|
||||
const vars = loader(options)
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
const field = "${" + key + "}"
|
||||
url = url.replaceAll(field, value)
|
||||
}
|
||||
}
|
||||
|
||||
url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
|
||||
const val = Env.get(String(key))
|
||||
return val ?? item
|
||||
})
|
||||
return url
|
||||
})
|
||||
|
||||
if (baseURL !== undefined) options["baseURL"] = baseURL
|
||||
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
|
||||
if (model.headers)
|
||||
options["headers"] = {
|
||||
...options["headers"],
|
||||
...model.headers,
|
||||
}
|
||||
|
||||
const key = Hash.fast(
|
||||
JSON.stringify({
|
||||
providerID: model.providerID,
|
||||
npm: model.api.npm,
|
||||
options,
|
||||
}),
|
||||
)
|
||||
const existing = s.sdk.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const customFetch = options["fetch"]
|
||||
const chunkTimeout = options["chunkTimeout"]
|
||||
delete options["chunkTimeout"]
|
||||
|
||||
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
||||
const fetchFn = customFetch ?? fetch
|
||||
const opts = init ?? {}
|
||||
const chunkAbortCtl =
|
||||
typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
|
||||
const signals: AbortSignal[] = []
|
||||
|
||||
if (opts.signal) signals.push(opts.signal)
|
||||
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
|
||||
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
|
||||
signals.push(AbortSignal.timeout(options["timeout"]))
|
||||
|
||||
const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
|
||||
if (combined) opts.signal = combined
|
||||
|
||||
// Strip openai itemId metadata following what codex does
|
||||
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
|
||||
const body = JSON.parse(opts.body as string)
|
||||
const isAzure = model.providerID.includes("azure")
|
||||
const keepIds = isAzure && body.store === true
|
||||
if (!keepIds && Array.isArray(body.input)) {
|
||||
for (const item of body.input) {
|
||||
if ("id" in item) {
|
||||
delete item.id
|
||||
}
|
||||
const configVariants = configProvider?.models?.[modelID]?.variants
|
||||
if (configVariants && model.variants) {
|
||||
const merged = mergeDeep(model.variants, configVariants)
|
||||
model.variants = mapValues(
|
||||
pickBy(merged, (v) => !v.disabled),
|
||||
(v) => omit(v, ["disabled"]),
|
||||
)
|
||||
}
|
||||
opts.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
if (Object.keys(provider.models).length === 0) {
|
||||
delete providers[providerID]
|
||||
continue
|
||||
}
|
||||
|
||||
log.info("found", { providerID })
|
||||
}
|
||||
|
||||
const res = await fetchFn(input, {
|
||||
...opts,
|
||||
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
|
||||
timeout: false,
|
||||
})
|
||||
return {
|
||||
models: languages,
|
||||
providers,
|
||||
sdk,
|
||||
modelLoaders,
|
||||
varsLoaders,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
if (!chunkAbortCtl) return res
|
||||
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
||||
}
|
||||
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
|
||||
|
||||
const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
|
||||
if (bundledFn) {
|
||||
log.info("using bundled provider", {
|
||||
async function resolveSDK(model: Model, s: State) {
|
||||
try {
|
||||
using _ = log.time("getSDK", {
|
||||
providerID: model.providerID,
|
||||
pkg: model.api.npm,
|
||||
})
|
||||
const loaded = bundledFn({
|
||||
const provider = s.providers[model.providerID]
|
||||
const options = { ...provider.options }
|
||||
|
||||
if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
|
||||
delete options.fetch
|
||||
}
|
||||
|
||||
if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
|
||||
options["includeUsage"] = true
|
||||
}
|
||||
|
||||
const baseURL = iife(() => {
|
||||
let url =
|
||||
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
|
||||
if (!url) return
|
||||
|
||||
const loader = s.varsLoaders[model.providerID]
|
||||
if (loader) {
|
||||
const vars = loader(options)
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
const field = "${" + key + "}"
|
||||
url = url.replaceAll(field, value)
|
||||
}
|
||||
}
|
||||
|
||||
url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
|
||||
const val = Env.get(String(key))
|
||||
return val ?? item
|
||||
})
|
||||
return url
|
||||
})
|
||||
|
||||
if (baseURL !== undefined) options["baseURL"] = baseURL
|
||||
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
|
||||
if (model.headers)
|
||||
options["headers"] = {
|
||||
...options["headers"],
|
||||
...model.headers,
|
||||
}
|
||||
|
||||
const key = Hash.fast(
|
||||
JSON.stringify({
|
||||
providerID: model.providerID,
|
||||
npm: model.api.npm,
|
||||
options,
|
||||
}),
|
||||
)
|
||||
const existing = s.sdk.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const customFetch = options["fetch"]
|
||||
const chunkTimeout = options["chunkTimeout"]
|
||||
delete options["chunkTimeout"]
|
||||
|
||||
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
||||
const fetchFn = customFetch ?? fetch
|
||||
const opts = init ?? {}
|
||||
const chunkAbortCtl =
|
||||
typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
|
||||
const signals: AbortSignal[] = []
|
||||
|
||||
if (opts.signal) signals.push(opts.signal)
|
||||
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
|
||||
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
|
||||
signals.push(AbortSignal.timeout(options["timeout"]))
|
||||
|
||||
const combined =
|
||||
signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
|
||||
if (combined) opts.signal = combined
|
||||
|
||||
// Strip openai itemId metadata following what codex does
|
||||
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
|
||||
const body = JSON.parse(opts.body as string)
|
||||
const isAzure = model.providerID.includes("azure")
|
||||
const keepIds = isAzure && body.store === true
|
||||
if (!keepIds && Array.isArray(body.input)) {
|
||||
for (const item of body.input) {
|
||||
if ("id" in item) {
|
||||
delete item.id
|
||||
}
|
||||
}
|
||||
opts.body = JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetchFn(input, {
|
||||
...opts,
|
||||
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
|
||||
timeout: false,
|
||||
})
|
||||
|
||||
if (!chunkAbortCtl) return res
|
||||
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
||||
}
|
||||
|
||||
const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
|
||||
if (bundledFn) {
|
||||
log.info("using bundled provider", {
|
||||
providerID: model.providerID,
|
||||
pkg: model.api.npm,
|
||||
})
|
||||
const loaded = bundledFn({
|
||||
name: model.providerID,
|
||||
...options,
|
||||
})
|
||||
s.sdk.set(key, loaded)
|
||||
return loaded as SDK
|
||||
}
|
||||
|
||||
let installedPath: string
|
||||
if (!model.api.npm.startsWith("file://")) {
|
||||
const item = await Npm.add(model.api.npm)
|
||||
if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`)
|
||||
installedPath = item.entrypoint
|
||||
} else {
|
||||
log.info("loading local provider", { pkg: model.api.npm })
|
||||
installedPath = model.api.npm
|
||||
}
|
||||
|
||||
const mod = await import(installedPath)
|
||||
|
||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||
const loaded = fn({
|
||||
name: model.providerID,
|
||||
...options,
|
||||
})
|
||||
s.sdk.set(key, loaded)
|
||||
return loaded as SDK
|
||||
}
|
||||
|
||||
let installedPath: string
|
||||
if (!model.api.npm.startsWith("file://")) {
|
||||
const item = await Npm.add(model.api.npm)
|
||||
if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`)
|
||||
installedPath = item.entrypoint
|
||||
} else {
|
||||
log.info("loading local provider", { pkg: model.api.npm })
|
||||
installedPath = model.api.npm
|
||||
}
|
||||
|
||||
const mod = await import(installedPath)
|
||||
|
||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||
const loaded = fn({
|
||||
name: model.providerID,
|
||||
...options,
|
||||
})
|
||||
s.sdk.set(key, loaded)
|
||||
return loaded as SDK
|
||||
} catch (e) {
|
||||
throw new InitError({ providerID: model.providerID }, { cause: e })
|
||||
}
|
||||
}
|
||||
|
||||
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
|
||||
InstanceState.use(state, (s) => s.providers[providerID]),
|
||||
)
|
||||
|
||||
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) {
|
||||
const available = Object.keys(s.providers)
|
||||
const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 })
|
||||
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
|
||||
}
|
||||
|
||||
const info = provider.models[modelID]
|
||||
if (!info) {
|
||||
const available = Object.keys(provider.models)
|
||||
const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 })
|
||||
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
|
||||
}
|
||||
return info
|
||||
})
|
||||
|
||||
const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const key = `${model.providerID}/${model.id}`
|
||||
if (s.models.has(key)) return s.models.get(key)!
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const url = e2eURL()
|
||||
if (url) {
|
||||
const language = createOpenAICompatible({
|
||||
name: model.providerID,
|
||||
apiKey: "test-key",
|
||||
baseURL: url,
|
||||
}).chatModel(model.api.id)
|
||||
s.models.set(key, language)
|
||||
return language
|
||||
}
|
||||
|
||||
const provider = s.providers[model.providerID]
|
||||
const sdk = await resolveSDK(model, s)
|
||||
|
||||
try {
|
||||
const language = s.modelLoaders[model.providerID]
|
||||
? await s.modelLoaders[model.providerID](sdk, model.api.id, {
|
||||
...provider.options,
|
||||
...model.options,
|
||||
})
|
||||
: sdk.languageModel(model.api.id)
|
||||
s.models.set(key, language)
|
||||
return language
|
||||
} catch (e) {
|
||||
if (e instanceof NoSuchModelError)
|
||||
throw new ModelNotFoundError(
|
||||
{
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
},
|
||||
{ cause: e },
|
||||
)
|
||||
throw e
|
||||
throw new InitError({ providerID: model.providerID }, { cause: e })
|
||||
}
|
||||
}
|
||||
|
||||
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
|
||||
InstanceState.use(state, (s) => s.providers[providerID]),
|
||||
)
|
||||
|
||||
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) {
|
||||
const available = Object.keys(s.providers)
|
||||
const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 })
|
||||
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
|
||||
}
|
||||
|
||||
const info = provider.models[modelID]
|
||||
if (!info) {
|
||||
const available = Object.keys(provider.models)
|
||||
const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 })
|
||||
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
|
||||
}
|
||||
return info
|
||||
})
|
||||
})
|
||||
|
||||
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) return undefined
|
||||
for (const item of query) {
|
||||
for (const modelID of Object.keys(provider.models)) {
|
||||
if (modelID.includes(item)) return { providerID, modelID }
|
||||
const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const key = `${model.providerID}/${model.id}`
|
||||
if (s.models.has(key)) return s.models.get(key)!
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const url = e2eURL()
|
||||
if (url) {
|
||||
const language = createOpenAICompatible({
|
||||
name: model.providerID,
|
||||
apiKey: "test-key",
|
||||
baseURL: url,
|
||||
}).chatModel(model.api.id)
|
||||
s.models.set(key, language)
|
||||
return language
|
||||
}
|
||||
|
||||
const provider = s.providers[model.providerID]
|
||||
const sdk = await resolveSDK(model, s)
|
||||
|
||||
try {
|
||||
const language = s.modelLoaders[model.providerID]
|
||||
? await s.modelLoaders[model.providerID](sdk, model.api.id, {
|
||||
...provider.options,
|
||||
...model.options,
|
||||
})
|
||||
: sdk.languageModel(model.api.id)
|
||||
s.models.set(key, language)
|
||||
return language
|
||||
} catch (e) {
|
||||
if (e instanceof NoSuchModelError)
|
||||
throw new ModelNotFoundError(
|
||||
{
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
},
|
||||
{ cause: e },
|
||||
)
|
||||
throw e
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) return undefined
|
||||
for (const item of query) {
|
||||
for (const modelID of Object.keys(provider.models)) {
|
||||
if (modelID.includes(item)) return { providerID, modelID }
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) {
|
||||
const cfg = yield* config.get()
|
||||
const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) {
|
||||
const cfg = yield* config.get()
|
||||
|
||||
if (cfg.small_model) {
|
||||
const parsed = parseModel(cfg.small_model)
|
||||
return yield* getModel(parsed.providerID, parsed.modelID)
|
||||
}
|
||||
if (cfg.small_model) {
|
||||
const parsed = parseModel(cfg.small_model)
|
||||
return yield* getModel(parsed.providerID, parsed.modelID)
|
||||
}
|
||||
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) return undefined
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) return undefined
|
||||
|
||||
let priority = [
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-4.5",
|
||||
"3-5-haiku",
|
||||
"3.5-haiku",
|
||||
"gemini-3-flash",
|
||||
"gemini-2.5-flash",
|
||||
"gpt-5-nano",
|
||||
]
|
||||
if (providerID.startsWith("opencode")) {
|
||||
priority = ["gpt-5-nano"]
|
||||
}
|
||||
if (providerID.startsWith("github-copilot")) {
|
||||
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
|
||||
}
|
||||
for (const item of priority) {
|
||||
if (providerID === ProviderID.amazonBedrock) {
|
||||
const crossRegionPrefixes = ["global.", "us.", "eu."]
|
||||
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
|
||||
let priority = [
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-4.5",
|
||||
"3-5-haiku",
|
||||
"3.5-haiku",
|
||||
"gemini-3-flash",
|
||||
"gemini-2.5-flash",
|
||||
"gpt-5-nano",
|
||||
]
|
||||
if (providerID.startsWith("opencode")) {
|
||||
priority = ["gpt-5-nano"]
|
||||
}
|
||||
if (providerID.startsWith("github-copilot")) {
|
||||
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
|
||||
}
|
||||
for (const item of priority) {
|
||||
if (providerID === ProviderID.amazonBedrock) {
|
||||
const crossRegionPrefixes = ["global.", "us.", "eu."]
|
||||
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
|
||||
|
||||
const globalMatch = candidates.find((m) => m.startsWith("global."))
|
||||
if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch))
|
||||
const globalMatch = candidates.find((m) => m.startsWith("global."))
|
||||
if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch))
|
||||
|
||||
const region = provider.options?.region
|
||||
if (region) {
|
||||
const regionPrefix = region.split("-")[0]
|
||||
if (regionPrefix === "us" || regionPrefix === "eu") {
|
||||
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
|
||||
if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch))
|
||||
const region = provider.options?.region
|
||||
if (region) {
|
||||
const regionPrefix = region.split("-")[0]
|
||||
if (regionPrefix === "us" || regionPrefix === "eu") {
|
||||
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
|
||||
if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch))
|
||||
}
|
||||
}
|
||||
|
||||
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
|
||||
if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed))
|
||||
} else {
|
||||
for (const model of Object.keys(provider.models)) {
|
||||
if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model))
|
||||
}
|
||||
}
|
||||
|
||||
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
|
||||
if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed))
|
||||
} else {
|
||||
for (const model of Object.keys(provider.models)) {
|
||||
if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
const defaultModel = Effect.fn("Provider.defaultModel")(function* () {
|
||||
const cfg = yield* config.get()
|
||||
if (cfg.model) return parseModel(cfg.model)
|
||||
const defaultModel = Effect.fn("Provider.defaultModel")(function* () {
|
||||
const cfg = yield* config.get()
|
||||
if (cfg.model) return parseModel(cfg.model)
|
||||
|
||||
const s = yield* InstanceState.get(state)
|
||||
const recent = yield* Effect.promise(() =>
|
||||
Filesystem.readJson<{
|
||||
recent?: { providerID: ProviderID; modelID: ModelID }[]
|
||||
}>(path.join(Global.Path.state, "model.json"))
|
||||
.then((x): { providerID: ProviderID; modelID: ModelID }[] => (Array.isArray(x.recent) ? x.recent : []))
|
||||
.catch((): { providerID: ProviderID; modelID: ModelID }[] => []),
|
||||
)
|
||||
for (const entry of recent) {
|
||||
const provider = s.providers[entry.providerID]
|
||||
if (!provider) continue
|
||||
if (!provider.models[entry.modelID]) continue
|
||||
return { providerID: entry.providerID, modelID: entry.modelID }
|
||||
}
|
||||
const s = yield* InstanceState.get(state)
|
||||
const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
|
||||
Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
|
||||
if (!isRecord(x) || !Array.isArray(x.recent)) return []
|
||||
return x.recent.flatMap((item) => {
|
||||
if (!isRecord(item)) return []
|
||||
if (typeof item.providerID !== "string") return []
|
||||
if (typeof item.modelID !== "string") return []
|
||||
return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
|
||||
})
|
||||
}),
|
||||
Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
|
||||
)
|
||||
for (const entry of recent) {
|
||||
const provider = s.providers[entry.providerID]
|
||||
if (!provider) continue
|
||||
if (!provider.models[entry.modelID]) continue
|
||||
return { providerID: entry.providerID, modelID: entry.modelID }
|
||||
}
|
||||
|
||||
const provider = Object.values(s.providers).find(
|
||||
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id),
|
||||
)
|
||||
if (!provider) throw new Error("no providers found")
|
||||
const [model] = sort(Object.values(provider.models))
|
||||
if (!model) throw new Error("no models found")
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
}
|
||||
})
|
||||
const provider = Object.values(s.providers).find(
|
||||
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id),
|
||||
)
|
||||
if (!provider) throw new Error("no providers found")
|
||||
const [model] = sort(Object.values(provider.models))
|
||||
if (!model) throw new Error("no models found")
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel })
|
||||
}),
|
||||
)
|
||||
return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
@@ -32,6 +32,9 @@ const emptyAuth = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
})
|
||||
|
||||
const installDeps = (dir: string) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.installDependencies(dir)).pipe(Effect.provide(Config.defaultLayer)))
|
||||
|
||||
// Get managed config directory from environment (set in preload.ts)
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
|
||||
@@ -822,61 +825,54 @@ test("dedupes concurrent config dependency installs for the same dir", async ()
|
||||
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 ready = Deferred.makeUnsafe<void>()
|
||||
const hold = Deferred.makeUnsafe<void>()
|
||||
const targetDir = path.normalize(dir)
|
||||
const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
|
||||
const hit = path.normalize(d) === path.normalize(targetDir)
|
||||
if (hit) {
|
||||
calls += 1
|
||||
start()
|
||||
await gate
|
||||
}
|
||||
if (path.normalize(d) !== targetDir) return
|
||||
calls += 1
|
||||
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" }),
|
||||
)
|
||||
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 = () => {}
|
||||
},
|
||||
})
|
||||
await waiting
|
||||
done()
|
||||
await Promise.all([first, second])
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const first = yield* Effect.promise(() => installDeps(dir)).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(ready)
|
||||
|
||||
let done = false
|
||||
const second = yield* Effect.promise(() => installDeps(dir)).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
done = true
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
yield* Effect.sleep("50 millis")
|
||||
expect(done).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(hold, void 0)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
} finally {
|
||||
online.mockRestore()
|
||||
run.mockRestore()
|
||||
}
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(ticks.length).toBeGreaterThan(0)
|
||||
expect(calls).toBe(1)
|
||||
expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -892,14 +888,8 @@ test("serializes config dependency installs across dirs", async () => {
|
||||
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
|
||||
})
|
||||
const ready = Deferred.makeUnsafe<void>()
|
||||
const hold = Deferred.makeUnsafe<void>()
|
||||
|
||||
const online = spyOn(Network, "online").mockReturnValue(false)
|
||||
const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
|
||||
@@ -910,8 +900,8 @@ test("serializes config dependency installs across dirs", async () => {
|
||||
open += 1
|
||||
peak = Math.max(peak, open)
|
||||
if (calls === 1) {
|
||||
start()
|
||||
await gate
|
||||
Deferred.doneUnsafe(ready, Effect.void)
|
||||
await Effect.runPromise(Deferred.await(hold))
|
||||
}
|
||||
}
|
||||
const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
|
||||
@@ -926,11 +916,20 @@ test("serializes config dependency installs across dirs", async () => {
|
||||
})
|
||||
|
||||
try {
|
||||
const first = Config.installDependencies(a)
|
||||
await ready
|
||||
const second = Config.installDependencies(b)
|
||||
done()
|
||||
await Promise.all([first, second])
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const first = yield* Effect.promise(() => installDeps(a)).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(ready)
|
||||
|
||||
const second = yield* Effect.promise(() => installDeps(b)).pipe(Effect.forkScoped)
|
||||
yield* Effect.sleep("50 millis")
|
||||
expect(peak).toBe(1)
|
||||
|
||||
yield* Deferred.succeed(hold, void 0)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
} finally {
|
||||
online.mockRestore()
|
||||
run.mockRestore()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user