refactor(provider): effectify Provider service (#20160)
This commit is contained in:
@@ -210,15 +210,13 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
|||||||
- [x] `Vcs` — `project/vcs.ts`
|
- [x] `Vcs` — `project/vcs.ts`
|
||||||
- [x] `Worktree` — `worktree/index.ts`
|
- [x] `Worktree` — `worktree/index.ts`
|
||||||
|
|
||||||
Still open and likely worth migrating:
|
|
||||||
|
|
||||||
- [x] `Session` — `session/index.ts`
|
- [x] `Session` — `session/index.ts`
|
||||||
- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433)
|
- [x] `SessionProcessor` — `session/processor.ts`
|
||||||
- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433)
|
- [x] `SessionPrompt` — `session/prompt.ts`
|
||||||
- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433)
|
- [x] `SessionCompaction` — `session/compaction.ts`
|
||||||
- [ ] `Provider` — blocked by AI SDK v6 PR (#18433)
|
- [x] `Provider` — `provider/provider.ts`
|
||||||
|
|
||||||
Other services not yet migrated:
|
Still open:
|
||||||
|
|
||||||
- [ ] `SessionSummary` — `session/summary.ts`
|
- [ ] `SessionSummary` — `session/summary.ts`
|
||||||
- [ ] `SessionTodo` — `session/todo.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
|
1. Migrate each tool to return Effects
|
||||||
2. Update `Tool.define()` factory to work with 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:
|
Individual tools, ordered by value:
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { iife } from "@/util/iife"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Filesystem } from "../util/filesystem"
|
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
|
// Direct imports for bundled providers
|
||||||
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
||||||
@@ -857,6 +860,29 @@ export namespace Provider {
|
|||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
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 {
|
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
|
||||||
const m: Model = {
|
const m: Model = {
|
||||||
id: ModelID.make(model.id),
|
id: ModelID.make(model.id),
|
||||||
@@ -935,14 +961,21 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
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")
|
using _ = log.time("state")
|
||||||
const config = await Config.get()
|
const cfg = yield* config.get()
|
||||||
const modelsDev = await ModelsDev.get()
|
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
|
||||||
const database = mapValues(modelsDev, fromModelsDevProvider)
|
const database = mapValues(modelsDev, fromModelsDevProvider)
|
||||||
|
|
||||||
const disabled = new Set(config.disabled_providers ?? [])
|
const disabled = new Set(cfg.disabled_providers ?? [])
|
||||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
|
const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null
|
||||||
|
|
||||||
function isProviderAllowed(providerID: ProviderID): boolean {
|
function isProviderAllowed(providerID: ProviderID): boolean {
|
||||||
if (enabled && !enabled.has(providerID)) return false
|
if (enabled && !enabled.has(providerID)) return false
|
||||||
@@ -965,7 +998,7 @@ export namespace Provider {
|
|||||||
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
|
|
||||||
const configProviders = Object.entries(config.provider ?? {})
|
const configProviders = Object.entries(cfg.provider ?? {})
|
||||||
|
|
||||||
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
|
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
|
||||||
const existing = providers[providerID]
|
const existing = providers[providerID]
|
||||||
@@ -1021,16 +1054,22 @@ export namespace Provider {
|
|||||||
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
|
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
|
||||||
input: {
|
input: {
|
||||||
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
|
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
|
||||||
audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
|
audio:
|
||||||
image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
|
model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
|
||||||
video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? 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,
|
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
|
text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
|
||||||
audio: model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
|
audio:
|
||||||
image: model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
|
model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
|
||||||
video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? 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,
|
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
|
||||||
},
|
},
|
||||||
interleaved: model.interleaved ?? false,
|
interleaved: model.interleaved ?? false,
|
||||||
@@ -1077,7 +1116,8 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// load apikeys
|
// load apikeys
|
||||||
for (const [id, provider] of Object.entries(await Auth.all())) {
|
const auths = yield* auth.all().pipe(Effect.orDie)
|
||||||
|
for (const [id, provider] of Object.entries(auths)) {
|
||||||
const providerID = ProviderID.make(id)
|
const providerID = ProviderID.make(id)
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
if (provider.type === "api") {
|
if (provider.type === "api") {
|
||||||
@@ -1088,22 +1128,23 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const plugin of await Plugin.list()) {
|
const plugins = yield* Effect.promise(() => Plugin.list())
|
||||||
|
for (const plugin of plugins) {
|
||||||
if (!plugin.auth) continue
|
if (!plugin.auth) continue
|
||||||
const providerID = ProviderID.make(plugin.auth.provider)
|
const providerID = ProviderID.make(plugin.auth.provider)
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
|
|
||||||
const auth = await Auth.get(providerID)
|
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
|
||||||
if (!auth) continue
|
if (!pluginAuth) continue
|
||||||
if (!plugin.auth.loader) continue
|
if (!plugin.auth.loader) continue
|
||||||
|
|
||||||
if (auth) {
|
const options = yield* Effect.promise(() =>
|
||||||
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
|
plugin.auth!.loader!(() => Auth.get(providerID) as any, database[plugin.auth!.provider]),
|
||||||
|
)
|
||||||
const opts = options ?? {}
|
const opts = options ?? {}
|
||||||
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
||||||
mergeProvider(providerID, patch)
|
mergeProvider(providerID, patch)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
|
for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||||
const providerID = ProviderID.make(id)
|
const providerID = ProviderID.make(id)
|
||||||
@@ -1113,13 +1154,15 @@ export namespace Provider {
|
|||||||
log.error("Provider does not exist in model list " + providerID)
|
log.error("Provider does not exist in model list " + providerID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const result = await fn(data)
|
const result = yield* Effect.promise(() => fn(data))
|
||||||
if (result && (result.autoload || providers[providerID])) {
|
if (result && (result.autoload || providers[providerID])) {
|
||||||
if (result.getModel) modelLoaders[providerID] = result.getModel
|
if (result.getModel) modelLoaders[providerID] = result.getModel
|
||||||
if (result.vars) varsLoaders[providerID] = result.vars
|
if (result.vars) varsLoaders[providerID] = result.vars
|
||||||
if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
|
if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
|
||||||
const opts = result.options ?? {}
|
const opts = result.options ?? {}
|
||||||
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
const patch: Partial<Info> = providers[providerID]
|
||||||
|
? { options: opts }
|
||||||
|
: { source: "custom", options: opts }
|
||||||
mergeProvider(providerID, patch)
|
mergeProvider(providerID, patch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1141,7 +1184,7 @@ export namespace Provider {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const configProvider = config.provider?.[providerID]
|
const configProvider = cfg.provider?.[providerID]
|
||||||
|
|
||||||
for (const [modelID, model] of Object.entries(provider.models)) {
|
for (const [modelID, model] of Object.entries(provider.models)) {
|
||||||
model.api.id = model.api.id ?? model.id ?? modelID
|
model.api.id = model.api.id ?? model.id ?? modelID
|
||||||
@@ -1150,7 +1193,8 @@ export namespace Provider {
|
|||||||
(providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
|
(providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
|
||||||
)
|
)
|
||||||
delete provider.models[modelID]
|
delete provider.models[modelID]
|
||||||
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) 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 (model.status === "deprecated") delete provider.models[modelID]
|
||||||
if (
|
if (
|
||||||
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
|
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
|
||||||
@@ -1160,7 +1204,6 @@ export namespace Provider {
|
|||||||
|
|
||||||
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
|
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
|
||||||
|
|
||||||
// Filter out disabled variants from config
|
|
||||||
const configVariants = configProvider?.models?.[modelID]?.variants
|
const configVariants = configProvider?.models?.[modelID]?.variants
|
||||||
if (configVariants && model.variants) {
|
if (configVariants && model.variants) {
|
||||||
const merged = mergeDeep(model.variants, configVariants)
|
const merged = mergeDeep(model.variants, configVariants)
|
||||||
@@ -1181,14 +1224,18 @@ export namespace Provider {
|
|||||||
|
|
||||||
const gitlab = ProviderID.make("gitlab")
|
const gitlab = ProviderID.make("gitlab")
|
||||||
if (discoveryLoaders[gitlab] && providers[gitlab]) {
|
if (discoveryLoaders[gitlab] && providers[gitlab]) {
|
||||||
await (async () => {
|
yield* Effect.promise(async () => {
|
||||||
|
try {
|
||||||
const discovered = await discoveryLoaders[gitlab]()
|
const discovered = await discoveryLoaders[gitlab]()
|
||||||
for (const [modelID, model] of Object.entries(discovered)) {
|
for (const [modelID, model] of Object.entries(discovered)) {
|
||||||
if (!providers[gitlab].models[modelID]) {
|
if (!providers[gitlab].models[modelID]) {
|
||||||
providers[gitlab].models[modelID] = model
|
providers[gitlab].models[modelID] = model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})().catch((e) => log.warn("state discovery error", { id: "gitlab", error: e }))
|
} catch (e) {
|
||||||
|
log.warn("state discovery error", { id: "gitlab", error: e })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1198,18 +1245,16 @@ export namespace Provider {
|
|||||||
modelLoaders,
|
modelLoaders,
|
||||||
varsLoaders,
|
varsLoaders,
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export async function list() {
|
const list = Effect.fn("Provider.list")(() => InstanceState.use(cache, (s) => s.providers))
|
||||||
return state().then((state) => state.providers)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSDK(model: Model) {
|
async function resolveSDK(model: Model, s: State) {
|
||||||
try {
|
try {
|
||||||
using _ = log.time("getSDK", {
|
using _ = log.time("getSDK", {
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
})
|
})
|
||||||
const s = await state()
|
|
||||||
const provider = s.providers[model.providerID]
|
const provider = s.providers[model.providerID]
|
||||||
const options = { ...provider.options }
|
const options = { ...provider.options }
|
||||||
|
|
||||||
@@ -1226,9 +1271,6 @@ export namespace Provider {
|
|||||||
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
|
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
|
||||||
if (!url) return
|
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]
|
const loader = s.varsLoaders[model.providerID]
|
||||||
if (loader) {
|
if (loader) {
|
||||||
const vars = loader(options)
|
const vars = loader(options)
|
||||||
@@ -1268,10 +1310,10 @@ export namespace Provider {
|
|||||||
delete options["chunkTimeout"]
|
delete options["chunkTimeout"]
|
||||||
|
|
||||||
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
||||||
// Preserve custom fetch if it exists, wrap it with timeout logic
|
|
||||||
const fetchFn = customFetch ?? fetch
|
const fetchFn = customFetch ?? fetch
|
||||||
const opts = init ?? {}
|
const opts = init ?? {}
|
||||||
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
|
const chunkAbortCtl =
|
||||||
|
typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
|
||||||
const signals: AbortSignal[] = []
|
const signals: AbortSignal[] = []
|
||||||
|
|
||||||
if (opts.signal) signals.push(opts.signal)
|
if (opts.signal) signals.push(opts.signal)
|
||||||
@@ -1279,13 +1321,11 @@ export namespace Provider {
|
|||||||
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
|
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
|
||||||
signals.push(AbortSignal.timeout(options["timeout"]))
|
signals.push(AbortSignal.timeout(options["timeout"]))
|
||||||
|
|
||||||
const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
|
const combined =
|
||||||
|
signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
|
||||||
if (combined) opts.signal = combined
|
if (combined) opts.signal = combined
|
||||||
|
|
||||||
// Strip openai itemId metadata following what codex does
|
// 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") {
|
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
|
||||||
const body = JSON.parse(opts.body as string)
|
const body = JSON.parse(opts.body as string)
|
||||||
const isAzure = model.providerID.includes("azure")
|
const isAzure = model.providerID.includes("azure")
|
||||||
@@ -1346,43 +1386,36 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProvider(providerID: ProviderID) {
|
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
|
||||||
return state().then((s) => s.providers[providerID])
|
InstanceState.use(cache, (s) => s.providers[providerID]),
|
||||||
}
|
)
|
||||||
|
|
||||||
export async function getModel(providerID: ProviderID, modelID: ModelID) {
|
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
|
||||||
const s = await state()
|
const s = yield* InstanceState.get(cache)
|
||||||
const provider = s.providers[providerID]
|
const provider = s.providers[providerID]
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
const availableProviders = Object.keys(s.providers)
|
const available = Object.keys(s.providers)
|
||||||
const matches = fuzzysort.go(providerID, availableProviders, {
|
const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 })
|
||||||
limit: 3,
|
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
|
||||||
threshold: -10000,
|
|
||||||
})
|
|
||||||
const suggestions = matches.map((m) => m.target)
|
|
||||||
throw new ModelNotFoundError({ providerID, modelID, suggestions })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = provider.models[modelID]
|
const info = provider.models[modelID]
|
||||||
if (!info) {
|
if (!info) {
|
||||||
const availableModels = Object.keys(provider.models)
|
const available = Object.keys(provider.models)
|
||||||
const matches = fuzzysort.go(modelID, availableModels, {
|
const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 })
|
||||||
limit: 3,
|
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
|
||||||
threshold: -10000,
|
|
||||||
})
|
|
||||||
const suggestions = matches.map((m) => m.target)
|
|
||||||
throw new ModelNotFoundError({ providerID, modelID, suggestions })
|
|
||||||
}
|
}
|
||||||
return info
|
return info
|
||||||
}
|
})
|
||||||
|
|
||||||
export async function getLanguage(model: Model): Promise<LanguageModelV3> {
|
const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
|
||||||
const s = await state()
|
const s = yield* InstanceState.get(cache)
|
||||||
const key = `${model.providerID}/${model.id}`
|
const key = `${model.providerID}/${model.id}`
|
||||||
if (s.models.has(key)) return s.models.get(key)!
|
if (s.models.has(key)) return s.models.get(key)!
|
||||||
|
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
const provider = s.providers[model.providerID]
|
const provider = s.providers[model.providerID]
|
||||||
const sdk = await getSDK(model)
|
const sdk = await resolveSDK(model, s)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const language = s.modelLoaders[model.providerID]
|
const language = s.modelLoaders[model.providerID]
|
||||||
@@ -1404,33 +1437,33 @@ export namespace Provider {
|
|||||||
)
|
)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
})
|
||||||
|
|
||||||
export async function closest(providerID: ProviderID, query: string[]) {
|
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
|
||||||
const s = await state()
|
const s = yield* InstanceState.get(cache)
|
||||||
const provider = s.providers[providerID]
|
const provider = s.providers[providerID]
|
||||||
if (!provider) return undefined
|
if (!provider) return undefined
|
||||||
for (const item of query) {
|
for (const item of query) {
|
||||||
for (const modelID of Object.keys(provider.models)) {
|
for (const modelID of Object.keys(provider.models)) {
|
||||||
if (modelID.includes(item))
|
if (modelID.includes(item)) return { providerID, modelID }
|
||||||
return {
|
|
||||||
providerID,
|
|
||||||
modelID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
export async function getSmallModel(providerID: ProviderID) {
|
const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) {
|
||||||
const cfg = await Config.get()
|
const cfg = yield* config.get()
|
||||||
|
|
||||||
if (cfg.small_model) {
|
if (cfg.small_model) {
|
||||||
const parsed = parseModel(cfg.small_model)
|
const parsed = parseModel(cfg.small_model)
|
||||||
return getModel(parsed.providerID, parsed.modelID)
|
return yield* getModel(parsed.providerID, parsed.modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = await state().then((state) => state.providers[providerID])
|
const s = yield* InstanceState.get(cache)
|
||||||
if (provider) {
|
const provider = s.providers[providerID]
|
||||||
|
if (!provider) return undefined
|
||||||
|
|
||||||
let priority = [
|
let priority = [
|
||||||
"claude-haiku-4-5",
|
"claude-haiku-4-5",
|
||||||
"claude-haiku-4.5",
|
"claude-haiku-4.5",
|
||||||
@@ -1444,7 +1477,6 @@ export namespace Provider {
|
|||||||
priority = ["gpt-5-nano"]
|
priority = ["gpt-5-nano"]
|
||||||
}
|
}
|
||||||
if (providerID.startsWith("github-copilot")) {
|
if (providerID.startsWith("github-copilot")) {
|
||||||
// prioritize free models for github copilot
|
|
||||||
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
|
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
|
||||||
}
|
}
|
||||||
for (const item of priority) {
|
for (const item of priority) {
|
||||||
@@ -1452,33 +1484,93 @@ export namespace Provider {
|
|||||||
const crossRegionPrefixes = ["global.", "us.", "eu."]
|
const crossRegionPrefixes = ["global.", "us.", "eu."]
|
||||||
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
|
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."))
|
const globalMatch = candidates.find((m) => m.startsWith("global."))
|
||||||
if (globalMatch) return getModel(providerID, ModelID.make(globalMatch))
|
if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch))
|
||||||
|
|
||||||
const region = provider.options?.region
|
const region = provider.options?.region
|
||||||
if (region) {
|
if (region) {
|
||||||
const regionPrefix = region.split("-")[0]
|
const regionPrefix = region.split("-")[0]
|
||||||
if (regionPrefix === "us" || regionPrefix === "eu") {
|
if (regionPrefix === "us" || regionPrefix === "eu") {
|
||||||
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
|
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
|
||||||
if (regionalMatch) return getModel(providerID, ModelID.make(regionalMatch))
|
if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
|
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
|
||||||
if (unprefixed) return getModel(providerID, ModelID.make(unprefixed))
|
if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed))
|
||||||
} else {
|
} else {
|
||||||
for (const model of Object.keys(provider.models)) {
|
for (const model of Object.keys(provider.models)) {
|
||||||
if (model.includes(item)) return getModel(providerID, ModelID.make(model))
|
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 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 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 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer)))
|
||||||
|
|
||||||
|
export async function list() {
|
||||||
|
return runPromise((svc) => svc.list())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProvider(providerID: ProviderID) {
|
||||||
|
return runPromise((svc) => svc.getProvider(providerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModel(providerID: ProviderID, modelID: ModelID) {
|
||||||
|
return runPromise((svc) => svc.getModel(providerID, modelID))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLanguage(model: Model) {
|
||||||
|
return runPromise((svc) => svc.getLanguage(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closest(providerID: ProviderID, query: string[]) {
|
||||||
|
return runPromise((svc) => svc.closest(providerID, query))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSmallModel(providerID: ProviderID) {
|
||||||
|
return runPromise((svc) => svc.getSmallModel(providerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function defaultModel() {
|
||||||
|
return runPromise((svc) => svc.defaultModel())
|
||||||
}
|
}
|
||||||
|
|
||||||
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
|
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) {
|
export function parseModel(model: string) {
|
||||||
const [providerID, ...rest] = model.split("/")
|
const [providerID, ...rest] = model.split("/")
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user