From 864f16c703e83bc17b13b5618286f749a2f8d1de Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 29 Jun 2026 19:50:12 +0530 Subject: [PATCH] refactor(core): consolidate provider helpers --- packages/core/src/catalog.ts | 7 +- packages/core/src/config/plugin/provider.ts | 19 ++-- packages/core/src/provider-overlay.ts | 38 ------- packages/core/src/provider-package.ts | 75 ------------ packages/core/src/provider.ts | 119 ++++++++++++++++++++ packages/core/src/session/runner/model.ts | 31 +++-- 6 files changed, 152 insertions(+), 137 deletions(-) delete mode 100644 packages/core/src/provider-overlay.ts delete mode 100644 packages/core/src/provider-package.ts diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index a9e01c66e6..5db968a22c 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -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), }) } diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 4dd4e064f2..8c1811ff8e 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -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) { diff --git a/packages/core/src/provider-overlay.ts b/packages/core/src/provider-overlay.ts deleted file mode 100644 index f5f7aca8e8..0000000000 --- a/packages/core/src/provider-overlay.ts +++ /dev/null @@ -1,38 +0,0 @@ -export * as ProviderOverlay from "./provider-overlay" - -export function merge( - base: Readonly> | undefined, - overlay: Readonly> | undefined, -): Record | 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> | undefined, - overlay: Readonly> | undefined, -) { - return Object.fromEntries( - [...Object.entries(base ?? {}), ...Object.entries(overlay ?? {})] - .reduce((result, entry) => { - result.set(entry[0].toLowerCase(), entry) - return result - }, new Map()) - .values(), - ) -} - -function plain(input: unknown): input is Readonly> { - if (typeof input !== "object" || input === null || Array.isArray(input)) return false - const prototype = Object.getPrototypeOf(input) - return prototype === Object.prototype || prototype === null -} diff --git a/packages/core/src/provider-package.ts b/packages/core/src/provider-package.ts deleted file mode 100644 index 559caf0d0f..0000000000 --- a/packages/core/src/provider-package.ts +++ /dev/null @@ -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>() - -export class LoadError extends Schema.TaggedErrorClass()("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] -} diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 3246c8d36e..d7a48f0e8d 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -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()("ProviderV2.LoadError", { + package: Schema.String, + cause: Schema.Defect(), +}) {} +export type ProviderPackage = ProviderPackageDefinition + +const packages = new Map>() + +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> | undefined, + overlay: Readonly> | undefined, +): Record | 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>, right as Readonly>), + ] + return [key, right] + }), + ) +} + +export function mergeHeaders( + base: Readonly> | undefined, + overlay: Readonly> | undefined, +) { + return Object.fromEntries( + [...Object.entries(base ?? {}), ...Object.entries(overlay ?? {})] + .reduce((result, entry) => { + result.set(entry[0].toLowerCase(), entry) + return result + }, new Map()) + .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 + +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] +} diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 464bab36a6..4b3045182e 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -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()( @@ -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.loadPackage, ): Effect.Effect => { 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, +) => + 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), ) }), })