refactor(core): consolidate provider helpers

This commit is contained in:
Shoubhit Dash 2026-06-29 19:50:12 +05:30
commit 864f16c703
6 changed files with 152 additions and 137 deletions

View file

@ -8,7 +8,6 @@ import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Integration } from "./integration"
import { ProviderOverlay } from "./provider-overlay"
export type ProviderRecord = {
provider: ProviderV2.MutableInfo
@ -79,9 +78,9 @@ export const layer = Layer.effect(
return ModelV2.Info.make({
...model,
package: model.package ?? provider.package,
settings: ProviderOverlay.merge(provider.settings, model.settings),
headers: ProviderOverlay.headers(provider.headers, model.headers),
body: ProviderOverlay.merge(provider.body, model.body),
settings: ProviderV2.mergeOverlay(provider.settings, model.settings),
headers: ProviderV2.mergeHeaders(provider.headers, model.headers),
body: ProviderV2.mergeOverlay(provider.body, model.body),
})
}

View file

@ -4,7 +4,6 @@ import { define } from "../../plugin/internal"
import { Effect } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
import { ProviderOverlay } from "../../provider-overlay"
import { ProviderV2 } from "../../provider"
export const Plugin = define({
@ -55,9 +54,9 @@ export const Plugin = define({
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.settings !== undefined)
provider.settings = ProviderOverlay.merge(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = ProviderOverlay.headers(provider.headers, item.headers)
if (item.body !== undefined) provider.body = ProviderOverlay.merge(provider.body, item.body)
provider.settings = ProviderV2.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = ProviderV2.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = ProviderV2.mergeOverlay(provider.body, item.body)
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
@ -66,9 +65,9 @@ export const Plugin = define({
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.package !== undefined) model.package = config.package
if (config.settings !== undefined)
model.settings = ProviderOverlay.merge(model.settings, config.settings)
if (config.headers !== undefined) model.headers = ProviderOverlay.headers(model.headers, config.headers)
if (config.body !== undefined) model.body = ProviderOverlay.merge(model.body, config.body)
model.settings = ProviderV2.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = ProviderV2.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = ProviderV2.mergeOverlay(model.body, config.body)
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
@ -87,10 +86,10 @@ export const Plugin = define({
model.variants.push(existing)
}
if (variant.settings !== undefined)
existing.settings = ProviderOverlay.merge(existing.settings, variant.settings)
existing.settings = ProviderV2.mergeOverlay(existing.settings, variant.settings)
if (variant.headers !== undefined)
existing.headers = ProviderOverlay.headers(existing.headers, variant.headers)
if (variant.body !== undefined) existing.body = ProviderOverlay.merge(existing.body, variant.body)
existing.headers = ProviderV2.mergeHeaders(existing.headers, variant.headers)
if (variant.body !== undefined) existing.body = ProviderV2.mergeOverlay(existing.body, variant.body)
}
}
if (config.cost !== undefined) {

View file

@ -1,38 +0,0 @@
export * as ProviderOverlay from "./provider-overlay"
export function merge(
base: Readonly<Record<string, unknown>> | undefined,
overlay: Readonly<Record<string, unknown>> | undefined,
): Record<string, unknown> | undefined {
if (base === undefined) return overlay && { ...overlay }
if (overlay === undefined) return { ...base }
return Object.fromEntries(
new Set([...Object.keys(base), ...Object.keys(overlay)]).values().map((key) => {
const left = base[key]
const right = overlay[key]
if (right === undefined) return [key, left]
if (plain(left) && plain(right)) return [key, merge(left, right)]
return [key, right]
}),
)
}
export function headers(
base: Readonly<Record<string, string>> | undefined,
overlay: Readonly<Record<string, string>> | undefined,
) {
return Object.fromEntries(
[...Object.entries(base ?? {}), ...Object.entries(overlay ?? {})]
.reduce((result, entry) => {
result.set(entry[0].toLowerCase(), entry)
return result
}, new Map<string, [string, string]>())
.values(),
)
}
function plain(input: unknown): input is Readonly<Record<string, unknown>> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
const prototype = Object.getPrototypeOf(input)
return prototype === Object.prototype || prototype === null
}

View file

@ -1,75 +0,0 @@
export * as ProviderPackage from "./provider-package"
import { Effect, Option, Schema } from "effect"
import { pathToFileURL } from "url"
import type { Model, ProviderPackageDefinition, ProviderPackageSettings } from "@opencode-ai/llm"
import { Npm } from "./npm"
const cache = new Map<string, Promise<unknown>>()
export class LoadError extends Schema.TaggedErrorClass<LoadError>()("ProviderPackage.LoadError", {
package: Schema.String,
cause: Schema.Defect(),
}) {}
export const load = Effect.fn("ProviderPackage.load")(function* (specifier: string) {
const npm = Option.getOrUndefined(yield* Effect.serviceOption(Npm.Service))
const resolved =
specifier.startsWith("file://") || specifier.startsWith("@opencode-ai/llm/")
? specifier
: yield* Effect.sync(() => {
try {
return import.meta.resolve(specifier)
} catch {
return undefined
}
})
if (resolved) return yield* importProviderPackage(specifier, resolved)
if (!npm) {
return yield* new LoadError({
package: specifier,
cause: new Error(`Provider package ${specifier} is not installed`),
})
}
const installed = yield* npm
.add(packageName(specifier))
.pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause })))
const entrypoint = yield* Effect.try({
try: () => import.meta.resolve(specifier, pathToFileURL(`${installed.directory}/`).href),
catch: (cause) => new LoadError({ package: specifier, cause }),
})
return yield* importProviderPackage(specifier, entrypoint)
})
const importProviderPackage = Effect.fn("ProviderPackage.import")(function* (specifier: string, entrypoint: string) {
const module = yield* Effect.tryPromise({
try: () => {
const existing = cache.get(entrypoint)
if (existing) return existing
const loaded = import(entrypoint)
cache.set(entrypoint, loaded)
return loaded
},
catch: (cause) => new LoadError({ package: specifier, cause }),
})
if (!isProviderPackage(module)) {
return yield* new LoadError({
package: specifier,
cause: new Error(`Provider package ${specifier} does not export model(id, settings)`),
})
}
return module
})
export const make = (module: ProviderPackageDefinition, modelID: string, settings: ProviderPackageSettings): Model =>
module.model(modelID, settings)
function isProviderPackage(input: unknown): input is ProviderPackageDefinition {
return typeof input === "object" && input !== null && "model" in input && typeof input.model === "function"
}
function packageName(specifier: string) {
const parts = specifier.split("/")
if (specifier.startsWith("@")) return parts.slice(0, 2).join("/")
return parts[0]
}

View file

@ -1,6 +1,10 @@
export * as ProviderV2 from "./provider"
import { Effect, Schema } from "effect"
import { pathToFileURL } from "url"
import { Provider } from "@opencode-ai/schema/provider"
import type { Model, ProviderPackageDefinition, ProviderPackageSettings } from "@opencode-ai/llm"
import { Npm } from "./npm"
import type { DeepMutable } from "./schema"
export const ID = Provider.ID
@ -12,6 +16,81 @@ export const aisdk = (packageName: string) => (isAISDK(packageName) ? packageNam
export const packageName = (packageName: string | undefined) =>
isAISDK(packageName) ? packageName!.slice(AISDK_PREFIX.length) : packageName
export class LoadError extends Schema.TaggedErrorClass<LoadError>()("ProviderV2.LoadError", {
package: Schema.String,
cause: Schema.Defect(),
}) {}
export type ProviderPackage = ProviderPackageDefinition
const packages = new Map<string, Promise<unknown>>()
export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specifier: string, npm?: Npm.Interface) {
const resolved = yield* resolvePackage(specifier)
if (resolved) return yield* importPackage(specifier, resolved)
if (!npm) {
return yield* new LoadError({
package: specifier,
cause: new Error(`Provider package ${specifier} is not installed`),
})
}
const installed = yield* npm
.add(rootPackage(specifier))
.pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause })))
const entrypoint = yield* Effect.try({
try: () => import.meta.resolve(specifier, pathToFileURL(`${installed.directory}/`).href),
catch: (cause) => new LoadError({ package: specifier, cause }),
})
return yield* importPackage(specifier, entrypoint)
})
export const makeModel = (
module: ProviderPackageDefinition,
modelID: string,
settings: ProviderPackageSettings,
): Model => module.model(modelID, settings)
export function mergeOverlay(
base: Readonly<Record<string, unknown>> | undefined,
overlay: Readonly<Record<string, unknown>> | undefined,
): Record<string, unknown> | undefined {
if (base === undefined) return overlay && { ...overlay }
if (overlay === undefined) return { ...base }
return Object.fromEntries(
new Set([...Object.keys(base), ...Object.keys(overlay)]).values().map((key) => {
const left = base[key]
const right = overlay[key]
if (right === undefined) return [key, left]
if (
typeof left === "object" &&
left !== null &&
!Array.isArray(left) &&
typeof right === "object" &&
right !== null &&
!Array.isArray(right)
)
return [
key,
mergeOverlay(left as Readonly<Record<string, unknown>>, right as Readonly<Record<string, unknown>>),
]
return [key, right]
}),
)
}
export function mergeHeaders(
base: Readonly<Record<string, string>> | undefined,
overlay: Readonly<Record<string, string>> | undefined,
) {
return Object.fromEntries(
[...Object.entries(base ?? {}), ...Object.entries(overlay ?? {})]
.reduce((result, entry) => {
result.set(entry[0].toLowerCase(), entry)
return result
}, new Map<string, [string, string]>())
.values(),
)
}
export const Request = Provider.Request
export type Request = Provider.Request
@ -19,3 +98,43 @@ export const Info = Provider.Info
export type Info = Provider.Info
export type MutableInfo = DeepMutable<Info>
const resolvePackage = (specifier: string) =>
Effect.sync(() => {
if (specifier.startsWith("file://") || specifier.startsWith("@opencode-ai/llm/")) return specifier
try {
return import.meta.resolve(specifier)
} catch {
return undefined
}
})
const importPackage = Effect.fn("ProviderV2.importPackage")(function* (specifier: string, entrypoint: string) {
const module = yield* Effect.tryPromise({
try: () => {
const existing = packages.get(entrypoint)
if (existing) return existing
const loaded = import(entrypoint)
packages.set(entrypoint, loaded)
return loaded
},
catch: (cause) => new LoadError({ package: specifier, cause }),
})
if (!isProviderPackage(module)) {
return yield* new LoadError({
package: specifier,
cause: new Error(`Provider package ${specifier} does not export model(modelID, settings)`),
})
}
return module
})
function isProviderPackage(input: unknown): input is ProviderPackageDefinition {
return typeof input === "object" && input !== null && "model" in input && typeof input.model === "function"
}
function rootPackage(specifier: string) {
const parts = specifier.split("/")
if (specifier.startsWith("@")) return parts.slice(0, 2).join("/")
return parts[0]
}

