summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-30 21:56:43 -0400
committerGitHub <[email protected]>2026-03-30 21:56:43 -0400
commit3df18dcde119150542f8c13487b0378fe8e3a8fe (patch)
treeff7c7465bdd8510aafc1979e07203bed75464ea8
parenta898c2ea3ad404056e015de8f37106cca7b7a4c3 (diff)
downloadopencode-3df18dcde119150542f8c13487b0378fe8e3a8fe.tar.gz
opencode-3df18dcde119150542f8c13487b0378fe8e3a8fe.zip
refactor(provider): effectify Provider service (#20160)
-rw-r--r--packages/opencode/specs/effect-migration.md14
-rw-r--r--packages/opencode/src/provider/provider.ts1085
2 files changed, 581 insertions, 518 deletions
diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md
index 38871356f..8e45491cc 100644
--- a/packages/opencode/specs/effect-migration.md
+++ b/packages/opencode/specs/effect-migration.md
@@ -210,15 +210,13 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `Vcs` — `project/vcs.ts`
- [x] `Worktree` — `worktree/index.ts`
-Still open and likely worth migrating:
-
- [x] `Session` — `session/index.ts`
-- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433)
-- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433)
-- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433)
-- [ ] `Provider` — blocked by AI SDK v6 PR (#18433)
+- [x] `SessionProcessor` — `session/processor.ts`
+- [x] `SessionPrompt` — `session/prompt.ts`
+- [x] `SessionCompaction` — `session/compaction.ts`
+- [x] `Provider` — `provider/provider.ts`
-Other services not yet migrated:
+Still open:
- [ ] `SessionSummary` — `session/summary.ts`
- [ ] `SessionTodo` — `session/todo.ts`
@@ -235,7 +233,7 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
1. Migrate each tool to return Effects
2. Update `Tool.define()` factory to work with Effects
-3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
+3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
Individual tools, ordered by value:
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 7fb316628..54fcede79 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -19,6 +19,9 @@ import { iife } from "@/util/iife"
import { Global } from "../global"
import path from "path"
import { Filesystem } from "../util/filesystem"
+import { Effect, Layer, ServiceMap } from "effect"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRuntime } from "@/effect/run-service"
// Direct imports for bundled providers
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
@@ -857,6 +860,29 @@ export namespace Provider {
})
export type Info = z.infer<typeof Info>
+ export interface Interface {
+ readonly list: () => Effect.Effect<Record<ProviderID, Info>>
+ readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
+ readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model>
+ readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3>
+ readonly closest: (
+ providerID: ProviderID,
+ query: string[],
+ ) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined>
+ readonly getSmallModel: (providerID: ProviderID) => Effect.Effect<Model | undefined>
+ readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }>
+ }
+
+ interface State {
+ models: Map<string, LanguageModelV3>
+ providers: Record<ProviderID, Info>
+ sdk: Map<string, BundledSDK>
+ modelLoaders: Record<string, CustomModelLoader>
+ varsLoaders: Record<string, CustomVarsLoader>
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Provider") {}
+
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
const m: Model = {
id: ModelID.make(model.id),
@@ -935,550 +961,616 @@ export namespace Provider {
}
}
- const state = Instance.state(async () => {
- using _ = log.time("state")
- const config = await Config.get()
- const modelsDev = await ModelsDev.get()
- const database = mapValues(modelsDev, fromModelsDevProvider)
+ const layer: Layer.Layer<Service, never, Config.Service | Auth.Service> = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const config = yield* Config.Service
+ const auth = yield* Auth.Service
+
+ const cache = 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 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
+ }
- const disabled = new Set(config.disabled_providers ?? [])
- const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
+ 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
+ } = {}
+
+ log.info("init")
+
+ const configProviders = Object.entries(cfg.provider ?? {})
+
+ 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)
+ }
- 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
- } = {}
-
- log.info("init")
-
- const configProviders = Object.entries(config.provider ?? {})
-
- 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)
- }
+ 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,
+ 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
+ }
- // extend database from config
- for (const [providerID, provider] of configProviders) {
- const existing = database[providerID]
- const parsed: Info = {
- id: ProviderID.make(providerID),
- name: provider.name ?? existing?.name ?? providerID,
- env: provider.env ?? existing?.env ?? [],
- options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
- source: "config",
- models: existing?.models ?? {},
- }
+ // load 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,
+ })
+ }
- 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,
- 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 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,
+ })
+ }
+ }
- // 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,
- })
- }
+ const plugins = yield* Effect.promise(() => Plugin.list())
+ for (const plugin of plugins) {
+ if (!plugin.auth) continue
+ const providerID = ProviderID.make(plugin.auth.provider)
+ if (disabled.has(providerID)) continue
- // load apikeys
- for (const [id, provider] of Object.entries(await Auth.all())) {
- const providerID = ProviderID.make(id)
- if (disabled.has(providerID)) continue
- if (provider.type === "api") {
- mergeProvider(providerID, {
- source: "api",
- key: provider.key,
- })
- }
- }
+ const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
+ if (!pluginAuth) continue
+ if (!plugin.auth.loader) continue
- for (const plugin of await Plugin.list()) {
- if (!plugin.auth) continue
- const providerID = ProviderID.make(plugin.auth.provider)
- if (disabled.has(providerID)) continue
+ const options = yield* Effect.promise(() =>
+ plugin.auth!.loader!(() => Auth.get(providerID) as any, database[plugin.auth!.provider]),
+ )
+ const opts = options ?? {}
+ const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
+ mergeProvider(providerID, patch)
+ }
- const auth = await Auth.get(providerID)
- if (!auth) continue
- if (!plugin.auth.loader) continue
+ for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
+ 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* Effect.promise(() => 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)
+ }
+ }
- if (auth) {
- const options = await plugin.auth.loader(() => Auth.get(providerID) 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
+ 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 [id, fn] of Object.entries(CUSTOM_LOADERS)) {
- 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 = await 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, provider] of Object.entries(providers)) {
+ const providerID = ProviderID.make(id)
+ if (!isProviderAllowed(providerID)) {
+ delete providers[providerID]
+ continue
+ }
- // load config
- 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 configProvider = cfg.provider?.[providerID]
- for (const [id, provider] of Object.entries(providers)) {
- const providerID = ProviderID.make(id)
- if (!isProviderAllowed(providerID)) {
- 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]
- const configProvider = config.provider?.[providerID]
+ model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
- 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 configVariants = configProvider?.models?.[modelID]?.variants
+ if (configVariants && model.variants) {
+ const merged = mergeDeep(model.variants, configVariants)
+ model.variants = mapValues(
+ pickBy(merged, (v) => !v.disabled),
+ (v) => omit(v, ["disabled"]),
+ )
+ }
+ }
+
+ if (Object.keys(provider.models).length === 0) {
+ delete providers[providerID]
+ continue
+ }
+
+ log.info("found", { providerID })
+ }
- model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
+ const gitlab = ProviderID.make("gitlab")
+ if (discoveryLoaders[gitlab] && providers[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 })
+ }
+ })
+ }
- // Filter out disabled variants from config
- 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,
+ }
+ }),
+ )
+
+ const list = Effect.fn("Provider.list")(() => InstanceState.use(cache, (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
+ }
+ }
+ opts.body = JSON.stringify(body)
+ }
+ }
- if (Object.keys(provider.models).length === 0) {
- delete providers[providerID]
- continue
- }
+ const res = await fetchFn(input, {
+ ...opts,
+ // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
+ timeout: false,
+ })
- log.info("found", { providerID })
- }
+ if (!chunkAbortCtl) return res
+ return wrapSSE(res, chunkTimeout, chunkAbortCtl)
+ }
- const gitlab = ProviderID.make("gitlab")
- if (discoveryLoaders[gitlab] && providers[gitlab]) {
- await (async () => {
- const discovered = await discoveryLoaders[gitlab]()
- for (const [modelID, model] of Object.entries(discovered)) {
- if (!providers[gitlab].models[modelID]) {
- providers[gitlab].models[modelID] = model
+ 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://")) {
+ installedPath = await BunProc.install(model.api.npm, "latest")
+ } 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 })
}
- })().catch((e) => log.warn("state discovery error", { id: "gitlab", error: e }))
- }
+ }
- return {
- models: languages,
- providers,
- sdk,
- modelLoaders,
- varsLoaders,
- }
- })
+ const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
+ InstanceState.use(cache, (s) => s.providers[providerID]),
+ )
- export async function list() {
- return state().then((state) => state.providers)
- }
+ const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
+ const s = yield* InstanceState.get(cache)
+ 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) })
+ }
- async function getSDK(model: Model) {
- try {
- using _ = log.time("getSDK", {
- providerID: model.providerID,
+ 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 s = await state()
- 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
- }
+ const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
+ const s = yield* InstanceState.get(cache)
+ const key = `${model.providerID}/${model.id}`
+ if (s.models.has(key)) return s.models.get(key)!
- if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
- options["includeUsage"] = true
- }
+ return yield* Effect.promise(async () => {
+ const provider = s.providers[model.providerID]
+ const sdk = await resolveSDK(model, s)
- const baseURL = iife(() => {
- let url =
- typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
- if (!url) return
-
- // some models/providers have variable urls, ex: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/anthropic/v1"
- // We track this in models.dev, and then when we are resolving the baseURL
- // we need to string replace that literal: "${AZURE_RESOURCE_NAME}"
- 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)
+ 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
}
- }
-
- 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 closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
+ const s = yield* InstanceState.get(cache)
+ 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 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) => {
- // Preserve custom fetch if it exists, wrap it with timeout logic
- 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
- // Codex uses #[serde(skip_serializing)] on id fields for all item types:
- // Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall
- // IDs are only re-attached for Azure with store=true
- 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 getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) {
+ const cfg = yield* config.get()
+
+ if (cfg.small_model) {
+ const parsed = parseModel(cfg.small_model)
+ return yield* getModel(parsed.providerID, parsed.modelID)
+ }
+
+ const s = yield* InstanceState.get(cache)
+ 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))
}
}
- opts.body = JSON.stringify(body)
+
+ 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 res = await fetchFn(input, {
- ...opts,
- // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
- timeout: false,
- })
+ return undefined
+ })
- if (!chunkAbortCtl) return res
- return wrapSSE(res, chunkTimeout, chunkAbortCtl)
- }
+ const defaultModel = Effect.fn("Provider.defaultModel")(function* () {
+ const cfg = yield* config.get()
+ if (cfg.model) return parseModel(cfg.model)
+
+ const s = yield* InstanceState.get(cache)
+ 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 bundledFn = BUNDLED_PROVIDERS[model.api.npm]
- if (bundledFn) {
- log.info("using bundled provider", {
- providerID: model.providerID,
- pkg: model.api.npm,
- })
- const loaded = bundledFn({
- name: model.providerID,
- ...options,
- })
- s.sdk.set(key, loaded)
- return loaded as SDK
- }
+ const 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,
+ }
+ })
- let installedPath: string
- if (!model.api.npm.startsWith("file://")) {
- installedPath = await BunProc.install(model.api.npm, "latest")
- } else {
- log.info("loading local provider", { pkg: model.api.npm })
- installedPath = model.api.npm
- }
+ return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel })
+ }),
+ )
- const mod = await import(installedPath)
+ const { runPromise } = makeRuntime(Service, layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer)))
- 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 })
- }
+ export async function list() {
+ return runPromise((svc) => svc.list())
}
export async function getProvider(providerID: ProviderID) {
- return state().then((s) => s.providers[providerID])
+ return runPromise((svc) => svc.getProvider(providerID))
}
export async function getModel(providerID: ProviderID, modelID: ModelID) {
- const s = await state()
- const provider = s.providers[providerID]
- if (!provider) {
- const availableProviders = Object.keys(s.providers)
- const matches = fuzzysort.go(providerID, availableProviders, {
- limit: 3,
- threshold: -10000,
- })
- const suggestions = matches.map((m) => m.target)
- throw new ModelNotFoundError({ providerID, modelID, suggestions })
- }
-
- const info = provider.models[modelID]
- if (!info) {
- const availableModels = Object.keys(provider.models)
- const matches = fuzzysort.go(modelID, availableModels, {
- limit: 3,
- threshold: -10000,
- })
- const suggestions = matches.map((m) => m.target)
- throw new ModelNotFoundError({ providerID, modelID, suggestions })
- }
- return info
+ return runPromise((svc) => svc.getModel(providerID, modelID))
}
- export async function getLanguage(model: Model): Promise<LanguageModelV3> {
- const s = await state()
- const key = `${model.providerID}/${model.id}`
- if (s.models.has(key)) return s.models.get(key)!
-
- const provider = s.providers[model.providerID]
- const sdk = await getSDK(model)
-
- 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
- }
+ export async function getLanguage(model: Model) {
+ return runPromise((svc) => svc.getLanguage(model))
}
export async function closest(providerID: ProviderID, query: string[]) {
- const s = await 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 runPromise((svc) => svc.closest(providerID, query))
}
export async function getSmallModel(providerID: ProviderID) {
- const cfg = await Config.get()
-
- if (cfg.small_model) {
- const parsed = parseModel(cfg.small_model)
- return getModel(parsed.providerID, parsed.modelID)
- }
-
- const provider = await state().then((state) => state.providers[providerID])
- if (provider) {
- 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")) {
- // prioritize free models for 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))
-
- // Model selection priority:
- // 1. global. prefix (works everywhere)
- // 2. User's region prefix (us., eu.)
- // 3. Unprefixed model
- const globalMatch = candidates.find((m) => m.startsWith("global."))
- if (globalMatch) return 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 getModel(providerID, ModelID.make(regionalMatch))
- }
- }
-
- const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
- if (unprefixed) return getModel(providerID, ModelID.make(unprefixed))
- } else {
- for (const model of Object.keys(provider.models)) {
- if (model.includes(item)) return getModel(providerID, ModelID.make(model))
- }
- }
- }
- }
+ return runPromise((svc) => svc.getSmallModel(providerID))
+ }
- return undefined
+ export async function defaultModel() {
+ return runPromise((svc) => svc.defaultModel())
}
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
@@ -1491,33 +1583,6 @@ export namespace Provider {
)
}
- export async function defaultModel() {
- const cfg = await Config.get()
- if (cfg.model) return parseModel(cfg.model)
-
- const providers = await list()
- const recent = (await Filesystem.readJson<{
- recent?: { providerID: ProviderID; modelID: ModelID }[]
- }>(path.join(Global.Path.state, "model.json"))
- .then((x) => (Array.isArray(x.recent) ? x.recent : []))
- .catch(() => [])) as { providerID: ProviderID; modelID: ModelID }[]
- for (const entry of recent) {
- const provider = providers[entry.providerID]
- if (!provider) continue
- if (!provider.models[entry.modelID]) continue
- return { providerID: entry.providerID, modelID: entry.modelID }
- }
-
- const provider = Object.values(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,
- }
- }
-
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {