From 506a090a2e01e62add0098f92f35afbe7faa8af8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 28 May 2026 23:32:59 -0400 Subject: [PATCH] refactor(core): add replayable catalog transforms --- packages/core/src/catalog.ts | 238 ++++++------- packages/core/src/config/plugin/provider.ts | 4 +- packages/core/src/plugin.ts | 24 +- packages/core/src/plugin/account.ts | 2 +- packages/core/src/plugin/env.ts | 2 +- packages/core/src/plugin/models-dev.ts | 4 +- .../src/plugin/provider/amazon-bedrock.ts | 2 +- .../core/src/plugin/provider/anthropic.ts | 2 +- packages/core/src/plugin/provider/azure.ts | 4 +- packages/core/src/plugin/provider/cerebras.ts | 2 +- .../plugin/provider/cloudflare-workers-ai.ts | 2 +- .../src/plugin/provider/github-copilot.ts | 2 +- .../core/src/plugin/provider/google-vertex.ts | 4 +- packages/core/src/plugin/provider/kilo.ts | 2 +- .../core/src/plugin/provider/llmgateway.ts | 2 +- packages/core/src/plugin/provider/nvidia.ts | 2 +- packages/core/src/plugin/provider/openai.ts | 2 +- packages/core/src/plugin/provider/opencode.ts | 2 +- .../core/src/plugin/provider/openrouter.ts | 2 +- packages/core/src/plugin/provider/vercel.ts | 2 +- packages/core/src/plugin/provider/zenmux.ts | 2 +- packages/core/src/transform-state.ts | 67 ++++ packages/core/test/account.test.ts | 15 +- packages/core/test/catalog.test.ts | 69 +++- .../plugin/provider-amazon-bedrock.test.ts | 4 +- .../test/plugin/provider-anthropic.test.ts | 8 +- .../provider-azure-cognitive-services.test.ts | 8 +- .../core/test/plugin/provider-azure.test.ts | 20 +- .../test/plugin/provider-cerebras.test.ts | 8 +- .../provider-cloudflare-workers-ai.test.ts | 16 +- .../plugin/provider-github-copilot.test.ts | 8 +- .../core/test/plugin/provider-gitlab.test.ts | 8 +- .../provider-google-vertex-anthropic.test.ts | 8 +- .../plugin/provider-google-vertex.test.ts | 20 +- packages/core/test/plugin/provider-helper.ts | 3 +- .../core/test/plugin/provider-kilo.test.ts | 12 +- .../test/plugin/provider-llmgateway.test.ts | 8 +- .../core/test/plugin/provider-nvidia.test.ts | 12 +- .../core/test/plugin/provider-openai.test.ts | 8 +- .../test/plugin/provider-opencode.test.ts | 36 +- .../test/plugin/provider-openrouter.test.ts | 12 +- .../core/test/plugin/provider-vercel.test.ts | 12 +- .../core/test/plugin/provider-zenmux.test.ts | 16 +- specs/v2/catalog-config-plugin-lifecycle.md | 326 ++++++++++++++++++ 44 files changed, 700 insertions(+), 312 deletions(-) create mode 100644 packages/core/src/transform-state.ts create mode 100644 specs/v2/catalog-config-plugin-lifecycle.md diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index b12db3ff9e..aafb4c12fb 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,19 +1,22 @@ export * as Catalog from "./catalog" -import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect" -import { produce, type Draft } from "immer" +import { Context, Effect, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect" +import { castDraft, enableMapSet, type Draft } from "immer" import { ModelV2 } from "./model" import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" import { Location } from "./location" import { EventV2 } from "./event" import { Policy } from "./policy" +import { TransformState } from "./transform-state" export type ProviderRecord = { provider: ProviderV2.Info models: Map } +export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } + export class ProviderNotFoundError extends Schema.TaggedErrorClass()( "CatalogV2.ProviderNotFound", { @@ -37,24 +40,31 @@ export const Event = { }), } -export type Context = { - data: readonly ProviderRecord[] - updateProvider: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => void - updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void +type State = { + providers: Map + defaultModel?: DefaultModel +} + +export type Editor = { provider: { + list: () => readonly ProviderRecord[] + get: (providerID: ProviderV2.ID) => ProviderRecord | undefined update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => void remove: (providerID: ProviderV2.ID) => void } model: { + get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void + default: { + get: () => DefaultModel | undefined + set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void + } } } -export type Loader = (update: (ctx: Context) => void) => Effect.Effect - export interface Interface { - readonly loader: () => Effect.Effect + readonly transform: () => Effect.Effect, never, Scope.Scope> readonly provider: { readonly get: (providerID: ProviderV2.ID) => Effect.Effect readonly all: () => Effect.Effect @@ -68,30 +78,26 @@ export interface Interface { readonly all: () => Effect.Effect readonly available: () => Effect.Effect readonly default: () => Effect.Effect> - readonly setDefault: ( - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - ) => Effect.Effect readonly small: (providerID: ProviderV2.ID) => Effect.Effect> } } export class Service extends Context.Service()("@opencode/v2/Catalog") {} +enableMapSet() + export const layer = Layer.effect( Service, Effect.gen(function* () { yield* Location.Service - let records = HashMap.empty() - let loaders: { update: (ctx: Context) => void }[] = [] - let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined const plugin = yield* PluginV2.Service const events = yield* EventV2.Service const policy = yield* Policy.Service const scope = yield* Scope.Scope const resolve = (model: ModelV2.Info) => { - const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider + const state = transformed.get() + const provider = state.providers.get(model.providerID)!.provider const endpoint = model.endpoint.type === "unknown" ? provider.endpoint @@ -124,9 +130,10 @@ export const layer = Layer.effect( } function* getRecord(providerID: ProviderV2.ID) { - const match = HashMap.get(records, providerID) - if (!match.valueOrUndefined) return yield* new ProviderNotFoundError({ providerID }) - return match.value + const state = transformed.get() + const match = state.providers.get(providerID) + if (!match) return yield* new ProviderNotFoundError({ providerID }) + return match } const normalizeEndpoint = (item: Draft | Draft) => { @@ -135,122 +142,79 @@ export const layer = Layer.effect( delete item.options.aisdk.provider.baseURL } - const clone = (input: HashMap.HashMap) => - HashMap.fromIterable( - HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const), - ) - - const context = (draft: { - records: HashMap.HashMap - data: ProviderRecord[] - }): Context => { - const result: Context = { - data: draft.data, - updateProvider: (providerID, fn) => result.provider.update(providerID, fn), - updateModel: (providerID, modelID, fn) => result.model.update(providerID, modelID, fn), - provider: { - update: (providerID, fn) => { - const current = Option.getOrUndefined(HashMap.get(draft.records, providerID)) - const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => { - fn(draft) - normalizeEndpoint(draft) - }) - const next = { - provider, - models: current?.models ?? new Map(), - } - draft.records = HashMap.set(draft.records, providerID, next) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index === -1) draft.data.push(next) - else draft.data[index] = next + const transformed = TransformState.create({ + initial: () => ({ providers: new Map() }), + editor: (draft) => { + const data = globalThis.Array.from(draft.providers.values()) as ProviderRecord[] + const result: Editor = { + provider: { + list: () => data, + get: (providerID) => draft.providers.get(providerID), + update: (providerID, fn) => { + let current = draft.providers.get(providerID) + if (!current) { + current = castDraft({ + provider: ProviderV2.Info.empty(providerID), + models: new Map(), + }) + draft.providers.set(providerID, current) + data.push(current) + } + fn(current.provider) + normalizeEndpoint(current.provider) + }, + remove: (providerID) => { + draft.providers.delete(providerID) + const index = data.findIndex((item) => item.provider.id === providerID) + if (index !== -1) data.splice(index, 1) + }, }, - remove: (providerID) => { - draft.records = HashMap.remove(draft.records, providerID) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index !== -1) draft.data.splice(index, 1) + model: { + get: (providerID, modelID) => draft.providers.get(providerID)?.models.get(modelID), + update: (providerID, modelID, fn) => { + result.provider.update(providerID, () => {}) + const record = draft.providers.get(providerID)! + const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID)) + if (!record.models.has(modelID)) record.models.set(modelID, model) + fn(model) + model.id = modelID + model.providerID = providerID + normalizeEndpoint(model) + }, + remove: (providerID, modelID) => { + draft.providers.get(providerID)?.models.delete(modelID) + }, + default: { + get: () => draft.defaultModel, + set: (providerID, modelID) => { + draft.defaultModel = { providerID, modelID } + }, + }, }, - }, - model: { - update: (providerID, modelID, fn) => { - const current = Option.getOrThrow(HashMap.get(draft.records, providerID)) - const model = produce(current.models.get(modelID) ?? ModelV2.Info.empty(providerID, modelID), (draft) => { - fn(draft) - normalizeEndpoint(draft) - }) - const next = { - provider: current.provider, - models: new Map(current.models).set(modelID, new ModelV2.Info({ ...model, id: modelID, providerID })), - } - draft.records = HashMap.set(draft.records, providerID, next) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index === -1) draft.data.push(next) - else draft.data[index] = next - }, - remove: (providerID, modelID) => { - const current = Option.getOrUndefined(HashMap.get(draft.records, providerID)) - if (!current) return - const next = { - provider: current.provider, - models: new Map(current.models), - } - next.models.delete(modelID) - draft.records = HashMap.set(draft.records, providerID, next) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index !== -1) draft.data[index] = next - }, - }, - } - return result - } - - const applyPolicy = Effect.fn("CatalogV2.applyPolicy")(function* (draft: { - records: HashMap.HashMap - data: ProviderRecord[] - }) { - const ctx = context(draft) - for (const record of [...draft.data]) { - if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { - ctx.provider.remove(record.provider.id) } - } + return result + }, + rebuild: (catalog) => plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid), + finalize: Effect.fn("CatalogV2.applyPolicy")(function* (catalog) { + for (const record of [...catalog.provider.list()]) { + if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { + catalog.provider.remove(record.provider.id) + } + } + }), }) - const rebuild = Effect.fn("CatalogV2.rebuild")(function* () { - const draft = { records: HashMap.empty(), data: [] as ProviderRecord[] } - for (const loader of loaders) loader.update(context(draft)) - yield* plugin.trigger("catalog.transform", context(draft), {}) - yield* applyPolicy(draft) - records = draft.records - }) - - yield* plugin.added().pipe( - Stream.runForEach((id) => - Effect.gen(function* () { - const draft = { records: clone(records), data: HashMap.toValues(records) } - yield* plugin.triggerFor(id, "catalog.transform", context(draft), {}) - yield* applyPolicy(draft) - records = draft.records - }), + yield* events.subscribe(PluginV2.Event.Added).pipe( + Stream.runForEach((event) => + transformed.update((catalog) => + plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}).pipe(Effect.asVoid), + ), ), Effect.forkIn(scope, { startImmediately: true }), ) const result: Interface = { - loader: Effect.fn("CatalogV2.loader")(function* () { - const loader = { update: (_ctx: Context) => {} } - loaders = [...loaders, loader] - const scope = yield* Scope.Scope - yield* Scope.addFinalizer( - scope, - Effect.sync(() => { - loaders = loaders.filter((item) => item !== loader) - }).pipe(Effect.andThen(rebuild())), - ) - return Effect.fnUntraced(function* (update) { - loader.update = update - yield* rebuild() - }) - }), + transform: transformed.transform, provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { @@ -259,11 +223,11 @@ export const layer = Layer.effect( }), all: Effect.fn("CatalogV2.provider.all")(function* () { - return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider) + return globalThis.Array.from(transformed.get().providers.values()).map((record) => record.provider) }), available: Effect.fn("CatalogV2.provider.available")(function* () { - return globalThis.Array.from(HashMap.values(records)) + return globalThis.Array.from(transformed.get().providers.values()) .map((record) => record.provider) .filter((provider) => provider.enabled) }), @@ -279,8 +243,7 @@ export const layer = Layer.effect( all: Effect.fn("CatalogV2.model.all")(function* () { return pipe( - records, - HashMap.toValues, + globalThis.Array.from(transformed.get().providers.values()), Array.flatMap((record) => globalThis.Array.from(record.models.values())), Array.map(resolve), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), @@ -289,12 +252,13 @@ export const layer = Layer.effect( available: Effect.fn("CatalogV2.model.available")(function* () { return (yield* result.model.all()).filter((model) => { - const record = Option.getOrUndefined(HashMap.get(records, model.providerID)) + const record = transformed.get().providers.get(model.providerID) return record?.provider.enabled !== false && model.enabled }) }), default: Effect.fn("CatalogV2.model.default")(function* () { + const defaultModel = transformed.get().defaultModel if (defaultModel) { const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) if (Option.isSome(model) && model.value.enabled) return model @@ -307,13 +271,8 @@ export const layer = Layer.effect( ) }), - setDefault: Effect.fn("CatalogV2.model.setDefault")(function* (providerID, modelID) { - yield* result.model.get(providerID, modelID) - defaultModel = { providerID, modelID } - }), - small: Effect.fn("CatalogV2.model.small")(function* (providerID) { - const record = Option.getOrUndefined(HashMap.get(records, providerID)) + const record = transformed.get().providers.get(providerID) if (!record) return Option.none() if (providerID === ProviderV2.ID.opencode) { @@ -366,7 +325,4 @@ export const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ -export const defaultLayer = layer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide(PluginV2.defaultLayer), -) +export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer)) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 4c50b8096f..fca2e53302 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -12,10 +12,10 @@ export const Plugin = PluginV2.define({ effect: Effect.gen(function* () { const catalog = yield* Catalog.Service const config = yield* Config.Service - const load = yield* catalog.loader() + const transform = yield* catalog.transform() const files = yield* config.get() - yield* load((catalog) => { + yield* transform((catalog) => { for (const file of files) { for (const [id, item] of Object.entries(file.info.providers ?? {})) { const providerID = ProviderV2.ID.make(id) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index e78dbc4059..df8a40c1fa 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -2,16 +2,26 @@ export * as PluginV2 from "./plugin" import { createDraft, finishDraft, type Draft } from "immer" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect" +import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" import type { ModelV2 } from "./model" import type { Catalog } from "./catalog" +import { EventV2 } from "./event" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export type ID = typeof ID.Type +export const Event = { + Added: EventV2.define({ + type: "plugin.added", + schema: { + id: ID, + }, + }), +} + type HookSpec = { "catalog.transform": { - input: Catalog.Context + input: Catalog.Editor output: {} } "account.switched": { @@ -71,7 +81,6 @@ export interface Interface { effect: Effect.Effect }) => Effect.Effect readonly remove: (id: ID) => Effect.Effect - readonly added: () => Stream.Stream readonly triggerFor: ( id: ID, name: Name, @@ -95,9 +104,7 @@ export const layer = Layer.effect( hooks: HookFunctions scope: Scope.Closeable }[] = [] - const added = yield* PubSub.unbounded() - - yield* Effect.addFinalizer(() => PubSub.shutdown(added)) + const events = yield* EventV2.Service const svc = Service.of({ add: Effect.fn("Plugin.add")(function* (input) { @@ -113,9 +120,8 @@ export const layer = Layer.effect( scope, }, ] - yield* PubSub.publish(added, input.id) + yield* events.publish(Event.Added, { id: input.id }) }), - added: () => Stream.fromPubSub(added), trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { return yield* svc.triggerFor(ID.make("*"), name, input, output) }), @@ -163,7 +169,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) // opencode // sdcok diff --git a/packages/core/src/plugin/account.ts b/packages/core/src/plugin/account.ts index d4d00c3ab6..4e77701f31 100644 --- a/packages/core/src/plugin/account.ts +++ b/packages/core/src/plugin/account.ts @@ -19,7 +19,7 @@ export const AccountPlugin = PluginV2.define({ return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie) if (!account) continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/env.ts b/packages/core/src/plugin/env.ts index 3d716fe6f3..35e6981a40 100644 --- a/packages/core/src/plugin/env.ts +++ b/packages/core/src/plugin/env.ts @@ -6,7 +6,7 @@ export const EnvPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { const key = item.provider.env.find((env) => process.env[env]) if (!key) continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index bde162d729..4733833fdb 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -57,10 +57,10 @@ export const ModelsDevPlugin = PluginV2.define({ const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service const scope = yield* Scope.Scope - const load = yield* catalog.loader() + const transform = yield* catalog.transform() const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { const data = yield* modelsDev.get() - yield* load((catalog) => { + yield* transform((catalog) => { for (const item of Object.values(data)) { const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 44a5d44e2b..e7452ac2e9 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -51,7 +51,7 @@ export const AmazonBedrockPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/amazon-bedrock") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 99fbf56ab3..026da36349 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -6,7 +6,7 @@ export const AnthropicPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/anthropic") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index ea5c34d7c0..bea98cf211 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -15,7 +15,7 @@ export const AzurePlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/azure") continue const configured = item.provider.options.aisdk.provider.resourceName @@ -58,7 +58,7 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({ "catalog.transform": Effect.fn(function* (evt) { const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME if (!resourceName) return - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (!item.provider.id.includes("azure-cognitive-services")) continue diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 12da385920..b18884cb6e 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -6,7 +6,7 @@ export const CerebrasPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (ctx) { - for (const item of ctx.data) { + for (const item of ctx.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/cerebras") continue ctx.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index d10f8e79e4..32cfb059f4 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -11,7 +11,7 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - const item = evt.data.find((record) => record.provider.id === providerID) + const item = evt.provider.get(providerID) if (!item) return evt.provider.update(item.provider.id, (provider) => { if (provider.endpoint.type !== "aisdk") return diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index dc984cdef9..20b1ad6d6c 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -31,7 +31,7 @@ export const GithubCopilotPlugin = PluginV2.define({ : evt.sdk.chat(evt.model.apiID) }), "catalog.transform": Effect.fn(function* (evt) { - const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.githubCopilot) + const item = evt.provider.get(ProviderV2.ID.githubCopilot) if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { // This chat-only alias conflicts with the Copilot GPT-5 Responses route, diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 3da7caf4e7..ef36a702bf 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -59,7 +59,7 @@ export const GoogleVertexPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if ( item.provider.endpoint.package !== "@ai-sdk/google-vertex" && @@ -110,7 +110,7 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue const project = diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 17436d9f96..098e5576c4 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -6,7 +6,7 @@ export const KiloPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (item.provider.endpoint.url !== "https://api.kilo.ai/api/gateway") continue diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index de1872935c..8a971f0a0d 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -6,7 +6,7 @@ export const LLMGatewayPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.enabled === false) continue if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 0c1301c842..f9c2a0420b 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -6,7 +6,7 @@ export const NvidiaPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (item.provider.endpoint.url !== "https://integrate.api.nvidia.com/v1") continue diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index d76d7417f1..2d33fbcbbe 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -17,7 +17,7 @@ export const OpenAIPlugin = PluginV2.define({ evt.language = evt.sdk.responses(evt.model.apiID) }), "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai") continue if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 411cbe091e..64d20f8bd4 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -8,7 +8,7 @@ export const OpencodePlugin = PluginV2.define({ let hasKey = false return { "catalog.transform": Effect.fn(function* (evt) { - const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.opencode) + const item = evt.provider.get(ProviderV2.ID.opencode) if (!item) return hasKey = Boolean( process.env.OPENCODE_API_KEY || diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 317f48158a..dd3e16070b 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -7,7 +7,7 @@ export const OpenRouterPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@openrouter/ai-sdk-provider") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index fa368d10d7..1da00989b3 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -6,7 +6,7 @@ export const VercelPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/vercel") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 3b0fcff165..01c3bd8adb 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -6,7 +6,7 @@ export const ZenmuxPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (item.provider.endpoint.url !== "https://zenmux.ai/api/v1") continue diff --git a/packages/core/src/transform-state.ts b/packages/core/src/transform-state.ts new file mode 100644 index 0000000000..7852eee076 --- /dev/null +++ b/packages/core/src/transform-state.ts @@ -0,0 +1,67 @@ +export * as TransformState from "./transform-state" + +import { Effect, Scope, Semaphore } from "effect" +import { createDraft, finishDraft, type Draft, type Objectish } from "immer" + +export type Transform = (editor: Editor) => void +export type MakeEditor = (draft: Draft) => Editor +export type SetTransform = (transform: Transform) => Effect.Effect + +export interface Options { + readonly initial: () => State + readonly editor: MakeEditor + /** Applies service-specific work during a full rebuild. */ + readonly rebuild?: (editor: Editor) => Effect.Effect + /** Applies invariants to every committed edit, including incremental edits. */ + readonly finalize?: (editor: Editor) => Effect.Effect +} + +export interface Interface { + readonly get: () => State + readonly transform: () => Effect.Effect, never, Scope.Scope> + readonly update: (update: (editor: Editor) => Effect.Effect) => Effect.Effect +} + +export function create(options: Options): Interface { + let state = options.initial() + let transforms: { update: Transform }[] = [] + const semaphore = Semaphore.makeUnsafe(1) + + const commit = Effect.fn("TransformState.commit")(function* (draft: Draft) { + const api = options.editor(draft) + if (options.finalize) yield* options.finalize(api) + state = finishDraft(draft) as State + }) + + const rebuild = Effect.fn("TransformState.rebuild")(function* () { + const draft = createDraft(options.initial()) + const api = options.editor(draft) + for (const transform of transforms) transform.update(api) + if (options.rebuild) yield* options.rebuild(api) + yield* commit(draft) + }, semaphore.withPermit) + + return { + get: () => state, + transform: Effect.fn("TransformState.transform")(function* () { + const transform = { update: (_editor: Editor) => {} } + transforms = [...transforms, transform] + const scope = yield* Scope.Scope + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + transforms = transforms.filter((item) => item !== transform) + }).pipe(Effect.andThen(rebuild())), + ) + return Effect.fnUntraced(function* (update: Transform) { + transform.update = update + yield* rebuild() + }) + }), + update: Effect.fn("TransformState.update")(function* (update) { + const draft = createDraft(state) + yield* update(options.editor(draft)) + yield* commit(draft) + }, semaphore.withPermit), + } +} diff --git a/packages/core/test/account.test.ts b/packages/core/test/account.test.ts index cf60740b1e..1e50fd340a 100644 --- a/packages/core/test/account.test.ts +++ b/packages/core/test/account.test.ts @@ -19,12 +19,11 @@ const it = testEffect(PluginV2.defaultLayer) function context( records: { provider: ProviderV2.Info; models: Map }[], updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>, -): Catalog.Context { +): Catalog.Editor { return { - data: records, - updateProvider: (providerID, fn) => context(records, updates).provider.update(providerID, fn), - updateModel: (providerID, modelID, fn) => context(records, updates).model.update(providerID, modelID, fn), provider: { + list: () => records, + get: (providerID) => records.find((item) => item.provider.id === providerID), update: (providerID, fn) => { const record = records.find((item) => item.provider.id === providerID) const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn) @@ -45,8 +44,13 @@ function context( }, }, model: { + get: () => undefined, update: () => {}, remove: () => {}, + default: { + get: () => undefined, + set: () => {}, + }, }, } } @@ -192,7 +196,7 @@ describe("AccountV2", () => { ] const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = [] const catalog = Catalog.Service.of({ - loader: () => Effect.die("unexpected catalog.loader"), + transform: () => Effect.die("unexpected catalog.transform"), provider: { get: () => Effect.die("unexpected provider.get"), all: () => Effect.succeed([]), @@ -203,7 +207,6 @@ describe("AccountV2", () => { all: () => Effect.succeed([]), available: () => Effect.succeed([]), default: () => Effect.succeed(Option.none()), - setDefault: () => Effect.die("unexpected model.setDefault"), small: () => Effect.succeed(Option.none()), }, }) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index fe651e0d78..9736e2a739 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -29,9 +29,9 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => + yield* transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", @@ -55,9 +55,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", @@ -84,9 +84,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", @@ -111,14 +111,14 @@ describe("CatalogV2", () => { const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("test") const seen: unknown[] = [] - const load = yield* catalog.loader() + const transform = yield* catalog.transform() yield* plugin.add({ id: PluginV2.ID.make("test"), effect: Effect.succeed({ "catalog.transform": (evt) => Effect.sync(() => { - const item = evt.data.find((record) => record.provider.id === providerID) + const item = evt.provider.get(providerID) if (!item) return seen.push(item.provider.endpoint.type) if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url) @@ -126,7 +126,7 @@ describe("CatalogV2", () => { }), }), }) - yield* load((catalog) => + yield* transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" } provider.options.aisdk.provider.baseURL = "https://provider.example.com" @@ -142,9 +142,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => + yield* transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.name = "Before" }), @@ -171,9 +171,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.options.headers.provider = "provider" provider.options.headers.shared = "provider" @@ -201,9 +201,9 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.enabled = { via: "custom", data: {} } }) @@ -219,13 +219,44 @@ describe("CatalogV2", () => { }), ) + it.effect("uses a transform-provided default model until that transform is replaced", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + const old = ModelV2.ID.make("old") + const newest = ModelV2.ID.make("new") + const transform = yield* catalog.transform() + + const models = (catalog: Catalog.Editor) => { + catalog.provider.update(providerID, (provider) => { + provider.enabled = { via: "custom", data: {} } + }) + catalog.model.update(providerID, old, (model) => { + model.time.released = DateTime.makeUnsafe(1000) + }) + catalog.model.update(providerID, newest, (model) => { + model.time.released = DateTime.makeUnsafe(2000) + }) + } + + yield* transform((catalog) => { + models(catalog) + catalog.model.default.set(providerID, old) + }) + expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old) + + yield* transform(models) + expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest) + }), + ) + it.effect("small model prefers small keyword candidates before cost scoring", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { model.capabilities.input = ["text"] @@ -250,10 +281,10 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const policy = yield* Policy.Service const providerID = ProviderV2.ID.make("blocked") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) }) diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index f2034eb1e3..602e85624b 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -24,8 +24,8 @@ describe("AmazonBedrockPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AmazonBedrockPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const bedrock = provider("amazon-bedrock", { endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, options: { diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index 0a1d5662d3..6cae612fd2 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -12,8 +12,8 @@ describe("AnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("anthropic", { endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" }, options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -35,8 +35,8 @@ describe("AnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(provider("openai").id, () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.headers["anthropic-beta"]).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 8b8baacb7e..a3837a66ad 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -13,8 +13,8 @@ describe("AzureCognitiveServicesPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzureCognitiveServicesPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" } }) @@ -37,8 +37,8 @@ describe("AzureCognitiveServicesPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzureCognitiveServicesPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure-cognitive-services", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, }) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index ae4a03140d..18670d6901 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -34,8 +34,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" } }) @@ -51,8 +51,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure", { endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } }, @@ -100,8 +100,8 @@ describe("AzurePlugin", () => { ), }) yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" } }) @@ -119,8 +119,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure", { endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } }, @@ -141,8 +141,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure", { endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } }, diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index dd8d34c7df..982b587a71 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -24,8 +24,8 @@ describe("CerebrasPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CerebrasPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" } item.options.headers.Existing = "1" @@ -43,8 +43,8 @@ describe("CerebrasPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CerebrasPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).options.headers).toEqual({}) }), ) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 2495a0646d..e6c6618599 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -54,8 +54,8 @@ describe("CloudflareWorkersAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider" } }), @@ -86,8 +86,8 @@ describe("CloudflareWorkersAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } }), @@ -152,8 +152,8 @@ describe("CloudflareWorkersAIPlugin", () => { ), }) yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider" } }), @@ -173,8 +173,8 @@ describe("CloudflareWorkersAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider" } provider.options.aisdk.provider.accountId = "configured-acct" diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 51822c26ac..c07f70597a 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -152,8 +152,8 @@ describe("GithubCopilotPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GithubCopilotPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) @@ -168,8 +168,8 @@ describe("GithubCopilotPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GithubCopilotPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index e804d15cfc..f9efa294f5 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -184,8 +184,8 @@ describe("GitLabPlugin", () => { ), }) yield* plugin.add(GitLabPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) yield* plugin.trigger( "aisdk.sdk", @@ -232,8 +232,8 @@ describe("GitLabPlugin", () => { ), }) yield* plugin.add(GitLabPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) yield* plugin.trigger( "aisdk.sdk", diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index 7cb2b24ffd..9d23dfefb3 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -22,8 +22,8 @@ describe("GoogleVertexAnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexAnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } }), @@ -41,8 +41,8 @@ describe("GoogleVertexAnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexAnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } provider.options.aisdk.provider.project = "configured-project" diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index 2a9a18875c..ecab998c3c 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -47,8 +47,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -86,8 +86,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -136,8 +136,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -165,8 +165,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -201,8 +201,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.options.aisdk.provider.project = "config-project" diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index 2e4c9ab622..a6d25ac187 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -31,7 +31,7 @@ export const npmLayer = Layer.succeed( export const catalogLayer = Layer.succeed( Catalog.Service, Catalog.Service.of({ - loader: () => Effect.die("unexpected catalog.loader"), + transform: () => Effect.die("unexpected catalog.transform"), provider: { get: () => Effect.die("unexpected provider.get"), all: () => Effect.succeed([]), @@ -42,7 +42,6 @@ export const catalogLayer = Layer.succeed( all: () => Effect.succeed([]), available: () => Effect.succeed([]), default: () => Effect.succeed(Option.none()), - setDefault: () => Effect.die("unexpected model.setDefault"), small: () => Effect.succeed(Option.none()), }, }), diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index cafe4c10f6..ac3cc172a2 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -22,8 +22,8 @@ describe("KiloPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const kilo = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -48,8 +48,8 @@ describe("KiloPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, }) @@ -74,8 +74,8 @@ describe("KiloPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const kilo = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index f34b7b3fcc..980abe2258 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -22,8 +22,8 @@ describe("LLMGatewayPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(LLMGatewayPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const llmgateway = provider("llmgateway", { enabled: { via: "env", name: "LLMGATEWAY_API_KEY" }, endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, @@ -56,8 +56,8 @@ describe("LLMGatewayPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(LLMGatewayPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("llmgateway", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, }) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 8bd9051bd8..dea0000717 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -22,8 +22,8 @@ describe("NvidiaPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const nvidia = provider("nvidia", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -49,8 +49,8 @@ describe("NvidiaPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("nvidia", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } }, @@ -74,8 +74,8 @@ describe("NvidiaPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("nvidia", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, options: { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 93e451e018..b65beb3c71 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -77,8 +77,8 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } }) catalog.provider.update(item.id, (draft) => { draft.endpoint = item.endpoint @@ -96,8 +96,8 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("custom-openai") catalog.provider.update(item.id, () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 9ed2894fa6..4f4dd6abef 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -24,8 +24,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -45,8 +45,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const free = model("opencode", "free", { cost: cost(0) }) @@ -66,8 +66,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) }) @@ -87,8 +87,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -108,8 +108,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }) catalog.provider.update(item.id, (draft) => { draft.env = [...item.env] @@ -131,8 +131,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode", { options: { headers: {}, @@ -163,8 +163,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode", { enabled: { via: "account", service: "opencode" } }) catalog.provider.update(item.id, (draft) => { draft.enabled = item.enabled @@ -186,8 +186,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("openai") catalog.provider.update(item.id, () => {}) const paid = model("openai", "paid", { cost: cost(1) }) @@ -206,8 +206,8 @@ describe("OpencodePlugin", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.opencode - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { model.capabilities.input = ["text"] diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index a60323b8d3..d19575a9bb 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -23,8 +23,8 @@ describe("OpenRouterPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const openrouter = provider("openrouter", { endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -75,8 +75,8 @@ describe("OpenRouterPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const openrouter = provider("openrouter", { endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, }) @@ -108,8 +108,8 @@ describe("OpenRouterPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index f6d9efd192..ba34281196 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -12,8 +12,8 @@ describe("VercelPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" }, options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -36,8 +36,8 @@ describe("VercelPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } }) catalog.provider.update(item.id, (draft) => { draft.endpoint = item.endpoint @@ -69,8 +69,8 @@ describe("VercelPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(provider("gateway").id, () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).options.headers).toEqual({}) }), ) diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 71067a5a1e..c5899627cf 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -22,8 +22,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, }) @@ -42,8 +42,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -67,8 +67,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, options: { @@ -95,8 +95,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("openrouter", { options: { headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, diff --git a/specs/v2/catalog-config-plugin-lifecycle.md b/specs/v2/catalog-config-plugin-lifecycle.md new file mode 100644 index 0000000000..0781283657 --- /dev/null +++ b/specs/v2/catalog-config-plugin-lifecycle.md @@ -0,0 +1,326 @@ +# Catalog / Config / Plugin Lifecycle Options + +We need to choose where provider/model inputs live and how visible catalog state changes after boot. The designs below compare config, models.dev, auth, plugin activation/disablement, config edits, and policy changes under each option. + +## Scenarios + +- Initial load: a location opens, built-in/configured plugins activate, and the first visible catalog is constructed. +- Config: authored provider/model definitions and overrides. +- models.dev: remote provider/model data refreshed on a timer. +- Auth: active credentials enable/configure providers and can later disappear. +- Plugin activation: a plugin starts contributing while the location is open. +- Plugin disablement: a plugin stops contributing and its influence must disappear. +- Config edit: authored configuration changes while the location is open. +- Policy: allowed/denied provider selection changes after providers exist. + +## A. Config Transforms, Service Reload + +`Config` merges its ordered documents and then runs ordered, replayable plugin transforms. Each transform is a callback receiving `Draft` and may mutate any config field. + +```ts +type ConfigTransform = (config: Draft) => void + +const transform = yield * Config.transform() + +yield * + transform((config) => { + config.providers ??= {} + config.providers.acme = { + /* ... */ + } + config.model = "acme/code" + config.permissions = [ + /* ... */ + ] + }) +``` + +Because a transform can mutate any part of config, a transform change cannot safely trigger only `Catalog.reload()` or any other granular subset. Every service derived from config must reload in place from the newly transformed config. + +```ts +const transform = yield* Config.transform() +yield* transform((draft) => mutateAnyConfigField(draft)) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Agent.reload() + → MCP.reload() + → other config-consuming services reload +``` + +### Initial Load + +Configured plugin installation/updates should not block location readiness. Build an initial snapshot from authored config and fast built-ins, then activate slow plugins in the background and coalesce their resulting reload requests. + +```ts +LocationServiceMap.get(ref) + → build location layer + → Config.layer reads authored documents + → merge authored documents + → run currently active Config transforms + → Policy.layer reads transformed Config + → Catalog.layer reads transformed Config + → materialize baseline provider/model catalog + → PluginBoot baseline ready + → Frontend.fetchCatalog() + +PluginBoot background fiber + → install/update plugin packages concurrently + → activate completed plugins + → Config.transform() + → transform(updateConfig) + → ReloadScheduler.request() + → debounce short burst of completed activations + → Reload.all() + → Config.get() + → run newly active Config transforms + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +The initial layer build is not a reload. `Reload.all()` only runs after the live location changes, such as a background plugin becoming active or a config source changing. Debouncing reduces repeated full-service reloads when multiple plugins complete near each other; each batch still reloads every config-consuming service because a config transform may mutate any field. + +### Config + +```ts +config file loaded + → config source/watch trigger records new documents + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### models.dev + +```ts +timer fires + → ModelsDevPlugin.refresh() + → ModelsDev.get() + → transform(applyModelsDevToConfig) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +`Catalog` does not know about `ModelsDev`; the plugin transforms config before catalog reads it. + +### Auth + +```ts +Account.switched(providerID) + → AuthPlugin.refresh(providerID) + → Account.active(providerID) + → transform(applyAuthToConfig) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Plugin Activation + +```ts +Plugin.activate("acme-models") + → Config.transform() + → transform(applyAcmeConfig) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Plugin Disablement + +```ts +Plugin.disable("company-naming") + → close plugin scope + → Config internally unregisters transform in finalizer + → Reload.all() + → Policy.reload() + → Catalog.reload() + → sonnet.name = "Sonnet" + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Config Edit + +```ts +file watcher sees edit + → config source/watch trigger records updated documents + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Policy + +```ts +policy config changes + → config source/watch trigger records updated documents + → Reload.all() + → Policy.reload() + → Catalog.reload() + → apply updated policy + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Tradeoffs + +- A plugin receives `Draft`, can inspect preceding config state, and can mutate arbitrary config fields through a replayable transform. +- Plugin disablement removes its config transform and lets services rematerialize without manual undo. +- models.dev and auth become config transforms rather than catalog dependencies. +- `Config` owns merge/order semantics for fields visible to transforms. +- Granular service reload is not safe because a config transform can mutate anything; every config-consuming service reloads after any transform change. +- `Catalog` depends on provider/model config semantics and is part of that full service reload. +- One reload produces at most one `Catalog.Event.Updated` notification. +- Deferred plugin activation avoids blocking readiness, but plugin completions may cause repeated full-service reload batches during startup. + +## B. Catalog Transforms + +Plugins register replayable catalog transforms. Each transform receives a `Catalog.Editor` whose helper methods mutate a private catalog draft; `Catalog` rematerializes visible records from its active transforms. + +```ts +interface Catalog { + transform(): Effect.Effect< + (update: (catalog: Catalog.Editor) => void) => Effect.Effect, + never, + Scope.Scope + > +} +``` + +```ts +const transform = yield* Catalog.transform() +yield* transform(update) + → replace this transform callback + → apply active transforms in registration order + → apply policy + → commit diff + → Event.publish(Catalog.Event.Updated) + → Frontend.refetchCatalog() +``` + +### Initial Load + +Configured plugin installation/updates should not block location readiness. Build an initial catalog from immediately available sources, then activate slow plugins in the background and coalesce refresh requests. + +```ts +LocationServiceMap.get(ref) + → build location layer + → Catalog.layer creates empty catalog state + → PluginBoot.layer activates immediately available plugins + → ConfigProviderPlugin installs Catalog.transform() + → ModelsDevPlugin installs Catalog.transform() + → AuthPlugin installs Catalog.transform() + → Catalog.layer applies active transforms during boot + → apply policy + → materialize baseline provider/model catalog + → PluginBoot baseline ready + → Frontend.fetchCatalog() + +PluginBoot background fiber + → install/update plugin packages concurrently + → activate completed plugins + → Catalog.transform() + → transform(updateCatalog) + → Catalog internally rebuilds + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +Each completed plugin activation rebuilds catalog when it calls its transform. Debouncing plugin completions would require adding an explicit batch/suspend-rebuild mechanism; it does not arise from the transform interface itself. + +### Config + +```ts +config file loaded + → ConfigProviderAdapter.load() + → transform(applyConfigToCatalog) + → Catalog internally rebuilds +``` + +### models.dev + +```ts +timer fires + → ModelsDevPlugin.refresh() + → ModelsDev.get() + → transform(applyModelsDevToCatalog) + → Catalog internally rebuilds + → commit diff +``` + +### Auth + +```ts +Account.switched(providerID) + → AuthPlugin.refresh() + → transform(applyAuthToCatalog) + → Catalog internally rebuilds + → replay active transforms including current auth + → apply policy + → commit diff +``` + +### Plugin Activation + +```ts +Plugin.activate("acme-models") + → Catalog.transform() + → transform(applyAcmeToCatalog) + → Catalog internally rebuilds + → commit diff +``` + +### Plugin Disablement + +```ts +Plugin.disable("company-naming") + → close plugin scope + → Catalog internally unregisters transform in finalizer + → Catalog internally rebuilds + → sonnet.name = "Sonnet" + → commit diff +``` + +### Config Edit + +```ts +file watcher sees edit + → ConfigProviderAdapter.load() + → transform(applyUpdatedConfigToCatalog) + → Catalog internally rebuilds +``` + +### Policy + +```ts +policy changes + → Catalog rebuild trigger + → replay all active transforms + → apply updated policy last + → commit diff +``` + +### Tradeoffs + +- Disablement, source refresh, and policy re-evaluation are transform replay operations. +- Auth does not need to be represented as config. +- Config remains one catalog source rather than a catalog dependency. +- The API shape matches A, but the mutable draft is catalog state instead of configuration state. +- Catalog needs transform ordering and internal rebuild behavior in addition to reads. +- Recompute ordering, serialization, and diff events must be specified. +- One internal rebuild produces at most one `Catalog.Event.Updated` notification. +- Deferred plugin activation avoids blocking readiness and only rebuilds catalog for catalog transform changes. +- Debouncing those rebuilds needs an additional batching interface or an activation coordinator that installs multiple transforms before exposing updates.