View file

@ -11,9 +11,8 @@ import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { Npm } from "../../npm"
import { ProviderV2 } from "../../provider"
import { ProviderPackage } from "../../provider-package"
import { ProviderOverlay } from "../../provider-overlay"
import { SessionSchema } from "../schema"
export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelectedError>()(
@ -119,9 +118,9 @@ const withVariant = (
return Effect.succeed(
variant
? produce(model, (draft) => {
draft.settings = ProviderOverlay.merge(draft.settings, variant.settings)
draft.headers = ProviderOverlay.headers(draft.headers, variant.headers)
draft.body = ProviderOverlay.merge(draft.body, variant.body)
draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings)
draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers)
draft.body = ProviderV2.mergeOverlay(draft.body, variant.body)
})
: model,
)
@ -130,12 +129,15 @@ const withVariant = (
export const fromCatalogModel = (
model: ModelV2.Info,
credential?: Credential.Value,
loadPackage: (
specifier: string,
) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError> = ProviderV2.loadPackage,
): Effect.Effect<Model, UnsupportedPackageError> => {
const resolved =
credential?.metadata === undefined
? model
: produce(model, (draft) => {
draft.settings = ProviderOverlay.merge(draft.settings, credential.metadata)
draft.settings = ProviderV2.mergeOverlay(draft.settings, credential.metadata)
})
const key = apiKey(resolved, credential)
const packageName = ProviderV2.packageName(resolved.package)
@ -167,7 +169,7 @@ export const fromCatalogModel = (
if (!ProviderV2.isAISDK(resolved.package) && resolved.package) {
const specifier = resolved.package
return Effect.gen(function* () {
const module = yield* ProviderPackage.load(specifier).pipe(
const module = yield* loadPackage(specifier).pipe(
Effect.mapError(
() =>
new UnsupportedPackageError({
@ -187,7 +189,7 @@ export const fromCatalogModel = (
limits: { context: resolved.limit.context, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => ProviderPackage.make(module, resolved.modelID ?? resolved.id, settings),
try: () => ProviderV2.makeModel(module, resolved.modelID ?? resolved.id, settings),
catch: () =>
new UnsupportedPackageError({
providerID: resolved.providerID,
@ -206,8 +208,15 @@ export const fromCatalogModel = (
)
}
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) =>
withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential)))
export const resolve = (
session: SessionSchema.Info,
model: ModelV2.Info,
credential?: Credential.Value,
loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>,
) =>
withVariant(model, session.model?.variant).pipe(
Effect.flatMap((model) => fromCatalogModel(model, credential, loadPackage)),
)
export const supported = (model: ModelV2.Info) =>
Boolean(model.package) &&
@ -223,6 +232,7 @@ export const locationLayer = Layer.effect(
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
@ -248,6 +258,7 @@ export const locationLayer = Layer.effect(
session,
selected,
connection ? yield* integrations.connection.resolve(connection) : undefined,
(specifier) => ProviderV2.loadPackage(specifier, npm),
)
}),
})