diff --git a/packages/core/src/account.ts b/packages/core/src/account.ts new file mode 100644 index 0000000000..a124a9a158 --- /dev/null +++ b/packages/core/src/account.ts @@ -0,0 +1,319 @@ +import path from "path" +import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" +import { Identifier } from "./util/identifier" +import { NonNegativeInt, withStatics } from "./schema" +import { Global } from "./global" +import { AppFileSystem } from "./filesystem" +import { EventV2 } from "./event" + +export const ID = Schema.String.pipe( + Schema.brand("AccountV2.ID"), + withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), +) +export type ID = typeof ID.Type + +export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) +export type ServiceID = typeof ServiceID.Type + +export class OAuthCredential extends Schema.Class("AccountV2.OAuthCredential")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, +}) {} + +export class ApiKeyCredential extends Schema.Class("AccountV2.ApiKeyCredential")({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ + identifier: "AccountV2.Credential", + }) +export type Credential = Schema.Schema.Type + +export class Info extends Schema.Class("AccountV2.Info")({ + id: ID, + serviceID: ServiceID, + description: Schema.String, + credential: Credential, +}) {} + +export class FileWriteError extends Schema.TaggedErrorClass()("AccountV2.FileWriteError", { + operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), + cause: Schema.Defect, +}) {} + +export type Error = FileWriteError + +export const Event = { + Added: EventV2.define({ + type: "account.added", + schema: { + account: Info, + }, + }), + Removed: EventV2.define({ + type: "account.removed", + schema: { + account: Info, + }, + }), + Switched: EventV2.define({ + type: "account.switched", + schema: { + serviceID: ServiceID, + from: Schema.optional(ID), + to: Schema.optional(ID), + }, + }), +} + +interface Writable { + version: 2 + accounts: Record + active: Record +} + +const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) + +function migrate(old: Record): Writable { + const accounts: Record = {} + const active: Record = {} + for (const [serviceID, value] of Object.entries(old)) { + const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) + const parsed = (decoded as Record)[serviceID] + if (!parsed) continue + const id = Identifier.ascending() + const account = ID.make(id) + const brandedServiceID = ServiceID.make(serviceID) + accounts[id] = new Info({ + id: account, + serviceID: brandedServiceID, + description: "default", + credential: parsed, + }) + active[brandedServiceID] = account + } + return { version: 2, accounts, active } +} + +export interface Interface { + readonly get: (id: ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly create: (input: { + serviceID: ServiceID + credential: Credential + description?: string + }) => Effect.Effect + readonly update: (id: ID, updates: Partial>) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect + readonly activate: (id: ID) => Effect.Effect + readonly active: (serviceID: ServiceID) => Effect.Effect + readonly forService: (serviceID: ServiceID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Account") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const global = yield* Global.Service + const events = yield* EventV2.Service + const file = path.join(global.data, "account.json") + const legacyFile = path.join(global.data, "auth.json") + + const writeMigrated = Effect.fnUntraced(function* (raw: Record) { + const migrated = migrate(raw) + yield* fsys + .writeJson(file, migrated, 0o600) + .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause }))) + return migrated + }) + + const parseAuthContent = () => { + try { + return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "") + } catch {} + } + + const load: () => Effect.Effect = Effect.fnUntraced(function* () { + if (process.env.OPENCODE_AUTH_CONTENT) { + const raw = parseAuthContent() + if (raw && typeof raw === "object") { + if ("version" in raw && raw.version === 2) return raw as Writable + return yield* writeMigrated(raw as Record) + } + return { version: 2, accounts: {}, active: {} } + } + + const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null)) + if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record) + + const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + + if (raw && typeof raw === "object") { + if ("version" in raw && raw.version === 2) return raw as Writable + return yield* writeMigrated(raw as Record) + } + + return { version: 2, accounts: {}, active: {} } + }) + + const write = (data: Writable) => + fsys + .writeJson(file, data, 0o600) + .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause }))) + + const state = SynchronizedRef.makeUnsafe( + yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))), + ) + + const activate = Effect.fn("AccountV2.activate")(function* (id: ID) { + const data = yield* SynchronizedRef.get(state) + const account = data.accounts[id] + if (!account) return + const activated = yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const nextAccount = data.accounts[id] + if (!nextAccount) return [undefined, data] as const + + const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } } + yield* write(next) + return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const + }), + ) + if (activated) yield* events.publish(Event.Switched, activated) + }) + + const result: Interface = { + get: Effect.fn("AccountV2.get")(function* (id) { + return (yield* SynchronizedRef.get(state)).accounts[id] + }), + + all: Effect.fn("AccountV2.all")(function* () { + return Object.values((yield* SynchronizedRef.get(state)).accounts) + }), + + active: Effect.fn("AccountV2.active")(function* (serviceID) { + const data = yield* SynchronizedRef.get(state) + return ( + data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) + ) + }), + + forService: Effect.fn("AccountV2.list")(function* (serviceID) { + return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) + }), + + create: Effect.fn("AccountV2.add")(function* (input) { + const id = ID.make(Identifier.ascending()) + const account = new Info({ + id, + serviceID: input.serviceID, + description: input.description ?? "default", + credential: input.credential, + }) + const added = yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const next = { + ...data, + accounts: { ...data.accounts, [account.id]: account }, + active: { ...data.active, [account.serviceID]: account.id }, + } + + yield* write(next) + return [ + { + account, + switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id }, + }, + next, + ] as const + }), + ) + yield* events.publish(Event.Added, { account: added.account }) + yield* events.publish(Event.Switched, added.switched) + return added.account + }), + + update: Effect.fn("AccountV2.update")(function* (id, updates) { + const existing = (yield* SynchronizedRef.get(state)).accounts[id] + if (!existing) return + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + if (!data.accounts[id]) return [undefined, data] as const + + const next = { + ...data, + accounts: { + ...data.accounts, + [id]: new Info({ + id, + serviceID: existing.serviceID, + description: updates.description ?? existing.description, + credential: updates.credential ?? existing.credential, + }), + }, + } + + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + remove: Effect.fn("AccountV2.remove")(function* (id) { + const removed = yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const accounts = { ...data.accounts } + const active = { ...data.active } + const removed = accounts[id] + if (!removed) return [undefined, data] as const + const wasActive = active[removed.serviceID] === id + delete accounts[id] + const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID) + if (wasActive) { + if (replacement) active[removed.serviceID] = replacement.id + else delete active[removed.serviceID] + } + + const next = { ...data, accounts, active } + yield* write(next) + return [ + { + account: removed, + switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined, + }, + next, + ] as const + }), + ) + if (removed) { + yield* events.publish(Event.Removed, { account: removed.account }) + if (removed.switched) yield* events.publish(Event.Switched, removed.switched) + } + }), + + activate, + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.defaultLayer), + Layer.provide(EventV2.defaultLayer), +) + +export * as AccountV2 from "./account" diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts new file mode 100644 index 0000000000..7f4456c59f --- /dev/null +++ b/packages/core/src/agent.ts @@ -0,0 +1,147 @@ +export * as AgentV2 from "./agent" + +import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect" +import { produce, type Draft } from "immer" +import { ModelV2 } from "./model" +import { PermissionV2 } from "./permission" +import { PluginV2 } from "./plugin" +import { ProviderV2 } from "./provider" + +export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) +export type ID = typeof ID.Type + +export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" }) +export type Mode = typeof Mode.Type + +export const Info = Schema.Struct({ + name: ID, + description: Schema.optional(Schema.String), + mode: Mode, + hidden: Schema.Boolean.pipe(Schema.optional), + color: Schema.String.pipe(Schema.optional), + permission: PermissionV2.Ruleset, + model: ModelV2.Ref.pipe(Schema.optional), + system: Schema.String.pipe(Schema.optional), + options: ProviderV2.Options.pipe(Schema.optional), + steps: Schema.Int.pipe(Schema.optional), +}).annotate({ identifier: "AgentV2.Info" }) +export type Info = typeof Info.Type + +export class NotFoundError extends Schema.TaggedErrorClass()("AgentV2.NotFound", { + agent: ID, +}) {} + +export class InvalidDefaultError extends Schema.TaggedErrorClass()("AgentV2.InvalidDefault", { + agent: ID, + reason: Schema.Literals(["missing", "subagent", "hidden"]), +}) {} + +export class NoDefaultError extends Schema.TaggedErrorClass()("AgentV2.NoDefault", {}) {} + +export interface Interface { + readonly get: (agent: ID) => Effect.Effect + readonly list: () => Effect.Effect + readonly update: (agent: ID, fn: (agent: Draft) => void) => Effect.Effect + readonly remove: (agent: ID) => Effect.Effect + readonly defaultInfo: () => Effect.Effect + readonly defaultAgent: () => Effect.Effect + readonly setDefault: (agent: ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Agent") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + let agents = HashMap.empty() + let defaultAgent: ID | undefined + + const result: Interface = { + get: Effect.fn("AgentV2.get")(function* (agent) { + const match = HashMap.get(agents, agent) + if (!match.valueOrUndefined) return yield* new NotFoundError({ agent }) + return match.value + }), + + list: Effect.fn("AgentV2.list")(function* () { + return pipe( + HashMap.toValues(agents), + Array.sortWith((agent) => agent.name, Order.String), + ) + }), + + update: Effect.fnUntraced(function* (agent, fn) { + const next = produce( + HashMap.get(agents, agent).pipe( + Option.getOrElse( + () => + ({ + name: agent, + mode: "all", + permission: [], + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + }) satisfies Info, + ), + ), + fn, + ) + const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false }) + if (updated.cancel) return + agents = HashMap.set(agents, agent, { ...updated.agent, name: agent }) + }), + + remove: Effect.fn("AgentV2.remove")(function* (agent) { + const existing = Option.getOrUndefined(HashMap.get(agents, agent)) + if (!existing) return + if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return + agents = HashMap.remove(agents, agent) + if (defaultAgent === agent) defaultAgent = undefined + }), + + defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () { + const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent }) + const selected = updated.agent + if (selected) { + const agent = yield* result + .get(selected) + .pipe( + Effect.catchTag("AgentV2.NotFound", () => + Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })), + ), + ) + if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" }) + if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" }) + return agent + } + + const visible = pipe( + yield* result.list(), + Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true), + ) + if (Option.isSome(visible)) return visible.value + return yield* new NoDefaultError() + }), + + defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () { + return (yield* result.defaultInfo()).name + }), + + setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) { + yield* result.get(agent) + defaultAgent = agent + }), + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer)) diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts deleted file mode 100644 index 843c9504b4..0000000000 --- a/packages/core/src/auth.ts +++ /dev/null @@ -1,264 +0,0 @@ -import path from "path" -import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" -import { Identifier } from "./util/identifier" -import { NonNegativeInt, withStatics } from "./schema" -import { Global } from "./global" -import { AppFileSystem } from "./filesystem" - -export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" - -const AccountID = Schema.String.pipe( - Schema.brand("AccountID"), - withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), -) -export type AccountID = typeof AccountID.Type - -export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) -export type ServiceID = typeof ServiceID.Type - -export class OAuthCredential extends Schema.Class("AuthV2.OAuthCredential")({ - type: Schema.Literal("oauth"), - refresh: Schema.String, - access: Schema.String, - expires: NonNegativeInt, -}) {} - -export class ApiKeyCredential extends Schema.Class("AuthV2.ApiKeyCredential")({ - type: Schema.Literal("api"), - key: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) {} - -export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) - .pipe(Schema.toTaggedUnion("type")) - .annotate({ - identifier: "AuthV2.Credential", - }) -export type Credential = Schema.Schema.Type - -export class Account extends Schema.Class("AuthV2.Account")({ - id: AccountID, - serviceID: ServiceID, - description: Schema.String, - credential: Credential, -}) {} - -export class AuthFileWriteError extends Schema.TaggedErrorClass()("AuthV2.FileWriteError", { - operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), - cause: Schema.Defect, -}) {} - -export type AuthError = AuthFileWriteError - -interface Writable { - version: 2 - accounts: Record - active: Record -} - -const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) - -function migrate(old: Record): Writable { - const accounts: Record = {} - const active: Record = {} - for (const [serviceID, value] of Object.entries(old)) { - const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) - const parsed = (decoded as Record)[serviceID] - if (!parsed) continue - const id = Identifier.ascending() - const accountID = AccountID.make(id) - const brandedServiceID = ServiceID.make(serviceID) - accounts[id] = new Account({ - id: accountID, - serviceID: brandedServiceID, - description: "default", - credential: parsed, - }) - active[brandedServiceID] = accountID - } - return { version: 2, accounts, active } -} - -export interface Interface { - readonly get: (accountID: AccountID) => Effect.Effect - readonly all: () => Effect.Effect - readonly create: (input: { - serviceID: ServiceID - credential: Credential - description?: string - active?: boolean - }) => Effect.Effect - readonly update: ( - accountID: AccountID, - updates: Partial>, - ) => Effect.Effect - readonly remove: (accountID: AccountID) => Effect.Effect - readonly activate: (accountID: AccountID) => Effect.Effect - readonly active: (serviceID: ServiceID) => Effect.Effect - readonly forService: (serviceID: ServiceID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/Auth") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fsys = yield* AppFileSystem.Service - const global = yield* Global.Service - const file = path.join(global.data, "auth-v2.json") - const legacyFile = path.join(global.data, "auth.json") - - const writeMigrated = Effect.fnUntraced(function* (raw: Record) { - const migrated = migrate(raw) - yield* fsys - .writeJson(file, migrated, 0o600) - .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause }))) - return migrated - }) - - const parseAuthContent = () => { - try { - return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "") - } catch {} - } - - const load: () => Effect.Effect = Effect.fnUntraced(function* () { - if (process.env.OPENCODE_AUTH_CONTENT) { - const raw = parseAuthContent() - if (raw && typeof raw === "object") { - if ("version" in raw && raw.version === 2) return raw as Writable - return yield* writeMigrated(raw as Record) - } - return { version: 2, accounts: {}, active: {} } - } - - const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null)) - if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record) - - const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) - - if (raw && typeof raw === "object") { - if ("version" in raw && raw.version === 2) return raw as Writable - return yield* writeMigrated(raw as Record) - } - - return { version: 2, accounts: {}, active: {} } - }) - - const write = (data: Writable) => - fsys - .writeJson(file, data, 0o600) - .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause }))) - - const state = SynchronizedRef.makeUnsafe(yield* load()) - - const result: Interface = { - get: Effect.fn("AuthV2.get")(function* (accountID) { - return (yield* SynchronizedRef.get(state)).accounts[accountID] - }), - - all: Effect.fn("AuthV2.all")(function* () { - return Object.values((yield* SynchronizedRef.get(state)).accounts) - }), - - active: Effect.fn("AuthV2.active")(function* (serviceID) { - const data = yield* SynchronizedRef.get(state) - return ( - data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) - ) - }), - - forService: Effect.fn("AuthV2.list")(function* (serviceID) { - return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) - }), - - create: Effect.fn("AuthV2.add")(function* (input) { - return yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const account = new Account({ - id: AccountID.make(Identifier.ascending()), - serviceID: input.serviceID, - description: input.description ?? "default", - credential: input.credential, - }) - const next = { - ...data, - accounts: { ...data.accounts, [account.id]: account }, - active: - (input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID)) - ? { ...data.active, [input.serviceID]: account.id } - : data.active, - } - - yield* write(next) - return [account, next] as const - }), - ) - }), - - update: Effect.fn("AuthV2.update")(function* (accountID, updates) { - yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const existing = data.accounts[accountID] - if (!existing) return [undefined, data] as const - - const next = { - ...data, - accounts: { - ...data.accounts, - [accountID]: new Account({ - id: accountID, - serviceID: existing.serviceID, - description: updates.description ?? existing.description, - credential: updates.credential ?? existing.credential, - }), - }, - } - - yield* write(next) - return [undefined, next] as const - }), - ) - }), - - remove: Effect.fn("AuthV2.remove")(function* (accountID) { - yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const accounts = { ...data.accounts } - const active = { ...data.active } - if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID) - delete active[accounts[accountID].serviceID] - delete accounts[accountID] - - const next = { ...data, accounts, active } - yield* write(next) - return [undefined, next] as const - }), - ) - }), - - activate: Effect.fn("AuthV2.activate")(function* (accountID) { - yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const account = data.accounts[accountID] - if (!account) return [undefined, data] as const - - const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } } - yield* write(next) - return [undefined, next] as const - }), - ) - }), - } - - return Service.of(result) - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) - -export * as AuthV2 from "./auth" diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index d27f17bfb8..e057b5c58a 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,6 +1,6 @@ export * as Catalog from "./catalog" -import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect" +import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect" import { produce, type Draft } from "immer" import { ModelV2 } from "./model" import { PluginV2 } from "./plugin" @@ -8,9 +8,9 @@ import { ProviderV2 } from "./provider" import { Location } from "./location" import { EventV2 } from "./event" -type ProviderRecord = { +export type ProviderRecord = { provider: ProviderV2.Info - models: HashMap.HashMap + models: Map } export class ProviderNotFoundError extends Schema.TaggedErrorClass()( @@ -34,10 +34,26 @@ 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 + provider: { + update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => void + remove: (providerID: ProviderV2.ID) => void + } + model: { + update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void + remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void + } +} + +export type Loader = (update: (ctx: Context) => void) => Effect.Effect + export interface Interface { + readonly loader: () => Effect.Effect readonly provider: { readonly get: (providerID: ProviderV2.ID) => Effect.Effect - readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => Effect.Effect readonly all: () => Effect.Effect readonly available: () => Effect.Effect } @@ -46,11 +62,6 @@ export interface Interface { providerID: ProviderV2.ID, modelID: ModelV2.ID, ) => Effect.Effect - readonly update: ( - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - fn: (model: Draft) => void, - ) => Effect.Effect readonly all: () => Effect.Effect readonly available: () => Effect.Effect readonly default: () => Effect.Effect> @@ -69,9 +80,11 @@ export const layer = Layer.effect( 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 scope = yield* Scope.Scope const resolve = (model: ModelV2.Info) => { const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider @@ -112,29 +125,122 @@ export const layer = Layer.effect( return match.value } + const normalizeEndpoint = (item: Draft | Draft) => { + if (item.endpoint.type !== "aisdk" || typeof item.options.aisdk.provider.baseURL !== "string") return + item.endpoint.url = item.options.aisdk.provider.baseURL + 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 + }, + 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: { + 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 transform = Effect.fn("CatalogV2.transform")(function* () { + const draft = { records: clone(records), data: HashMap.toValues(records) } + yield* plugin.trigger("catalog.transform", context(draft), {}) + records = draft.records + }) + + 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), {}) + 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), {}) + records = draft.records + }), + ), + 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() + }) + }), + provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { const record = yield* getRecord(providerID) return record.provider }), - update: Effect.fnUntraced(function* (providerID, fn) { - const current = Option.getOrUndefined(HashMap.get(records, providerID)) - const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => { - fn(draft) - if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") { - draft.endpoint.url = draft.options.aisdk.provider.baseURL - delete draft.options.aisdk.provider.baseURL - } - }) - const updated = yield* plugin.trigger("provider.update", {}, { provider, cancel: false }) - records = HashMap.set(records, providerID, { - provider: updated.provider, - models: current?.models ?? HashMap.empty(), - }) - }), - all: Effect.fn("CatalogV2.provider.all")(function* () { return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider) }), @@ -149,39 +255,16 @@ export const layer = Layer.effect( model: { get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) { const record = yield* getRecord(providerID) - const model = Option.getOrUndefined(HashMap.get(record.models, modelID)) + const model = record.models.get(modelID) if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) return resolve(model) }), - update: Effect.fnUntraced(function* (providerID, modelID, fn) { - const record = yield* getRecord(providerID) - const model = produce( - HashMap.get(record.models, modelID).pipe(Option.getOrElse(() => ModelV2.Info.empty(providerID, modelID))), - (draft) => { - fn(draft) - if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") { - draft.endpoint.url = draft.options.aisdk.provider.baseURL - delete draft.options.aisdk.provider.baseURL - } - }, - ) - const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false }) - if (updated.cancel) return - const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID }) - records = HashMap.set(records, providerID, { - provider: record.provider, - models: HashMap.set(record.models, modelID, next), - }) - yield* events.publish(Event.ModelUpdated, { model: resolve(next) }) - return - }), - all: Effect.fn("CatalogV2.model.all")(function* () { return pipe( records, HashMap.toValues, - Array.flatMap((record) => HashMap.toValues(record.models)), + Array.flatMap((record) => globalThis.Array.from(record.models.values())), Array.map(resolve), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), ) @@ -217,12 +300,12 @@ export const layer = Layer.effect( if (!record) return Option.none() if (providerID === ProviderV2.ID.opencode) { - const gpt5Nano = Option.getOrUndefined(HashMap.get(record.models, ModelV2.ID.make("gpt-5-nano"))) + const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano)) } const candidates = pipe( - HashMap.toValues(record.models), + globalThis.Array.from(record.models.values()), Array.filter( (model) => model.providerID === providerID && @@ -266,4 +349,7 @@ export const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ -export const defaultLayer = layer.pipe(Layer.provideMerge(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/event.ts b/packages/core/src/event.ts index e01dc5b0d6..a4a5dd8595 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,3 +1,5 @@ +export * as EventV2 from "./event" + import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Location } from "./location" import { withStatics } from "./schema" @@ -153,5 +155,3 @@ export const layer = Layer.effect( ) export const defaultLayer = layer - -export * as EventV2 from "./event" diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 84dfb3dfae..c40a940430 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -4,9 +4,10 @@ import { Catalog } from "./catalog" import { PluginBoot } from "./plugin/boot" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { - lookup: (ref: Location.Ref) => { - const location = Layer.succeed(Location.Service, Location.Service.of(ref)) - return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location)) - }, + lookup: (ref: Location.Ref) => + Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe( + Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]), + ), idleTimeToLive: "5 minutes", + dependencies: [], }) {} diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 202943a2f2..d40fa74774 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -7,6 +7,7 @@ import { Flock } from "./util/flock" import { Hash } from "./util/hash" import { AppFileSystem } from "./filesystem" import { InstallationChannel, InstallationVersion } from "./installation/version" +import { EventV2 } from "./event" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export type CatalogModelStatus = typeof CatalogModelStatus.Type @@ -105,6 +106,13 @@ export const Provider = Schema.Struct({ export type Provider = Schema.Schema.Type +export const Event = { + Refreshed: EventV2.define({ + type: "models-dev.refreshed", + schema: {}, + }), +} + declare const OPENCODE_MODELS_DEV: Record | undefined export interface Interface { @@ -118,6 +126,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service + const events = yield* EventV2.Service const http = HttpClient.filterStatusOk( (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ @@ -197,6 +206,7 @@ export const layer = Layer.effect( if (!force && (yield* fresh())) return yield* fetchAndWrite() yield* invalidate + yield* events.publish(Event.Refreshed, {}) }), ).pipe( Effect.tapCause((cause) => @@ -215,9 +225,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer: Layer.Layer = layer.pipe( +export const defaultLayer = layer.pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(EventV2.defaultLayer), ) export * as ModelsDev from "./models-dev" diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts new file mode 100644 index 0000000000..ec8038f713 --- /dev/null +++ b/packages/core/src/permission.ts @@ -0,0 +1,45 @@ +export * as PermissionV2 from "./permission" + +import { Schema } from "effect" +import { Wildcard } from "./util/wildcard" + +export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Action" }) +export type Action = typeof Action.Type + +export const Rule = Schema.Struct({ + permission: Schema.String, + pattern: Schema.String, + action: Action, +}).annotate({ identifier: "PermissionV2.Rule" }) +export type Rule = typeof Rule.Type + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) +export type Ruleset = typeof Ruleset.Type + +const EDIT_TOOLS = ["edit", "write", "apply_patch"] + +export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { + return ( + rulesets + .flat() + .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? { + action: "ask", + permission, + pattern: "*", + } + ) +} + +export function merge(...rulesets: Ruleset[]): Ruleset { + return rulesets.flat() +} + +export function disabled(tools: string[], ruleset: Ruleset): Set { + return new Set( + tools.filter((tool) => { + const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool + const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) + return rule?.pattern === "*" && rule.action === "deny" + }), + ) +} diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index dfcae94685..ab2d4cbf7d 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -2,27 +2,26 @@ export * as PluginV2 from "./plugin" import { createDraft, finishDraft, type Draft } from "immer" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { type ProviderV2 } from "./provider" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect" import type { ModelV2 } from "./model" +import type { AgentV2 } from "./agent" +import type { Catalog } from "./catalog" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export type ID = typeof ID.Type type HookSpec = { - "provider.update": { - input: {} - output: { - provider: ProviderV2.Info - cancel: boolean - } + "catalog.transform": { + input: Catalog.Context + output: {} } - "model.update": { - input: {} - output: { - model: ModelV2.Info - cancel: boolean + "account.switched": { + input: { + serviceID: import("./account").AccountV2.ServiceID + from?: import("./account").AccountV2.ID + to?: import("./account").AccountV2.ID } + output: {} } "aisdk.language": { input: { @@ -44,6 +43,27 @@ type HookSpec = { sdk?: any } } + "agent.update": { + input: {} + output: { + agent: AgentV2.Info + cancel: boolean + } + } + "agent.remove": { + input: { + agent: AgentV2.Info + } + output: { + cancel: boolean + } + } + "agent.default": { + input: {} + output: { + agent?: AgentV2.ID + } + } } export type Hooks = { @@ -61,15 +81,25 @@ export type HookFunctions = { export type HookInput = HookSpec[Name]["input"] export type HookOutput = HookSpec[Name]["output"] -export type Effect = Effect.Effect +export type Effect = Effect.Effect export function define(input: { id: ID; effect: Effect.Effect }) { return input } export interface Interface { - readonly add: (input: { id: ID; effect: Effect }) => Effect.Effect + readonly add: (input: { + id: ID + effect: Effect.Effect + }) => Effect.Effect readonly remove: (id: ID) => Effect.Effect + readonly added: () => Stream.Stream + readonly triggerFor: ( + id: ID, + name: Name, + input: HookInput, + output: HookOutput, + ) => Effect.Effect & HookOutput> readonly trigger: ( name: Name, input: HookInput, @@ -85,21 +115,33 @@ export const layer = Layer.effect( let hooks: { id: ID hooks: HookFunctions + scope: Scope.Closeable }[] = [] + const added = yield* PubSub.unbounded() + + yield* Effect.addFinalizer(() => PubSub.shutdown(added)) const svc = Service.of({ add: Effect.fn("Plugin.add")(function* (input) { - const result = yield* input.effect - if (!result) return + const existing = hooks.find((item) => item.id === input.id) + if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) + const scope = yield* Scope.make() + const result = yield* input.effect.pipe(Scope.provide(scope)) hooks = [ ...hooks.filter((item) => item.id !== input.id), { id: input.id, - hooks: result, + hooks: result ?? {}, + scope, }, ] + yield* PubSub.publish(added, input.id) }), + added: () => Stream.fromPubSub(added), trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { + return yield* svc.triggerFor(ID.make("*"), name, input, output) + }), + triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) { const draftEntries = new Map>() const event = { ...input, @@ -114,6 +156,7 @@ export const layer = Layer.effect( } for (const item of hooks) { + if (id !== ID.make("*") && item.id !== id) continue const match = item.hooks[name] if (!match) continue yield* match(event as any).pipe( @@ -133,7 +176,9 @@ export const layer = Layer.effect( return event as any }), remove: Effect.fn("Plugin.remove")(function* (id) { + const existing = hooks.find((item) => item.id === id) hooks = hooks.filter((item) => item.id !== id) + if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) }), }) return svc diff --git a/packages/core/src/plugin/account.ts b/packages/core/src/plugin/account.ts new file mode 100644 index 0000000000..d4d00c3ab6 --- /dev/null +++ b/packages/core/src/plugin/account.ts @@ -0,0 +1,41 @@ +import { Effect, Scope, Stream } from "effect" +import { AccountV2 } from "../account" +import { EventV2 } from "../event" +import { PluginV2 } from "../plugin" + +export const AccountPlugin = PluginV2.define({ + id: PluginV2.ID.make("account"), + effect: Effect.gen(function* () { + const accounts = yield* AccountV2.Service + const events = yield* EventV2.Service + const scope = yield* Scope.Scope + + yield* events.subscribe(AccountV2.Event.Switched).pipe( + Stream.runForEach((event) => + PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid), + ), + Effect.forkIn(scope, { startImmediately: true }), + ) + + return { + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie) + if (!account) continue + evt.provider.update(item.provider.id, (provider) => { + provider.enabled = { + via: "account", + service: account.serviceID, + } + if (account.credential.type === "api") { + provider.options.aisdk.provider.apiKey = account.credential.key + Object.assign(provider.options.aisdk.provider, account.credential.metadata ?? {}) + } + if (account.credential.type === "oauth") provider.options.aisdk.provider.apiKey = account.credential.access + }) + } + }), + "account.switched": Effect.fn(function* () {}), + } + }), +}) diff --git a/packages/core/src/plugin/auth.ts b/packages/core/src/plugin/auth.ts deleted file mode 100644 index 81cbfbe3f7..0000000000 --- a/packages/core/src/plugin/auth.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Effect } from "effect" -import { AuthV2 } from "../auth" -import { PluginV2 } from "../plugin" - -export const AuthPlugin = PluginV2.define({ - id: PluginV2.ID.make("auth"), - effect: Effect.gen(function* () { - const auth = yield* AuthV2.Service - return { - "provider.update": Effect.fn(function* (evt) { - const account = yield* auth.active(AuthV2.ServiceID.make(evt.provider.id)).pipe(Effect.orDie) - if (!account) return - evt.provider.enabled = { - via: "auth", - service: account.serviceID, - } - if (account.credential.type === "api") { - evt.provider.options.aisdk.provider.apiKey = account.credential.key - Object.assign(evt.provider.options.aisdk.provider, account.credential.metadata ?? {}) - } - if (account.credential.type === "oauth") { - evt.provider.options.aisdk.provider.apiKey = account.credential.access - } - }), - } - }), -}) diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 74560ac85b..cffd21bec4 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -1,18 +1,22 @@ export * as PluginBoot from "./boot" -import { Context, Deferred, Effect, Layer } from "effect" -import { AuthV2 } from "../auth" +import { Context, Deferred, Effect, Layer, Scope } from "effect" +import { AccountV2 } from "../account" +import { AgentV2 } from "../agent" import { Catalog } from "../catalog" +import { EventV2 } from "../event" import { Npm } from "../npm" import { PluginV2 } from "../plugin" -import { AuthPlugin } from "./auth" +import { AccountPlugin } from "./account" import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" type Plugin = { id: PluginV2.ID - effect: Effect.Effect + effect: PluginV2.Effect< + Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service + > } export interface Interface { @@ -21,51 +25,57 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/PluginBoot") {} -export const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const plugin = yield* PluginV2.Service - const auth = yield* AuthV2.Service - const npm = yield* Npm.Service - const done = yield* Deferred.make() +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const accounts = yield* AccountV2.Service + const npm = yield* Npm.Service + const events = yield* EventV2.Service + const done = yield* Deferred.make() - const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { - yield* plugin.add({ - id: input.id, - effect: input.effect.pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(AuthV2.Service, auth), - Effect.provideService(Npm.Service, npm), - ), - }) + const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { + yield* plugin.add({ + id: input.id, + effect: input.effect.pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(AgentV2.Service, agent), + Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(PluginV2.Service, plugin), + ), }) + }) - const boot = Effect.gen(function* () { - yield* add(EnvPlugin) - yield* add(AuthPlugin) - for (const item of ProviderPlugins) { - yield* add(item) - } - yield* add(ModelsDevPlugin) - }).pipe(Effect.withSpan("PluginBoot.boot")) + const boot = Effect.gen(function* () { + yield* add(EnvPlugin) + yield* add(AccountPlugin) + for (const item of ProviderPlugins) { + yield* add(item) + } + yield* add(ModelsDevPlugin) + }).pipe(Effect.withSpan("PluginBoot.boot")) - yield* boot.pipe( - Effect.exit, - Effect.flatMap((exit) => Deferred.done(done, exit)), - Effect.forkScoped, - ) + yield* boot.pipe( + Effect.exit, + Effect.flatMap((exit) => Deferred.done(done, exit)), + Effect.forkScoped, + ) - return Service.of({ - wait: () => Deferred.await(done), - }) - }), - ) + return Service.of({ + wait: () => Deferred.await(done), + }) + }), +) export const defaultLayer = layer.pipe( + Layer.provide(AgentV2.defaultLayer), Layer.provide(Catalog.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer), - Layer.provide(Layer.orDie(AuthV2.defaultLayer)), + Layer.provide(AccountV2.defaultLayer), Layer.provide(Npm.defaultLayer), ) diff --git a/packages/core/src/plugin/env.ts b/packages/core/src/plugin/env.ts index d63936fa13..3d716fe6f3 100644 --- a/packages/core/src/plugin/env.ts +++ b/packages/core/src/plugin/env.ts @@ -5,12 +5,16 @@ export const EnvPlugin = PluginV2.define({ id: PluginV2.ID.make("env"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - const key = evt.provider.env.find((item) => process.env[item]) - if (!key) return - evt.provider.enabled = { - via: "env", - name: key, + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + const key = item.provider.env.find((env) => process.env[env]) + if (!key) continue + evt.provider.update(item.provider.id, (provider) => { + provider.enabled = { + via: "env", + name: key, + } + }) } }), } diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index e36a095eb0..bde162d729 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,5 +1,6 @@ -import { DateTime, Effect } from "effect" +import { DateTime, Effect, Scope, Stream } from "effect" import { Catalog } from "../catalog" +import { EventV2 } from "../event" import { ModelV2 } from "../model" import { ModelsDev } from "../models-dev" import { PluginV2 } from "../plugin" @@ -54,55 +55,66 @@ export const ModelsDevPlugin = PluginV2.define({ effect: Effect.gen(function* () { const catalog = yield* Catalog.Service const modelsDev = yield* ModelsDev.Service - for (const item of Object.values(yield* modelsDev.get())) { - const providerID = ProviderV2.ID.make(item.id) - yield* catalog.provider.update(providerID, (provider) => { - provider.name = item.name - provider.env = [...item.env] - provider.endpoint = item.npm - ? { - type: "aisdk", - package: item.npm, - url: item.api, - } - : { - type: "unknown", - } - }) - - for (const model of Object.values(item.models)) { - const modelID = ModelV2.ID.make(model.id) - yield* catalog.model - .update(providerID, modelID, (draft) => { - draft.name = model.name - draft.family = model.family ? ModelV2.Family.make(model.family) : undefined - draft.endpoint = model.provider?.npm + const events = yield* EventV2.Service + const scope = yield* Scope.Scope + const load = yield* catalog.loader() + const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { + const data = yield* modelsDev.get() + yield* load((catalog) => { + for (const item of Object.values(data)) { + const providerID = ProviderV2.ID.make(item.id) + catalog.provider.update(providerID, (provider) => { + provider.name = item.name + provider.env = [...item.env] + provider.endpoint = item.npm ? { type: "aisdk", - package: model.provider?.npm, - url: model.provider.api, + package: item.npm, + url: item.api, } : { type: "unknown", } - draft.capabilities = { - tools: model.tool_call, - input: [...(model.modalities?.input ?? [])], - output: [...(model.modalities?.output ?? [])], - } - draft.variants = variants(model) - draft.time.released = released(model.release_date) - draft.cost = cost(model.cost) - draft.status = model.status ?? "active" - draft.enabled = true - draft.limit = { - context: model.limit.context, - input: model.limit.input, - output: model.limit.output, - } }) - .pipe(Effect.orDie) - } - } + + for (const model of Object.values(item.models)) { + const modelID = ModelV2.ID.make(model.id) + catalog.model.update(providerID, modelID, (draft) => { + draft.name = model.name + draft.family = model.family ? ModelV2.Family.make(model.family) : undefined + draft.endpoint = model.provider?.npm + ? { + type: "aisdk", + package: model.provider?.npm, + url: model.provider.api, + } + : { + type: "unknown", + } + draft.capabilities = { + tools: model.tool_call, + input: [...(model.modalities?.input ?? [])], + output: [...(model.modalities?.output ?? [])], + } + draft.variants = variants(model) + draft.time.released = released(model.release_date) + draft.cost = cost(model.cost) + draft.status = model.status ?? "active" + draft.enabled = true + draft.limit = { + context: model.limit.context, + input: model.limit.input, + output: model.limit.output, + } + }) + } + } + }) + }) + yield* refresh() + yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.runForEach(() => refresh()), + Effect.forkIn(scope, { startImmediately: true }), + ) }).pipe(Effect.provide(ModelsDev.defaultLayer)), }) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 366548a0a3..44a5d44e2b 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -50,14 +50,19 @@ export const AmazonBedrockPlugin = PluginV2.define({ id: PluginV2.ID.make("amazon-bedrock"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.amazonBedrock) return - if (evt.provider.endpoint.type !== "aisdk") return - if (typeof evt.provider.options.aisdk.provider.endpoint !== "string") return - // The AI SDK expects a base URL, but users configure Bedrock private/VPC - // endpoints as `endpoint`; move it into the catalog endpoint URL once. - evt.provider.endpoint.url = evt.provider.options.aisdk.provider.endpoint - delete evt.provider.options.aisdk.provider.endpoint + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + if (item.provider.endpoint.type !== "aisdk") continue + if (item.provider.endpoint.package !== "@ai-sdk/amazon-bedrock") continue + evt.provider.update(item.provider.id, (provider) => { + if (provider.endpoint.type !== "aisdk") return + if (typeof provider.options.aisdk.provider.endpoint !== "string") return + // The AI SDK expects a base URL, but users configure Bedrock private/VPC + // endpoints as `endpoint`; move it into the catalog endpoint URL once. + provider.endpoint.url = provider.options.aisdk.provider.endpoint + delete provider.options.aisdk.provider.endpoint + }) + } }), "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/amazon-bedrock") return diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 14851c4a31..09e80ea95d 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,15 +1,19 @@ import { Effect } from "effect" import { PluginV2 } from "../../plugin" -import { ProviderV2 } from "../../provider" export const AnthropicPlugin = PluginV2.define({ id: PluginV2.ID.make("anthropic"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.anthropic) return - evt.provider.options.headers["anthropic-beta"] = - "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + if (item.provider.endpoint.type !== "aisdk") continue + if (item.provider.endpoint.package !== "@ai-sdk/anthropic") continue + evt.provider.update(item.provider.id, (provider) => { + provider.options.headers["anthropic-beta"] = + "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + }) + } }), "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 6c29a16103..ea5c34d7c0 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -14,12 +14,18 @@ export const AzurePlugin = PluginV2.define({ id: PluginV2.ID.make("azure"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.azure) return - const configured = evt.provider.options.aisdk.provider.resourceName - const resourceName = - typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME - if (resourceName) evt.provider.options.aisdk.provider.resourceName = resourceName + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + if (item.provider.endpoint.type !== "aisdk") continue + if (item.provider.endpoint.package !== "@ai-sdk/azure") continue + const configured = item.provider.options.aisdk.provider.resourceName + const resourceName = + typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME + if (!resourceName) continue + evt.provider.update(item.provider.id, (provider) => { + provider.options.aisdk.provider.resourceName = resourceName + }) + } }), "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return @@ -49,11 +55,17 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({ id: PluginV2.ID.make("azure-cognitive-services"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.make("azure-cognitive-services")) return + "catalog.transform": Effect.fn(function* (evt) { const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME - if (resourceName) - evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` + if (!resourceName) return + for (const item of evt.data) { + 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 + evt.provider.update(item.provider.id, (provider) => { + provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` + }) + } }), "aisdk.language": Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index b2fadd8bf1..12da385920 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,14 +1,18 @@ import { Effect } from "effect" import { PluginV2 } from "../../plugin" -import { ProviderV2 } from "../../provider" export const CerebrasPlugin = PluginV2.define({ id: PluginV2.ID.make("cerebras"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.make("cerebras")) return - evt.provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" + "catalog.transform": Effect.fn(function* (ctx) { + for (const item of ctx.data) { + if (item.provider.endpoint.type !== "aisdk") continue + if (item.provider.endpoint.package !== "@ai-sdk/cerebras") continue + ctx.provider.update(item.provider.id, (provider) => { + provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" + }) + } }), "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index ffcd4adcf4..1cd974ee95 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) function gatewayConfig(options: Record): GatewayConfig | undefined { const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") - // AuthPlugin copies CLI prompt metadata into options. The prompt stores the + // AccountPlugin copies CLI prompt metadata into options. The prompt stores the // gateway as gatewayId, while older config examples may use gateway. const gatewayId = process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway") diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index f39869b57d..d10f8e79e4 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -10,13 +10,15 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ id: PluginV2.ID.make("cloudflare-workers-ai"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== providerID) return - if (evt.provider.endpoint.type !== "aisdk") return - if (evt.provider.endpoint.url) return - - const accountId = resolveAccountId(evt.provider.options.aisdk.provider) - if (accountId) evt.provider.endpoint.url = workersEndpoint(accountId) + "catalog.transform": Effect.fn(function* (evt) { + const item = evt.data.find((record) => record.provider.id === providerID) + if (!item) return + evt.provider.update(item.provider.id, (provider) => { + if (provider.endpoint.type !== "aisdk") return + if (provider.endpoint.url) return + const accountId = resolveAccountId(provider.options.aisdk.provider) + if (accountId) provider.endpoint.url = workersEndpoint(accountId) + }) }), "aisdk.sdk": Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 31e57ba12a..dc984cdef9 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -15,9 +15,6 @@ export const GithubCopilotPlugin = PluginV2.define({ id: PluginV2.ID.make("github-copilot"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.githubCopilot) return - }), "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) @@ -33,11 +30,14 @@ export const GithubCopilotPlugin = PluginV2.define({ ? evt.sdk.responses(evt.model.apiID) : evt.sdk.chat(evt.model.apiID) }), - "model.update": Effect.fn(function* (evt) { - if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return - // This chat-only alias conflicts with the Copilot GPT-5 Responses route, - // so hide it only for Copilot rather than for every provider catalog. - if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true + "catalog.transform": Effect.fn(function* (evt) { + const item = evt.data.find((record) => record.provider.id === 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, + // so hide it only for Copilot rather than for every provider catalog. + model.enabled = false + }) }), } }), diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 0c335df931..fbe03e3ae8 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -57,20 +57,22 @@ export const GoogleVertexPlugin = PluginV2.define({ id: PluginV2.ID.make("google-vertex"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.googleVertex) return - const project = resolveProject(evt.provider.options.aisdk.provider) - const location = String(resolveLocation(evt.provider.options.aisdk.provider)) - if (project) evt.provider.options.aisdk.provider.project = project - evt.provider.options.aisdk.provider.location = location - if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.url) { - evt.provider.endpoint.url = replaceVertexVars(evt.provider.endpoint.url, project, location) - } - if ( - evt.provider.endpoint.type === "aisdk" && - evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible") - ) { - evt.provider.options.aisdk.provider.fetch = authFetch(evt.provider.options.aisdk.provider.fetch) + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + if (item.provider.endpoint.type !== "aisdk") continue + if (item.provider.endpoint.package !== "@ai-sdk/google-vertex" && !item.provider.endpoint.package.includes("@ai-sdk/openai-compatible")) continue + const project = resolveProject(item.provider.options.aisdk.provider) + const location = String(resolveLocation(item.provider.options.aisdk.provider)) + evt.provider.update(item.provider.id, (provider) => { + if (project) provider.options.aisdk.provider.project = project + provider.options.aisdk.provider.location = location + if (provider.endpoint.type === "aisdk" && provider.endpoint.url) { + provider.endpoint.url = replaceVertexVars(provider.endpoint.url, project, location) + } + if (provider.endpoint.type === "aisdk" && provider.endpoint.package.includes("@ai-sdk/openai-compatible")) { + provider.options.aisdk.provider.fetch = authFetch(provider.options.aisdk.provider.fetch) + } + }) } }), "aisdk.sdk": Effect.fn(function* (evt) { @@ -102,20 +104,25 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ id: PluginV2.ID.make("google-vertex-anthropic"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.make("google-vertex-anthropic")) return - const project = - evt.provider.options.aisdk.provider.project ?? - process.env.GOOGLE_CLOUD_PROJECT ?? - process.env.GCP_PROJECT ?? - process.env.GCLOUD_PROJECT - const location = - evt.provider.options.aisdk.provider.location ?? - process.env.GOOGLE_CLOUD_LOCATION ?? - process.env.VERTEX_LOCATION ?? - "global" - if (project) evt.provider.options.aisdk.provider.project = project - evt.provider.options.aisdk.provider.location = location + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + if (item.provider.endpoint.type !== "aisdk") continue + if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue + const project = + item.provider.options.aisdk.provider.project ?? + process.env.GOOGLE_CLOUD_PROJECT ?? + process.env.GCP_PROJECT ?? + process.env.GCLOUD_PROJECT + const location = + item.provider.options.aisdk.provider.location ?? + process.env.GOOGLE_CLOUD_LOCATION ?? + process.env.VERTEX_LOCATION ?? + "global" + evt.provider.update(item.provider.id, (provider) => { + if (project) provider.options.aisdk.provider.project = project + provider.options.aisdk.provider.location = location + }) + } }), "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 47b8ec99cd..17436d9f96 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,15 +1,20 @@ import { Effect } from "effect" import { PluginV2 } from "../../plugin" -import { ProviderV2 } from "../../provider" export const KiloPlugin = PluginV2.define({ id: PluginV2.ID.make("kilo"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.make("kilo")) return - evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" - evt.provider.options.headers["X-Title"] = "opencode" + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + 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 + evt.provider.update(item.provider.id, (provider) => { + provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.options.headers["X-Title"] = "opencode" + }) + } }), } }), diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index da1ab282bd..de1872935c 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,17 +1,22 @@ import { Effect } from "effect" import { PluginV2 } from "../../plugin" -import { ProviderV2 } from "../../provider" export const LLMGatewayPlugin = PluginV2.define({ id: PluginV2.ID.make("llmgateway"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.make("llmgateway")) return - if (evt.provider.enabled === false) return - evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" - evt.provider.options.headers["X-Title"] = "opencode" - evt.provider.options.headers["X-Source"] = "opencode" + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + if (item.provider.enabled === false) continue + if (item.provider.endpoint.type !== "aisdk") continue + if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue + if (item.provider.endpoint.url !== "https://api.llmgateway.io/v1") continue + evt.provider.update(item.provider.id, (provider) => { + provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.options.headers["X-Title"] = "opencode" + provider.options.headers["X-Source"] = "opencode" + }) + } }), } }), diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 49ef6af0f6..74f7af0225 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,16 +1,21 @@ import { Effect } from "effect" import { PluginV2 } from "../../plugin" -import { ProviderV2 } from "../../provider" export const NvidiaPlugin = PluginV2.define({ id: PluginV2.ID.make("nvidia"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return - evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" - evt.provider.options.headers["X-Title"] = "opencode" - evt.provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode" + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + 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 + evt.provider.update(item.provider.id, (provider) => { + provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.options.headers["X-Title"] = "opencode" + provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode" + }) + } }), } }), diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index a81455f198..d76d7417f1 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -16,11 +16,17 @@ export const OpenAIPlugin = PluginV2.define({ if (evt.model.providerID !== ProviderV2.ID.openai) return evt.language = evt.sdk.responses(evt.model.apiID) }), - "model.update": Effect.fn(function* (evt) { - if (evt.model.providerID !== ProviderV2.ID.openai) return - // OpenAIPlugin sends OpenAI models through Responses; this alias is a - // chat-completions-only model, so remove it only from OpenAI's catalog. - if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + 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 + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // OpenAIPlugin sends OpenAI models through Responses; this alias is a + // chat-completions-only model, so hide it only from OpenAI's catalog. + model.enabled = false + }) + } }), } }), diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 10bbb62dad..411cbe091e 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -7,20 +7,25 @@ export const OpencodePlugin = PluginV2.define({ effect: Effect.gen(function* () { let hasKey = false return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.opencode) return + "catalog.transform": Effect.fn(function* (evt) { + const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.opencode) + if (!item) return hasKey = Boolean( process.env.OPENCODE_API_KEY || - evt.provider.env.some((item) => process.env[item]) || - evt.provider.options.aisdk.provider.apiKey || - (evt.provider.enabled && evt.provider.enabled.via === "auth"), + item.provider.env.some((env) => process.env[env]) || + item.provider.options.aisdk.provider.apiKey || + (item.provider.enabled && item.provider.enabled.via === "account"), ) - if (!hasKey) evt.provider.options.aisdk.provider.apiKey = "public" - }), - "model.update": Effect.fn(function* (evt) { - if (evt.model.providerID !== ProviderV2.ID.opencode) return + evt.provider.update(item.provider.id, (provider) => { + if (!hasKey) provider.options.aisdk.provider.apiKey = "public" + }) if (hasKey) return - if (evt.model.cost.some((item) => item.input > 0)) evt.cancel = true + for (const model of item.models.values()) { + if (!model.cost.some((cost) => cost.input > 0)) continue + evt.model.update(item.provider.id, model.id, (draft) => { + draft.enabled = false + }) + } }), } }), diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 976eea8c05..317f48158a 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,29 +1,34 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" import { PluginV2 } from "../../plugin" -import { ProviderV2 } from "../../provider" export const OpenRouterPlugin = PluginV2.define({ id: PluginV2.ID.make("openrouter"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.openrouter) return - evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" - evt.provider.options.headers["X-Title"] = "opencode" + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + if (item.provider.endpoint.type !== "aisdk") continue + if (item.provider.endpoint.package !== "@openrouter/ai-sdk-provider") continue + evt.provider.update(item.provider.id, (provider) => { + provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.options.headers["X-Title"] = "opencode" + }) + for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) { + if (!item.models.has(modelID)) continue + evt.model.update(item.provider.id, modelID, (model) => { + // These are OpenRouter-specific OpenAI chat aliases that do not work + // on the generic path. Keep custom providers with matching IDs untouched. + model.enabled = false + }) + } + } }), "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) evt.sdk = mod.createOpenRouter(evt.options) }), - "model.update": Effect.fn(function* (evt) { - if (evt.model.providerID !== ProviderV2.ID.openrouter) return - // These are OpenRouter-specific OpenAI chat aliases that do not work on - // the generic path. Keep custom providers with matching IDs untouched. - if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true - if (evt.model.id === ModelV2.ID.make("openai/gpt-5-chat")) evt.cancel = true - }), } }), }) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 2108542b16..a50e6768ed 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -6,10 +6,15 @@ export const VercelPlugin = PluginV2.define({ id: PluginV2.ID.make("vercel"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.make("vercel")) return - evt.provider.options.headers["http-referer"] = "https://opencode.ai/" - evt.provider.options.headers["x-title"] = "opencode" + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + if (item.provider.endpoint.type !== "aisdk") continue + if (item.provider.endpoint.package !== "@ai-sdk/vercel") continue + evt.provider.update(item.provider.id, (provider) => { + provider.options.headers["http-referer"] = "https://opencode.ai/" + provider.options.headers["x-title"] = "opencode" + }) + } }), "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 6bdd426010..3b0fcff165 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,15 +1,20 @@ import { Effect } from "effect" import { PluginV2 } from "../../plugin" -import { ProviderV2 } from "../../provider" export const ZenmuxPlugin = PluginV2.define({ id: PluginV2.ID.make("zenmux"), effect: Effect.gen(function* () { return { - "provider.update": Effect.fn(function* (evt) { - if (evt.provider.id !== ProviderV2.ID.make("zenmux")) return - evt.provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/" - evt.provider.options.headers["X-Title"] ??= "opencode" + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.data) { + 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 + evt.provider.update(item.provider.id, (provider) => { + provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/" + provider.options.headers["X-Title"] ??= "opencode" + }) + } }), } }), diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 7c1c966654..7ba2172ada 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -86,7 +86,7 @@ export class Info extends Schema.Class("ProviderV2.Info")({ name: Schema.String, }), Schema.Struct({ - via: Schema.Literal("auth"), + via: Schema.Literal("account"), service: Schema.String, }), Schema.Struct({ diff --git a/packages/core/src/util/wildcard.ts b/packages/core/src/util/wildcard.ts new file mode 100644 index 0000000000..0a67817bcb --- /dev/null +++ b/packages/core/src/util/wildcard.ts @@ -0,0 +1,14 @@ +export * as Wildcard from "./wildcard" + +export function match(input: string, pattern: string) { + const normalized = input.replaceAll("\\", "/") + let escaped = pattern + .replaceAll("\\", "/") + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, ".") + + if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?" + + return new RegExp("^" + escaped + "$", process.platform === "win32" ? "si" : "s").test(normalized) +} diff --git a/packages/core/test/account.test.ts b/packages/core/test/account.test.ts new file mode 100644 index 0000000000..cf60740b1e --- /dev/null +++ b/packages/core/test/account.test.ts @@ -0,0 +1,284 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { produce } from "immer" +import { Effect, Fiber, Layer, Option, Stream } from "effect" +import { AccountV2 } from "@opencode-ai/core/account" +import { Catalog } from "@opencode-ai/core/catalog" +import { EventV2 } from "@opencode-ai/core/event" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AccountPlugin } from "@opencode-ai/core/plugin/account" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +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 { + 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: { + update: (providerID, fn) => { + const record = records.find((item) => item.provider.id === providerID) + const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn) + if (record) record.provider = provider + else records.push({ provider, models: new Map() }) + updates.push({ + id: providerID, + enabled: provider.enabled, + apiKey: + typeof provider.options.aisdk.provider.apiKey === "string" + ? provider.options.aisdk.provider.apiKey + : undefined, + }) + }, + remove: (providerID) => { + const index = records.findIndex((item) => item.provider.id === providerID) + if (index !== -1) records.splice(index, 1) + }, + }, + model: { + update: () => {}, + remove: () => {}, + }, + } +} + +function testLayer(dir: string) { + return AccountV2.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), + Layer.provide( + Global.layerWith({ + data: dir, + cache: path.join(dir, "cache"), + config: path.join(dir, "config"), + state: path.join(dir, "state"), + tmp: path.join(dir, "tmp"), + bin: path.join(dir, "bin"), + log: path.join(dir, "log"), + repos: path.join(dir, "repos"), + }), + ), + ) +} + +describe("AccountV2", () => { + it.live("emits account lifecycle events", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const accounts = yield* AccountV2.Service + const eventSvc = yield* EventV2.Service + const addedFiber = yield* eventSvc + .subscribe(AccountV2.Event.Added) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) + const switchedFiber = yield* eventSvc + .subscribe(AccountV2.Event.Switched) + .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) + const removedFiber = yield* eventSvc + .subscribe(AccountV2.Event.Removed) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + + yield* Effect.yieldNow + + const first = yield* accounts.create({ + serviceID: AccountV2.ServiceID.make("provider"), + credential: new AccountV2.ApiKeyCredential({ type: "api", key: "raw-key" }), + }) + expect(first).toBeDefined() + if (!first) return + expect(first.description).toBe("default") + expect(first.credential.type).toBe("api") + if (first.credential.type === "api") expect(first.credential.key).toBe("raw-key") + + yield* accounts.update(first.id, { description: "keep" }) + const updated = yield* accounts.get(first.id) + expect(updated?.description).toBe("keep") + expect(updated?.credential.type).toBe("api") + if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key") + + const second = yield* accounts.create({ + serviceID: AccountV2.ServiceID.make("provider"), + credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }), + }) + expect(second).toBeDefined() + if (!second) return + + yield* accounts.remove(second.id) + const added = Array.from(yield* Fiber.join(addedFiber)) + const switched = Array.from(yield* Fiber.join(switchedFiber)) + const removed = Array.from(yield* Fiber.join(removedFiber)) + expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id]) + expect(switched.map((event) => event.data)).toEqual([ + { serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id }, + { serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id }, + { serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: first.id }, + ]) + expect(removed[0]?.data.account.id).toBe(second.id) + }).pipe(Effect.provide(testLayer(tmp.path))), + ), + ), + ) + + it.live("always switches to newly created accounts", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const accounts = yield* AccountV2.Service + const eventSvc = yield* EventV2.Service + const switchedFiber = yield* eventSvc + .subscribe(AccountV2.Event.Switched) + .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) + + yield* Effect.yieldNow + + const first = yield* accounts.create({ + serviceID: AccountV2.ServiceID.make("provider"), + credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }), + }) + const second = yield* accounts.create({ + serviceID: AccountV2.ServiceID.make("provider"), + credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }), + }) + const third = yield* accounts.create({ + serviceID: AccountV2.ServiceID.make("provider"), + credential: new AccountV2.ApiKeyCredential({ type: "api", key: "third-key" }), + }) + + expect(first).toBeDefined() + expect(second).toBeDefined() + expect(third).toBeDefined() + if (!first || !second || !third) return + + expect((yield* accounts.active(AccountV2.ServiceID.make("provider")))?.id).toBe(third.id) + expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([ + { serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id }, + { serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id }, + { serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: third.id }, + ]) + }).pipe(Effect.provide(testLayer(tmp.path))), + ), + ), + ) + + it.live("account plugin refreshes providers on account lifecycle events", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const accounts = yield* AccountV2.Service + const plugin = yield* PluginV2.Service + const records = [ + { + provider: ProviderV2.Info.empty(ProviderV2.ID.make("provider")), + models: new Map(), + }, + ] + const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = [] + const catalog = Catalog.Service.of({ + loader: () => Effect.die("unexpected catalog.loader"), + provider: { + get: () => Effect.die("unexpected provider.get"), + all: () => Effect.succeed([]), + available: () => Effect.succeed([]), + }, + model: { + get: () => Effect.die("unexpected model.get"), + all: () => Effect.succeed([]), + available: () => Effect.succeed([]), + default: () => Effect.succeed(Option.none()), + setDefault: () => Effect.die("unexpected model.setDefault"), + small: () => Effect.succeed(Option.none()), + }, + }) + + const eventSvc = yield* EventV2.Service + yield* plugin.add({ + ...AccountPlugin, + effect: AccountPlugin.effect.pipe( + Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(EventV2.Service, eventSvc), + Effect.provideService(PluginV2.Service, plugin), + ), + }) + yield* Effect.yieldNow + + const first = yield* accounts.create({ + serviceID: AccountV2.ServiceID.make("provider"), + credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }), + }) + expect(first).toBeDefined() + if (!first) return + yield* plugin.trigger("catalog.transform", context(records, updates), {}) + expect(updates).toEqual([ + { + id: ProviderV2.ID.make("provider"), + enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + apiKey: "first-key", + }, + ]) + + updates.length = 0 + const second = yield* accounts.create({ + serviceID: AccountV2.ServiceID.make("provider"), + credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }), + }) + expect(second).toBeDefined() + if (!second) return + yield* plugin.trigger("catalog.transform", context(records, updates), {}) + expect(updates).toEqual([ + { + id: ProviderV2.ID.make("provider"), + enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + apiKey: "second-key", + }, + ]) + + updates.length = 0 + yield* accounts.activate(first.id) + yield* plugin.trigger("catalog.transform", context(records, updates), {}) + expect(updates).toEqual([ + { + id: ProviderV2.ID.make("provider"), + enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + apiKey: "first-key", + }, + ]) + + updates.length = 0 + yield* accounts.remove(first.id) + yield* plugin.trigger("catalog.transform", context(records, updates), {}) + expect(updates).toEqual([ + { + id: ProviderV2.ID.make("provider"), + enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + apiKey: "second-key", + }, + ]) + + updates.length = 0 + yield* accounts.remove(second.id) + yield* plugin.trigger("catalog.transform", context(records, updates), {}) + expect(updates).toEqual([]) + }).pipe(Effect.provide(testLayer(tmp.path))), + ), + ), + ) +}) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 594f42d1c8..7ca784e471 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect" +import { DateTime, Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" @@ -22,24 +22,20 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") + const load = yield* catalog.loader() - yield* catalog.provider.update(providerID, (provider) => { - provider.endpoint = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://default.example.com", - } - provider.options.aisdk.provider.baseURL = "https://override.example.com" - }) + yield* load((catalog) => + catalog.provider.update(providerID, (provider) => { + provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://default.example.com" } + provider.options.aisdk.provider.baseURL = "https://override.example.com" + }), + ) - const provider = yield* catalog.provider.get(providerID) - - expect(provider.endpoint).toEqual({ + expect((yield* catalog.provider.get(providerID)).endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://override.example.com", }) - expect(provider.options.aisdk.provider.baseURL).toBeUndefined() }), ) @@ -48,56 +44,23 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") + const load = yield* catalog.loader() - yield* catalog.provider.update(providerID, (provider) => { - provider.endpoint = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://provider.example.com", - } - }) - yield* catalog.model.update(providerID, modelID, (model) => { - model.endpoint = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://model.example.com", - } - model.options.aisdk.provider.baseURL = "https://override.example.com" + yield* load((catalog) => { + catalog.provider.update(providerID, (provider) => { + provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://provider.example.com" } + }) + catalog.model.update(providerID, modelID, (model) => { + model.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://model.example.com" } + model.options.aisdk.provider.baseURL = "https://override.example.com" + }) }) - const model = yield* catalog.model.get(providerID, modelID) - - expect(model.endpoint).toEqual({ + expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://override.example.com", }) - expect(model.options.aisdk.provider.baseURL).toBeUndefined() - }), - ) - - it.effect("publishes model updated events", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const events = yield* EventV2.Service - const providerID = ProviderV2.ID.make("test") - const modelID = ModelV2.ID.make("model") - const fiber = yield* events - .subscribe(Catalog.Event.ModelUpdated) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) - - yield* Effect.yieldNow - yield* catalog.provider.update(providerID, () => {}) - yield* catalog.model.update(providerID, modelID, (model) => { - model.name = "Updated Model" - }) - const event = Array.from(yield* Fiber.join(fiber))[0] - - expect(event?.type).toBe("catalog.model.updated") - expect(event?.data.model.providerID).toBe(providerID) - expect(event?.data.model.id).toBe(modelID) - expect(event?.data.model.name).toBe("Updated Model") - expect(event?.location).toEqual({ directory: "test" }) }), ) @@ -106,19 +69,16 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") + const load = yield* catalog.loader() - yield* catalog.provider.update(providerID, (provider) => { - provider.endpoint = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://provider.example.com", - } + yield* load((catalog) => { + catalog.provider.update(providerID, (provider) => { + provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://provider.example.com" } + }) + catalog.model.update(providerID, modelID, () => {}) }) - yield* catalog.model.update(providerID, modelID, () => {}) - const model = yield* catalog.model.get(providerID, modelID) - - expect(model.endpoint).toEqual({ + expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://provider.example.com", @@ -126,58 +86,82 @@ describe("CatalogV2", () => { }), ) - it.effect("runs provider hooks after baseURL is normalized", () => + it.effect("runs catalog transform hooks after baseURL is normalized", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("test") const seen: unknown[] = [] + const load = yield* catalog.loader() yield* plugin.add({ id: PluginV2.ID.make("test"), effect: Effect.succeed({ - "provider.update": (evt) => + "catalog.transform": (evt) => Effect.sync(() => { - seen.push(evt.provider.endpoint.type) - if (evt.provider.endpoint.type === "aisdk") seen.push(evt.provider.endpoint.url) - seen.push(evt.provider.options.aisdk.provider.baseURL) + const item = evt.data.find((record) => record.provider.id === providerID) + if (!item) return + seen.push(item.provider.endpoint.type) + if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url) + seen.push(item?.provider.options.aisdk.provider.baseURL) }), }), }) - yield* catalog.provider.update(providerID, (provider) => { - provider.endpoint = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - } - provider.options.aisdk.provider.baseURL = "https://provider.example.com" - }) + yield* load((catalog) => + catalog.provider.update(providerID, (provider) => { + provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" } + provider.options.aisdk.provider.baseURL = "https://provider.example.com" + }), + ) expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined]) }), ) + it.effect("runs catalog transform when a plugin is added", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const providerID = ProviderV2.ID.make("test") + const load = yield* catalog.loader() + + yield* load((catalog) => catalog.provider.update(providerID, (provider) => { provider.name = "Before" })) + yield* plugin.add({ + id: PluginV2.ID.make("test-transform"), + effect: Effect.succeed({ + "catalog.transform": (evt) => Effect.sync(() => evt.provider.update(providerID, (provider) => { provider.name = "After" })), + }), + }) + yield* Effect.yieldNow + + expect((yield* catalog.provider.get(providerID)).name).toBe("After") + }), + ) + it.effect("resolves provider and model option merges", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") + const load = yield* catalog.loader() - yield* catalog.provider.update(providerID, (provider) => { - provider.options.headers.provider = "provider" - provider.options.headers.shared = "provider" - provider.options.body.provider = true - provider.options.aisdk.provider.provider = true - }) - yield* catalog.model.update(providerID, modelID, (model) => { - model.options.headers.model = "model" - model.options.headers.shared = "model" - model.options.body.model = true - model.options.aisdk.provider.model = true - model.options.aisdk.request.request = true + yield* load((catalog) => { + catalog.provider.update(providerID, (provider) => { + provider.options.headers.provider = "provider" + provider.options.headers.shared = "provider" + provider.options.body.provider = true + provider.options.aisdk.provider.provider = true + }) + catalog.model.update(providerID, modelID, (model) => { + model.options.headers.model = "model" + model.options.headers.shared = "model" + model.options.body.model = true + model.options.aisdk.provider.model = true + model.options.aisdk.request.request = true + }) }) const model = yield* catalog.model.get(providerID, modelID) - expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) expect(model.options.body).toEqual({ provider: true, model: true }) expect(model.options.aisdk.provider).toEqual({ provider: true, model: true }) @@ -189,20 +173,15 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") + const load = yield* catalog.loader() - yield* catalog.provider.update(providerID, (provider) => { - provider.enabled = { via: "custom", data: {} } - }) - yield* catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { - model.time.released = DateTime.makeUnsafe(1000) - }) - yield* catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { - model.time.released = DateTime.makeUnsafe(2000) + yield* load((catalog) => { + catalog.provider.update(providerID, (provider) => { provider.enabled = { via: "custom", data: {} } }) + catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { model.time.released = DateTime.makeUnsafe(1000) }) + catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { model.time.released = DateTime.makeUnsafe(2000) }) }) - const model = yield* catalog.model.default() - - expect(Option.getOrUndefined(model)?.id).toMatch("new") + expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new") }), ) @@ -210,24 +189,25 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") + const load = yield* catalog.loader() - yield* catalog.provider.update(providerID, () => {}) - yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] - model.time.released = DateTime.makeUnsafe(Date.now()) - }) - yield* catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] - model.time.released = DateTime.makeUnsafe(Date.now()) + yield* load((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] + model.time.released = DateTime.makeUnsafe(Date.now()) + }) + catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] + model.time.released = DateTime.makeUnsafe(Date.now()) + }) }) - const model = yield* catalog.model.small(providerID) - - expect(Option.getOrUndefined(model)?.id).toMatch("expensive-mini") + expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") }), ) }) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index c512ddacd9..38b2c2a7cf 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -5,6 +5,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { ModelsDev } from "@opencode-ai/core/models-dev" +import { EventV2 } from "@opencode-ai/core/event" import { it } from "./lib/effect" import { rm, writeFile, utimes, mkdir } from "fs/promises" import path from "path" @@ -92,6 +93,7 @@ const buildLayer = (state: Ref.Ref) => Layer.fresh(ModelsDev.layer).pipe( Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(EventV2.defaultLayer), ) const writeCache = (data: object, mtimeMs?: number) => diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index c70ada08d9..f2034eb1e3 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -1,7 +1,9 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" +import { ProviderV2 } from "@opencode-ai/core/provider" import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { @@ -20,27 +22,30 @@ describe("AmazonBedrockPlugin", () => { it.effect("moves endpoint option to endpoint URL", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("amazon-bedrock", { - options: { - headers: {}, - body: {}, - aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} }, - }, - }), - cancel: false, - }, - ) - expect(result.provider.endpoint).toEqual({ + const load = yield* catalog.loader() + yield* load((catalog) => { + const bedrock = provider("amazon-bedrock", { + endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, + options: { + headers: {}, + body: {}, + aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} }, + }, + }) + catalog.provider.update(bedrock.id, (item) => { + item.endpoint = bedrock.endpoint + item.options = bedrock.options + }) + }) + const result = yield* catalog.provider.get(ProviderV2.ID.amazonBedrock) + expect(result.endpoint).toEqual({ type: "aisdk", - package: "test-provider", + package: "@ai-sdk/amazon-bedrock", url: "https://bedrock.example", }) - expect(result.provider.options.aisdk.provider.endpoint).toBeUndefined() + expect(result.options.aisdk.provider.endpoint).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index bbea4a3721..0a1d5662d3 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -1,37 +1,43 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" +import { ProviderV2 } from "@opencode-ai/core/provider" import { it, model, provider } from "./provider-helper" describe("AnthropicPlugin", () => { it.effect("applies legacy beta headers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(AnthropicPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("anthropic", { - options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, - }), - cancel: false, - }, - ) - expect(result.provider.options.headers["anthropic-beta"]).toBe( + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("anthropic", { + endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" }, + options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, + }) + catalog.provider.update(item.id, (draft) => { + draft.endpoint = item.endpoint + draft.options = item.options + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers["anthropic-beta"]).toBe( "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", ) - expect(result.provider.options.headers.Existing).toBe("1") + expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers.Existing).toBe("1") }), ) it.effect("ignores non-Anthropic providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(AnthropicPlugin) - const result = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) - expect(result.provider.options.headers["anthropic-beta"]).toBeUndefined() + const load = yield* catalog.loader() + yield* load((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 b835cbeeff..8b8baacb7e 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -1,7 +1,9 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" +import { ProviderV2 } from "@opencode-ai/core/provider" import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" describe("AzureCognitiveServicesPlugin", () => { @@ -9,20 +11,22 @@ describe("AzureCognitiveServicesPlugin", () => { withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(AzureCognitiveServicesPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { provider: provider("azure-cognitive-services"), cancel: false }, - ) - expect(result.provider.endpoint).toEqual({ - type: "aisdk", - package: "test-provider", + const load = yield* catalog.loader() + yield* load((catalog) => { + catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { + item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" } + }) }) - expect(result.provider.options.aisdk.provider.baseURL).toBe( - "https://cognitive.cognitiveservices.azure.com/openai", - ) - expect(result.provider.options.aisdk.provider.resourceName).toBeUndefined() + const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")) + expect(result.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://cognitive.cognitiveservices.azure.com/openai", + }) + expect(result.options.aisdk.provider.baseURL).toBeUndefined() + expect(result.options.aisdk.provider.resourceName).toBeUndefined() }), ), ) @@ -31,17 +35,27 @@ describe("AzureCognitiveServicesPlugin", () => { withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(AzureCognitiveServicesPlugin) - const azure = yield* plugin.trigger( - "provider.update", - {}, - { provider: provider("azure-cognitive-services"), cancel: false }, - ) - const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) - expect(azure.provider.options.aisdk.provider.baseURL).toBeUndefined() - expect(azure.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" }) - expect(other.provider.options.aisdk.provider.baseURL).toBeUndefined() - expect(other.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" }) + const load = yield* catalog.loader() + yield* load((catalog) => { + const azure = provider("azure-cognitive-services", { + endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, + }) + const openai = provider("openai") + catalog.provider.update(azure.id, (item) => { + item.endpoint = azure.endpoint + }) + catalog.provider.update(openai.id, (item) => { + item.endpoint = openai.endpoint + }) + }) + const azure = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")) + const openai = yield* catalog.provider.get(ProviderV2.ID.openai) + expect(azure.options.aisdk.provider.baseURL).toBeUndefined() + expect(azure.endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" }) + expect(openai.options.aisdk.provider.baseURL).toBeUndefined() + expect(openai.endpoint).toEqual({ type: "aisdk", package: "test-provider" }) }), ), ) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 5121b1eec0..8c8995a372 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,22 +1,40 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { AuthV2 } from "@opencode-ai/core/auth" +import { AccountV2 } from "@opencode-ai/core/account" +import { Catalog } from "@opencode-ai/core/catalog" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" -import { AuthPlugin } from "@opencode-ai/core/plugin/auth" +import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" -const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) +const itWithAccount = testEffect( + Catalog.layer.pipe( + Layer.provideMerge(PluginV2.defaultLayer), + Layer.provideMerge(AccountV2.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provideMerge(npmLayer), + ), +) describe("AzurePlugin", () => { it.effect("resolves resourceName from env", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false }) - expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") + const load = yield* catalog.loader() + yield* load((catalog) => { + catalog.provider.update(ProviderV2.ID.azure, (item) => { + item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" } + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env") }), ), ) @@ -25,25 +43,29 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const azure = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("azure", { - options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } }, - }), - cancel: false, - }, + const load = yield* catalog.loader() + yield* load((catalog) => { + const azure = provider("azure", { + endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, + options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } }, + }) + catalog.provider.update(azure.id, (item) => { + item.endpoint = azure.endpoint + item.options = azure.options + }) + catalog.provider.update(ProviderV2.ID.openai, () => {}) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe( + "from-config", ) - const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) - expect(azure.provider.options.aisdk.provider.resourceName).toBe("from-config") - expect(other.provider.options.aisdk.provider.resourceName).toBeUndefined() + expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.resourceName).toBeUndefined() }), ), ) - itWithAuth.effect("prefers auth resourceName over env", () => + itWithAccount.effect("prefers account resourceName over env", () => withEnv( { AZURE_RESOURCE_NAME: "from-env", @@ -51,23 +73,36 @@ describe("AzurePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const auth = yield* AuthV2.Service - yield* auth.create({ - serviceID: AuthV2.ServiceID.make("azure"), - credential: new AuthV2.ApiKeyCredential({ + const accounts = yield* AccountV2.Service + const catalog = yield* Catalog.Service + const events = yield* EventV2.Service + yield* accounts.create({ + serviceID: AccountV2.ServiceID.make("azure"), + credential: new AccountV2.ApiKeyCredential({ type: "api", key: "key", - metadata: { resourceName: "from-auth" }, + metadata: { resourceName: "from-account" }, }), - active: true, }) yield* plugin.add({ - ...AuthPlugin, - effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + ...AccountPlugin, + effect: AccountPlugin.effect.pipe( + Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(EventV2.Service, events), + Effect.provideService(PluginV2.Service, plugin), + ), }) yield* plugin.add(AzurePlugin) - const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false }) - expect(result.provider.options.aisdk.provider.resourceName).toBe("from-auth") + const load = yield* catalog.loader() + yield* load((catalog) => { + catalog.provider.update(ProviderV2.ID.azure, (item) => { + item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" } + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe( + "from-account", + ) }), ), ) @@ -76,18 +111,20 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("azure", { - options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } }, - }), - cancel: false, - }, - ) - expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") + const load = yield* catalog.loader() + yield* load((catalog) => { + const azure = provider("azure", { + endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, + options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } }, + }) + catalog.provider.update(azure.id, (item) => { + item.endpoint = azure.endpoint + item.options = azure.options + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env") }), ), ) @@ -96,18 +133,20 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("azure", { - options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } }, - }), - cancel: false, - }, - ) - expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") + const load = yield* catalog.loader() + yield* load((catalog) => { + const azure = provider("azure", { + endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, + options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } }, + }) + catalog.provider.update(azure.id, (item) => { + item.endpoint = azure.endpoint + item.options = azure.options + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env") }), ), ) diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index 7270d5367a..3db579305a 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -1,7 +1,9 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" +import { ProviderV2 } from "@opencode-ai/core/provider" import { it, model, provider } from "./provider-helper" const cerebrasOptions: Record[] = [] @@ -20,27 +22,27 @@ describe("CerebrasPlugin", () => { it.effect("applies the legacy integration header", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(CerebrasPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("cerebras", { - options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, - }), - cancel: false, - }, - ) - expect(result.provider.options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" }) + const load = yield* catalog.loader() + yield* load((catalog) => { + catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { + item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" } + item.options.headers.Existing = "1" + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" }) }), ) it.effect("ignores non-Cerebras providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(CerebrasPlugin) - const result = yield* plugin.trigger("provider.update", {}, { provider: provider("groq"), cancel: false }) - expect(result.provider.options.headers).toEqual({}) + const load = yield* catalog.loader() + yield* load((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 10aba171c3..d1db7b27a1 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,14 +1,26 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { AuthV2 } from "@opencode-ai/core/auth" +import { AccountV2 } from "@opencode-ai/core/account" +import { Catalog } from "@opencode-ai/core/catalog" +import { Location } from "@opencode-ai/core/location" +import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" -import { AuthPlugin } from "@opencode-ai/core/plugin/auth" +import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" +import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" -const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) +const itWithAccount = testEffect( + Catalog.layer.pipe( + Layer.provideMerge(PluginV2.defaultLayer), + Layer.provideMerge(AccountV2.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provideMerge(npmLayer), + ), +) function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( @@ -34,22 +46,25 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const updated = yield* plugin.trigger( - "provider.update", - {}, - { provider: provider("cloudflare-workers-ai"), cancel: false }, + const load = yield* catalog.loader() + yield* load((catalog) => + catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { + provider.endpoint = { type: "aisdk", package: "test-provider" } + }), ) + const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")) const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { endpoint: updated.provider.endpoint }), + model: model("cloudflare-workers-ai", "@cf/model", { endpoint: provider.endpoint }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, }, {}, ) - expect(updated.provider.endpoint).toEqual({ + expect(provider.endpoint).toEqual({ type: "aisdk", package: "test-provider", url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1", @@ -63,18 +78,15 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("cloudflare-workers-ai", { - endpoint: { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }, - }), - cancel: false, - }, + const load = yield* catalog.loader() + yield* load((catalog) => + catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { + provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } + }), ) - expect(result.provider.endpoint).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({ type: "aisdk", package: "test-provider", url: "https://proxy.example/v1", @@ -104,7 +116,7 @@ describe("CloudflareWorkersAIPlugin", () => { ), ) - itWithAuth.effect("falls back to auth account metadata when account env is absent", () => + itWithAccount.effect("falls back to account metadata when account env is absent", () => withEnv( { CLOUDFLARE_ACCOUNT_ID: undefined, @@ -113,30 +125,37 @@ describe("CloudflareWorkersAIPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const auth = yield* AuthV2.Service - yield* auth.create({ - serviceID: AuthV2.ServiceID.make("cloudflare-workers-ai"), - credential: new AuthV2.ApiKeyCredential({ + const accounts = yield* AccountV2.Service + const catalog = yield* Catalog.Service + const events = yield* EventV2.Service + yield* accounts.create({ + serviceID: AccountV2.ServiceID.make("cloudflare-workers-ai"), + credential: new AccountV2.ApiKeyCredential({ type: "api", - key: "auth-key", - metadata: { accountId: "auth-acct" }, + key: "account-key", + metadata: { accountId: "account-acct" }, }), - active: true, }) yield* plugin.add({ - ...AuthPlugin, - effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + ...AccountPlugin, + effect: AccountPlugin.effect.pipe( + Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(EventV2.Service, events), + Effect.provideService(PluginV2.Service, plugin), + ), }) yield* plugin.add(CloudflareWorkersAIPlugin) - const updated = yield* plugin.trigger( - "provider.update", - {}, - { provider: provider("cloudflare-workers-ai"), cancel: false }, + const load = yield* catalog.loader() + yield* load((catalog) => + catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { + provider.endpoint = { type: "aisdk", package: "test-provider" } + }), ) - expect(updated.provider.endpoint).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({ type: "aisdk", package: "test-provider", - url: "https://api.cloudflare.com/client/v4/accounts/auth-acct/ai/v1", + url: "https://api.cloudflare.com/client/v4/accounts/account-acct/ai/v1", }) }), ), @@ -146,18 +165,16 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("cloudflare-workers-ai", { - options: { headers: {}, body: {}, aisdk: { provider: { accountId: "configured-acct" }, request: {} } }, - }), - cancel: false, - }, + const load = yield* catalog.loader() + yield* load((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" + }), ) - expect(result.provider.endpoint).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({ type: "aisdk", package: "test-provider", url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1", diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index e2bd899ff3..acaf0a15df 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -1,8 +1,10 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" +import { ProviderV2 } from "@opencode-ai/core/provider" import { fakeSelectorSdk, it, model } from "./provider-helper" describe("GithubCopilotPlugin", () => { @@ -145,29 +147,31 @@ describe("GithubCopilotPlugin", () => { }), ) - it.effect("filters gpt-5-chat-latest before Copilot language selection", () => + it.effect("disables gpt-5-chat-latest before Copilot language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(GithubCopilotPlugin) - const result = yield* plugin.trigger( - "model.update", - {}, - { model: model("github-copilot", "gpt-5-chat-latest"), cancel: false }, - ) - expect(result.cancel).toBe(true) + const load = yield* catalog.loader() + yield* load((catalog) => { + catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) + catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) + }) + expect((yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false) }), ) - it.effect("does not filter gpt-5-chat-latest for non-Copilot providers", () => + it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(GithubCopilotPlugin) - const result = yield* plugin.trigger( - "model.update", - {}, - { model: model("custom-copilot", "gpt-5-chat-latest"), cancel: false }, - ) - expect(result.cancel).toBe(false) + const load = yield* catalog.loader() + yield* load((catalog) => { + catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) + catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) + }) + expect((yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(true) }), ) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index 56a22649a9..e785fbbb7f 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,11 +1,15 @@ import { describe, expect, mock } from "bun:test" import { Effect, Layer } from "effect" -import { AuthV2 } from "@opencode-ai/core/auth" +import { AccountV2 } from "@opencode-ai/core/account" +import { Catalog } from "@opencode-ai/core/catalog" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" -import { AuthPlugin } from "@opencode-ai/core/plugin/auth" +import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { it, model, npmLayer, provider, withEnv } from "./provider-helper" +import { it, model, npmLayer, withEnv } from "./provider-helper" const gitlabSDKOptions: Record[] = [] @@ -22,7 +26,15 @@ void mock.module("gitlab-ai-provider", () => ({ isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact", })) -const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) +const itWithAccount = testEffect( + Catalog.layer.pipe( + Layer.provideMerge(PluginV2.defaultLayer), + Layer.provideMerge(AccountV2.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provideMerge(npmLayer), + ), +) describe("GitLabPlugin", () => { it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () => @@ -141,7 +153,7 @@ describe("GitLabPlugin", () => { }), ) - itWithAuth.effect("uses active API auth token over GITLAB_TOKEN", () => + itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () => withEnv( { GITLAB_TOKEN: "env-token", @@ -150,33 +162,41 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - const auth = yield* AuthV2.Service - yield* auth.create({ - serviceID: AuthV2.ServiceID.make("gitlab"), - credential: new AuthV2.ApiKeyCredential({ type: "api", key: "auth-token" }), - active: true, + const accounts = yield* AccountV2.Service + const catalog = yield* Catalog.Service + const events = yield* EventV2.Service + yield* accounts.create({ + serviceID: AccountV2.ServiceID.make("gitlab"), + credential: new AccountV2.ApiKeyCredential({ type: "api", key: "account-token" }), }) yield* plugin.add({ - ...AuthPlugin, - effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + ...AccountPlugin, + effect: AccountPlugin.effect.pipe( + Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(EventV2.Service, events), + Effect.provideService(PluginV2.Service, plugin), + ), }) yield* plugin.add(GitLabPlugin) - const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false }) + const load = yield* catalog.loader() + yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) + const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) yield* plugin.trigger( "aisdk.sdk", { model: model("gitlab", "claude"), package: "gitlab-ai-provider", - options: updated.provider.options.aisdk.provider, + options: provider.options.aisdk.provider, }, {}, ) - expect(gitlabSDKOptions[0].apiKey).toBe("auth-token") + expect(gitlabSDKOptions[0].apiKey).toBe("account-token") }), ), ) - itWithAuth.effect("uses active OAuth access token when no API auth exists", () => + itWithAccount.effect("uses active account OAuth access token when no API token exists", () => withEnv( { GITLAB_TOKEN: undefined, @@ -185,33 +205,41 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - const auth = yield* AuthV2.Service - yield* auth.create({ - serviceID: AuthV2.ServiceID.make("gitlab"), - credential: new AuthV2.OAuthCredential({ + const accounts = yield* AccountV2.Service + const catalog = yield* Catalog.Service + const events = yield* EventV2.Service + yield* accounts.create({ + serviceID: AccountV2.ServiceID.make("gitlab"), + credential: new AccountV2.OAuthCredential({ type: "oauth", refresh: "refresh-token", - access: "oauth-token", + access: "account-oauth-token", expires: 9999999999999, }), - active: true, }) yield* plugin.add({ - ...AuthPlugin, - effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + ...AccountPlugin, + effect: AccountPlugin.effect.pipe( + Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(EventV2.Service, events), + Effect.provideService(PluginV2.Service, plugin), + ), }) yield* plugin.add(GitLabPlugin) - const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false }) + const load = yield* catalog.loader() + yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) + const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) yield* plugin.trigger( "aisdk.sdk", { model: model("gitlab", "claude"), package: "gitlab-ai-provider", - options: updated.provider.options.aisdk.provider, + options: provider.options.aisdk.provider, }, {}, ) - expect(gitlabSDKOptions[0].apiKey).toBe("oauth-token") + expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token") }), ), ) 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 6bcece53c9..85a11baf82 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -1,8 +1,10 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" -import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" describe("GoogleVertexAnthropicPlugin", () => { it.effect("resolves legacy project and location env on provider update", () => @@ -18,14 +20,17 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { provider: provider("google-vertex-anthropic"), cancel: false }, + const load = yield* catalog.loader() + yield* load((catalog) => + catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { + provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } + }), ) - expect(result.provider.options.aisdk.provider.project).toBe("cloud-project") - expect(result.provider.options.aisdk.provider.location).toBe("cloud-location") + const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")) + expect(provider.options.aisdk.provider.project).toBe("cloud-project") + expect(provider.options.aisdk.provider.location).toBe("cloud-location") }), ), ) @@ -34,23 +39,19 @@ describe("GoogleVertexAnthropicPlugin", () => { withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("google-vertex-anthropic", { - options: { - headers: {}, - body: {}, - aisdk: { provider: { project: "configured-project", location: "configured-location" }, request: {} }, - }, - }), - cancel: false, - }, + const load = yield* catalog.loader() + yield* load((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" + provider.options.aisdk.provider.location = "configured-location" + }), ) - expect(result.provider.options.aisdk.provider.project).toBe("configured-project") - expect(result.provider.options.aisdk.provider.location).toBe("configured-location") + const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")) + expect(provider.options.aisdk.provider.project).toBe("configured-project") + expect(provider.options.aisdk.provider.location).toBe("configured-location") }), ), ) diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index 3bd60fd721..4c4e19ee85 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -1,8 +1,10 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" -import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" const vertexOptions: Record[] = [] @@ -43,24 +45,22 @@ describe("GoogleVertexPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("google-vertex", { - endpoint: { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", - }, - }), - cancel: false, - }, + const load = yield* catalog.loader() + yield* load((catalog) => + catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { + provider.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + } + }), ) - expect(result.provider.options.aisdk.provider.project).toBe("google-cloud-project") - expect(result.provider.options.aisdk.provider.location).toBe("google-vertex-location") - expect(result.provider.endpoint).toEqual({ + const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + expect(provider.options.aisdk.provider.project).toBe("google-cloud-project") + expect(provider.options.aisdk.provider.location).toBe("google-vertex-location") + expect(provider.endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location", @@ -84,21 +84,19 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const updated = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("google-vertex", { - endpoint: { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", - }, - }), - cancel: false, - }, + const load = yield* catalog.loader() + yield* load((catalog) => + catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { + provider.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + } + }), ) + const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) yield* plugin.trigger( "aisdk.sdk", { @@ -111,8 +109,8 @@ describe("GoogleVertexPlugin", () => { {}, ) - expect(updated.provider.options.aisdk.provider.project).toBe("vertex-project") - expect(updated.provider.endpoint).toEqual({ + expect(provider.options.aisdk.provider.project).toBe("vertex-project") + expect(provider.endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4", @@ -136,29 +134,24 @@ describe("GoogleVertexPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("google-vertex", { - endpoint: { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", - }, - options: { - headers: {}, - body: {}, - aisdk: { provider: { project: "config-project", location: "global" }, request: {} }, - }, - }), - cancel: false, - }, + const load = yield* catalog.loader() + yield* load((catalog) => + catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { + provider.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + } + provider.options.aisdk.provider.project = "config-project" + provider.options.aisdk.provider.location = "global" + }), ) - expect(result.provider.options.aisdk.provider.project).toBe("config-project") - expect(result.provider.options.aisdk.provider.location).toBe("global") - expect(result.provider.endpoint).toEqual({ + const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + expect(provider.options.aisdk.provider.project).toBe("config-project") + expect(provider.options.aisdk.provider.location).toBe("global") + expect(provider.endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global", @@ -180,19 +173,18 @@ describe("GoogleVertexPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("google-vertex", { - options: { headers: {}, body: {}, aisdk: { provider: { project: "config-project" }, request: {} } }, - }), - cancel: false, - }, + const load = yield* catalog.loader() + yield* load((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" + }), ) - expect(result.provider.options.aisdk.provider.project).toBe("config-project") - expect(result.provider.options.aisdk.provider.location).toBe("us-central1") + const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + expect(provider.options.aisdk.provider.project).toBe("config-project") + expect(provider.options.aisdk.provider.location).toBe("us-central1") }), ), ) diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index 0d67075ac8..1b8f1c65a0 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -2,12 +2,16 @@ import { Npm } from "@opencode-ai/core/npm" import type { LanguageModelV3 } from "@ai-sdk/provider" import { expect } from "bun:test" import { Effect, Layer, Option } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href +const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) export const npmLayer = Layer.succeed( Npm.Service, @@ -18,7 +22,34 @@ export const npmLayer = Layer.succeed( }), ) -export const it = testEffect(Layer.mergeAll(PluginV2.defaultLayer, npmLayer)) +export const catalogLayer = Layer.succeed( + Catalog.Service, + Catalog.Service.of({ + loader: () => Effect.die("unexpected catalog.loader"), + provider: { + get: () => Effect.die("unexpected provider.get"), + all: () => Effect.succeed([]), + available: () => Effect.succeed([]), + }, + model: { + get: () => Effect.die("unexpected model.get"), + all: () => Effect.succeed([]), + available: () => Effect.succeed([]), + default: () => Effect.succeed(Option.none()), + setDefault: () => Effect.die("unexpected model.setDefault"), + small: () => Effect.succeed(Option.none()), + }, + }), +) + +export const it = testEffect( + Catalog.layer.pipe( + Layer.provideMerge(PluginV2.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(locationLayer), + Layer.provideMerge(npmLayer), + ), +) export function provider(providerID: string, options?: Partial) { return new ProviderV2.Info({ diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 4261ae1328..d957049483 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -1,8 +1,10 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" +import { ProviderV2 } from "@opencode-ai/core/provider" import { expectPluginRegistered, it, provider } from "./provider-helper" describe("KiloPlugin", () => { @@ -18,73 +20,79 @@ describe("KiloPlugin", () => { it.effect("applies legacy referer headers only to kilo", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("kilo", { - options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, - }), - cancel: false, - }, - ) - const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) - expect(result.provider.options.headers).toEqual({ + const load = yield* catalog.loader() + yield* load((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: {} } }, + }) + catalog.provider.update(kilo.id, (draft) => { + draft.endpoint = kilo.endpoint + draft.options = kilo.options + }) + catalog.provider.update(provider("openrouter").id, () => {}) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(ignored.provider.options.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({}) }), ) it.effect("uses the exact legacy Kilo header casing and set", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const result = yield* plugin.trigger("provider.update", {}, { provider: provider("kilo"), cancel: false }) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" } }) + catalog.provider.update(item.id, (draft) => { + draft.endpoint = item.endpoint + }) + }) - expect(result.provider.options.headers).toEqual({ + const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo")) + expect(result.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(result.provider.options.headers).not.toHaveProperty("http-referer") - expect(result.provider.options.headers).not.toHaveProperty("x-title") - expect(result.provider.options.headers).not.toHaveProperty("X-Source") + expect(result.options.headers).not.toHaveProperty("http-referer") + expect(result.options.headers).not.toHaveProperty("x-title") + expect(result.options.headers).not.toHaveProperty("X-Source") }), ) it.effect("uses the legacy provider-id guard instead of endpoint package matching", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const matchingID = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("kilo", { - endpoint: { type: "aisdk", package: "not-kilo" }, - }), - cancel: false, - }, - ) - const matchingPackage = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("custom-kilo", { - endpoint: { type: "aisdk", package: "kilo" }, - }), - cancel: false, - }, - ) + const load = yield* catalog.loader() + yield* load((catalog) => { + const kilo = provider("kilo", { + endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, + }) + catalog.provider.update(kilo.id, (draft) => { + draft.endpoint = kilo.endpoint + }) + const custom = provider("custom-kilo", { + endpoint: { type: "aisdk", package: "kilo" }, + }) + catalog.provider.update(custom.id, (draft) => { + draft.endpoint = custom.endpoint + }) + }) - expect(matchingID.provider.options.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(matchingPackage.provider.options.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).options.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 1ffea96bcb..ace77a5602 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -1,8 +1,10 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" +import { ProviderV2 } from "@opencode-ai/core/provider" import { expectPluginRegistered, it, provider } from "./provider-helper" describe("LLMGatewayPlugin", () => { @@ -18,46 +20,52 @@ describe("LLMGatewayPlugin", () => { it.effect("applies legacy referer headers only to enabled llmgateway", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(LLMGatewayPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("llmgateway", { - enabled: { via: "env", name: "LLMGATEWAY_API_KEY" }, - options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, - }), - cancel: false, - }, - ) - const ignored = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("openrouter", { - enabled: { via: "env", name: "OPENROUTER_API_KEY" }, - }), - cancel: false, - }, - ) - expect(result.provider.options.headers).toEqual({ + const load = yield* catalog.loader() + yield* load((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" }, + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }) + catalog.provider.update(llmgateway.id, (draft) => { + draft.enabled = llmgateway.enabled + draft.endpoint = llmgateway.endpoint + draft.options = llmgateway.options + }) + const openrouter = provider("openrouter", { + enabled: { via: "env", name: "OPENROUTER_API_KEY" }, + }) + catalog.provider.update(openrouter.id, (draft) => { + draft.enabled = openrouter.enabled + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-Source": "opencode", }) - expect(ignored.provider.options.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({}) }), ) it.effect("does not apply legacy headers to a disabled llmgateway provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(LLMGatewayPlugin) - const result = yield* plugin.trigger("provider.update", {}, { provider: provider("llmgateway"), cancel: false }) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("llmgateway", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" } }) + catalog.provider.update(item.id, (draft) => { + draft.endpoint = item.endpoint + }) + }) - expect(result.provider.enabled).toBe(false) - expect(result.provider.options.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 26e7db0bfb..8bd9051bd8 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -1,8 +1,10 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" +import { ProviderV2 } from "@opencode-ai/core/provider" import { expectPluginRegistered, it, provider } from "./provider-helper" describe("NvidiaPlugin", () => { @@ -18,45 +20,48 @@ describe("NvidiaPlugin", () => { it.effect("applies NVIDIA tracking headers only to nvidia", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("nvidia", { - options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, - }), - cancel: false, - }, - ) - const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) - expect(result.provider.options.headers).toEqual({ + const load = yield* catalog.loader() + yield* load((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: {} } }, + }) + catalog.provider.update(nvidia.id, (draft) => { + draft.endpoint = nvidia.endpoint + draft.options = nvidia.options + }) + catalog.provider.update(provider("openrouter").id, () => {}) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", }) - expect(ignored.provider.options.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({}) }), ) it.effect("adds billing origin for custom NVIDIA endpoints", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("nvidia", { - endpoint: { type: "aisdk", package: "test-provider", url: "http://localhost:8000/v1" }, - options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } }, - }), - cancel: false, - }, - ) + const load = yield* catalog.loader() + yield* load((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: {} } }, + }) + catalog.provider.update(item.id, (draft) => { + draft.endpoint = item.endpoint + draft.options = item.options + }) + }) - expect(result.provider.options.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", @@ -67,23 +72,25 @@ describe("NvidiaPlugin", () => { it.effect("preserves an explicit NVIDIA billing origin header", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("nvidia", { - options: { - headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, - body: {}, - aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} }, - }, - }), - cancel: false, - }, - ) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("nvidia", { + endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, + options: { + headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, + body: {}, + aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} }, + }, + }) + catalog.provider.update(item.id, (draft) => { + draft.endpoint = item.endpoint + draft.options = item.options + }) + }) - expect(result.provider.options.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin", diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 31d6dd0b6d..81b16b993c 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,9 +1,11 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { fakeSelectorSdk, it, model, provider } from "./provider-helper" describe("OpenAIPlugin", () => { it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => @@ -70,31 +72,37 @@ describe("OpenAIPlugin", () => { }), ) - it.effect("cancels gpt-5-chat-latest during model updates", () => + it.effect("disables gpt-5-chat-latest during catalog transforms", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpenAIPlugin) - const normal = yield* plugin.trigger("model.update", {}, { model: model("openai", "gpt-5"), cancel: false }) - const filtered = yield* plugin.trigger( - "model.update", - {}, - { model: model("openai", "gpt-5-chat-latest"), cancel: false }, - ) - expect(normal.cancel).toBe(false) - expect(filtered.cancel).toBe(true) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } }) + catalog.provider.update(item.id, (draft) => { + draft.endpoint = item.endpoint + }) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {}) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) + }) + expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) + expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false) }), ) - it.effect("does not cancel gpt-5-chat-latest for non-OpenAI providers", () => + it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpenAIPlugin) - const result = yield* plugin.trigger( - "model.update", - {}, - { model: model("custom-openai", "gpt-5-chat-latest"), cancel: false }, - ) - expect(result.cancel).toBe(false) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("custom-openai") + catalog.provider.update(item.id, () => {}) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) + }) + expect((yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(true) }), ) }) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index ed82686a21..3f59a34977 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -12,19 +12,23 @@ const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) describe("OpencodePlugin", () => { - it.effect("uses a public key and cancels paid models without credentials", () => + it.effect("uses a public key and disables paid models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) - const paid = yield* plugin.trigger( - "model.update", - {}, - { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, - ) - expect(updated.provider.options.aisdk.provider.apiKey).toBe("public") - expect(paid.cancel).toBe(true) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("opencode") + catalog.provider.update(item.id, () => {}) + const paid = model("opencode", "paid", { cost: cost(1) }) + catalog.model.update(item.id, paid.id, (draft) => { + draft.cost = [...paid.cost] + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public") + expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) }), ), ) @@ -33,14 +37,19 @@ describe("OpencodePlugin", () => { withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) - const free = yield* plugin.trigger( - "model.update", - {}, - { model: model("opencode", "free", { cost: cost(0) }), cancel: false }, - ) - expect(free.cancel).toBe(false) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("opencode") + catalog.provider.update(item.id, () => {}) + const free = model("opencode", "free", { cost: cost(0) }) + catalog.model.update(item.id, free.id, (draft) => { + draft.cost = [...free.cost] + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public") + expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) }), ), ) @@ -49,14 +58,19 @@ describe("OpencodePlugin", () => { withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) - const outputOnly = yield* plugin.trigger( - "model.update", - {}, - { model: model("opencode", "output-only", { cost: cost(0, 1) }), cancel: false }, - ) - expect(outputOnly.cancel).toBe(false) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("opencode") + catalog.provider.update(item.id, () => {}) + const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) }) + catalog.model.update(item.id, outputOnly.id, (draft) => { + draft.cost = [...outputOnly.cost] + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public") + expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true) }), ), ) @@ -65,15 +79,19 @@ describe("OpencodePlugin", () => { withEnv({ OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) - const paid = yield* plugin.trigger( - "model.update", - {}, - { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, - ) - expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() - expect(paid.cancel).toBe(false) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("opencode") + catalog.provider.update(item.id, () => {}) + const paid = model("opencode", "paid", { cost: cost(1) }) + catalog.model.update(item.id, paid.id, (draft) => { + draft.cost = [...paid.cost] + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined() + expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -82,19 +100,21 @@ describe("OpencodePlugin", () => { withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const updated = yield* plugin.trigger( - "provider.update", - {}, - { provider: provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }), cancel: false }, - ) - const paid = yield* plugin.trigger( - "model.update", - {}, - { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, - ) - expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() - expect(paid.cancel).toBe(false) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }) + catalog.provider.update(item.id, (draft) => { + draft.env = [...item.env] + }) + const paid = model("opencode", "paid", { cost: cost(1) }) + catalog.model.update(item.id, paid.id, (draft) => { + draft.cost = [...paid.cost] + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined() + expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -103,31 +123,30 @@ describe("OpencodePlugin", () => { withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const updated = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("opencode", { - options: { - headers: {}, - body: {}, - aisdk: { - provider: { apiKey: "configured" }, - request: {}, - }, + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("opencode", { + options: { + headers: {}, + body: {}, + aisdk: { + provider: { apiKey: "configured" }, + request: {}, }, - }), - cancel: false, - }, - ) - const paid = yield* plugin.trigger( - "model.update", - {}, - { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, - ) - expect(updated.provider.options.aisdk.provider.apiKey).toBe("configured") - expect(paid.cancel).toBe(false) + }, + }) + catalog.provider.update(item.id, (draft) => { + draft.options = item.options + }) + const paid = model("opencode", "paid", { cost: cost(1) }) + catalog.model.update(item.id, paid.id, (draft) => { + draft.cost = [...paid.cost] + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("configured") + expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -136,19 +155,21 @@ describe("OpencodePlugin", () => { withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const updated = yield* plugin.trigger( - "provider.update", - {}, - { provider: provider("opencode", { enabled: { via: "auth", service: "opencode" } }), cancel: false }, - ) - const paid = yield* plugin.trigger( - "model.update", - {}, - { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, - ) - expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() - expect(paid.cancel).toBe(false) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("opencode", { enabled: { via: "account", service: "opencode" } }) + catalog.provider.update(item.id, (draft) => { + draft.enabled = item.enabled + }) + const paid = model("opencode", "paid", { cost: cost(1) }) + catalog.model.update(item.id, paid.id, (draft) => { + draft.cost = [...paid.cost] + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined() + expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -157,15 +178,19 @@ describe("OpencodePlugin", () => { withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) - const paid = yield* plugin.trigger( - "model.update", - {}, - { model: model("openai", "paid", { cost: cost(1) }), cancel: false }, - ) - expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() - expect(paid.cancel).toBe(false) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("openai") + catalog.provider.update(item.id, () => {}) + const paid = model("openai", "paid", { cost: cost(1) }) + catalog.model.update(item.id, paid.id, (draft) => { + draft.cost = [...paid.cost] + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.apiKey).toBeUndefined() + expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -175,18 +200,21 @@ describe("OpencodePlugin", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.opencode - yield* catalog.provider.update(providerID, () => {}) - yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = cost(1, 1) - model.time.released = DateTime.makeUnsafe(Date.now()) - }) - yield* catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = cost(10, 10) - model.time.released = DateTime.makeUnsafe(Date.now()) + const load = yield* catalog.loader() + yield* load((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [...cost(1, 1)] + model.time.released = DateTime.makeUnsafe(Date.now()) + }) + catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [...cost(10, 10)] + model.time.released = DateTime.makeUnsafe(Date.now()) + }) }) const selected = yield* catalog.model.small(providerID) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 3d143ac7f2..b5475dab4c 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -1,8 +1,11 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" +import { ProviderV2 } from "@opencode-ai/core/provider" import { expectPluginRegistered, it, model, provider } from "./provider-helper" describe("OpenRouterPlugin", () => { @@ -18,24 +21,27 @@ describe("OpenRouterPlugin", () => { it.effect("applies legacy referer headers only to openrouter", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("openrouter", { - options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, - }), - cancel: false, - }, - ) - const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("nvidia"), cancel: false }) - expect(result.provider.options.headers).toEqual({ + const load = yield* catalog.loader() + yield* load((catalog) => { + const openrouter = provider("openrouter", { + endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }) + catalog.provider.update(openrouter.id, (item) => { + item.endpoint = openrouter.endpoint + item.options = openrouter.options + }) + catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {}) + }) + + expect((yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).options.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(ignored.provider.options.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({}) }), ) @@ -67,39 +73,43 @@ describe("OpenRouterPlugin", () => { it.effect("filters OpenRouter's gpt-5 chat alias", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const result = yield* plugin.trigger( - "model.update", - {}, - { model: model("openrouter", "openai/gpt-5-chat"), cancel: false }, - ) - const regular = yield* plugin.trigger( - "model.update", - {}, - { model: model("openrouter", "openai/gpt-5"), cancel: false }, - ) - const ignored = yield* plugin.trigger( - "model.update", - {}, - { model: model("openai", "openai/gpt-5-chat"), cancel: false }, - ) + const load = yield* catalog.loader() + yield* load((catalog) => { + const openrouter = provider("openrouter", { endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" } }) + catalog.provider.update(openrouter.id, (item) => { + item.endpoint = openrouter.endpoint + }) + catalog.provider.update(ProviderV2.ID.openai, () => {}) + for (const item of [ + model("openrouter", "openai/gpt-5-chat"), + model("openrouter", "openai/gpt-5"), + model("openai", "openai/gpt-5-chat"), + ]) { + catalog.model.update(item.providerID, item.id, () => {}) + } + }) - expect(result.cancel).toBe(true) - expect(regular.cancel).toBe(false) - expect(ignored.cancel).toBe(false) + expect((yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(false) + expect((yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled).toBe(true) + expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(true) }), ) - it.effect("does not filter gpt-5-chat-latest for non-OpenRouter providers", () => + it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const result = yield* plugin.trigger( - "model.update", - {}, - { model: model("custom-openrouter", "gpt-5-chat-latest"), cancel: false }, - ) - expect(result.cancel).toBe(false) + const load = yield* catalog.loader() + yield* load((catalog) => { + catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) + catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) + }) + expect( + (yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + ).toBe(true) }), ) }) diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index 3134a7b83c..8d148b1a8d 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -1,25 +1,29 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" +import { ProviderV2 } from "@opencode-ai/core/provider" import { it, model, provider } from "./provider-helper" describe("VercelPlugin", () => { it.effect("applies legacy lower-case referer headers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("vercel", { - options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, - }), - cancel: false, - }, - ) - expect(result.provider.options.headers).toEqual({ + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("vercel", { + endpoint: { type: "aisdk", package: "@ai-sdk/vercel" }, + options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, + }) + catalog.provider.update(item.id, (draft) => { + draft.endpoint = item.endpoint + draft.options = item.options + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).toEqual({ Existing: "1", "http-referer": "https://opencode.ai/", "x-title": "opencode", @@ -30,10 +34,17 @@ describe("VercelPlugin", () => { it.effect("does not add legacy upper-case referer headers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const result = yield* plugin.trigger("provider.update", {}, { provider: provider("vercel"), cancel: false }) - expect(result.provider.options.headers).not.toHaveProperty("HTTP-Referer") - expect(result.provider.options.headers).not.toHaveProperty("X-Title") + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } }) + catalog.provider.update(item.id, (draft) => { + draft.endpoint = item.endpoint + }) + }) + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty("HTTP-Referer") + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty("X-Title") }), ) @@ -54,9 +65,11 @@ describe("VercelPlugin", () => { it.effect("ignores non-Vercel providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const result = yield* plugin.trigger("provider.update", {}, { provider: provider("gateway"), cancel: false }) - expect(result.provider.options.headers).toEqual({}) + const load = yield* catalog.loader() + yield* load((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 2b7730e6c7..6c4a2dcb59 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -1,8 +1,10 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" +import { ProviderV2 } from "@opencode-ai/core/provider" import { expectPluginRegistered, it, provider } from "./provider-helper" describe("ZenmuxPlugin", () => { @@ -18,30 +20,39 @@ describe("ZenmuxPlugin", () => { it.effect("applies the exact legacy Zenmux headers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const result = yield* plugin.trigger("provider.update", {}, { provider: provider("zenmux"), cancel: false }) - expect(result.provider.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) - expect(Object.keys(result.provider.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) - expect(result.cancel).toBe(false) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" } }) + catalog.provider.update(item.id, (draft) => { + draft.endpoint = item.endpoint + }) + }) + const result = yield* catalog.provider.get(ProviderV2.ID.make("zenmux")) + expect(result.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) + expect(Object.keys(result.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) }), ) it.effect("merges legacy Zenmux headers with existing headers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("zenmux", { - options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, - }), - cancel: false, - }, - ) + const load = yield* catalog.loader() + yield* load((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: {} } }, + }) + catalog.provider.update(item.id, (draft) => { + draft.endpoint = item.endpoint + draft.options = item.options + }) + }) - expect(result.provider.options.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", @@ -52,23 +63,25 @@ describe("ZenmuxPlugin", () => { it.effect("lets configured Zenmux legacy headers override defaults", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const result = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("zenmux", { - options: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - aisdk: { provider: {}, request: {} }, - }, - }), - cancel: false, - }, - ) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("zenmux", { + endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, + options: { + headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + }) + catalog.provider.update(item.id, (draft) => { + draft.endpoint = item.endpoint + draft.options = item.options + }) + }) - expect(result.provider.options.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({ "HTTP-Referer": "https://example.com/", "X-Title": "custom-title", }) @@ -78,23 +91,23 @@ describe("ZenmuxPlugin", () => { it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const ignored = yield* plugin.trigger( - "provider.update", - {}, - { - provider: provider("openrouter", { - options: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - aisdk: { provider: {}, request: {} }, - }, - }), - cancel: false, - }, - ) + const load = yield* catalog.loader() + yield* load((catalog) => { + const item = provider("openrouter", { + options: { + headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + }) + catalog.provider.update(item.id, (draft) => { + draft.options = item.options + }) + }) - expect(ignored.provider.options.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({ "HTTP-Referer": "https://example.com/", "X-Title": "custom-title", }) diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 836f581367..56866a0e02 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -1,18 +1,16 @@ import { EOL } from "os" -import { Effect, Layer, Option } from "effect" +import { Effect, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { effectCmd } from "../../effect-cmd" -const Runtime = Layer.mergeAll(LocationServiceMap.layer) - export const V2Command = effectCmd({ command: "v2", describe: "debug v2 catalog and built-in plugins", instance: false, - handler: Effect.fn("Cli.debug.v2")( - function* () { + handler: () => + Effect.gen(function* () { yield* PluginBoot.Service.use((service) => service.wait()) const catalog = yield* Catalog.Service const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id)) @@ -35,12 +33,13 @@ export const V2Command = effectCmd({ ), } process.stdout.write(JSON.stringify(result, null, 2) + EOL) - }, - Effect.provide( - LocationServiceMap.get({ - directory: process.cwd(), - }), + }).pipe( + Effect.withSpan("Cli.debug.v2"), + Effect.provide( + LocationServiceMap.get({ + directory: process.cwd(), + }), + ), + Effect.provide(LocationServiceMap.layer), ), - Effect.provide(Runtime), - ), }) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 303ba80190..b80a2389ef 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -458,7 +458,7 @@ export const RunCommand = effectCmd({ const name = title() const result = await sdk.session.create({ title: name, - permission: rules, + permission: [...rules], }) const id = result.data?.id if (!id) { @@ -501,7 +501,7 @@ export const RunCommand = effectCmd({ variant: input.variant, } : undefined, - permission: rules, + permission: [...rules], }) const id = result.data?.id if (!id) { diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index c8213254a5..7a731bb0cd 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -81,7 +81,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provideMerge(EventV2.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(SyncEvent.defaultLayer), Layer.provide(ProjectBus.defaultLayer), ) diff --git a/packages/opencode/src/permission/evaluate.ts b/packages/opencode/src/permission/evaluate.ts index 2b0604f4ba..6fd0576e97 100644 --- a/packages/opencode/src/permission/evaluate.ts +++ b/packages/opencode/src/permission/evaluate.ts @@ -1,15 +1 @@ -import { Wildcard } from "@/util/wildcard" - -type Rule = { - permission: string - pattern: string - action: "allow" | "deny" | "ask" -} - -export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule { - const rules = rulesets.flat() - const match = rules.findLast( - (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), - ) - return match ?? { action: "ask", permission, pattern: "*" } -} +export { evaluate } from "@opencode-ai/core/permission" diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 2f0813affa..f486eb43b1 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -8,15 +8,15 @@ import { PermissionTable } from "@/session/session.sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" -import { Wildcard } from "@/util/wildcard" +import { Wildcard } from "@opencode-ai/core/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" -import { evaluate as evalRule } from "./evaluate" +import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionID } from "./schema" const log = Log.create({ service: "permission" }) -export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) +export const Action = PermissionV2.Action.annotate({ identifier: "PermissionAction" }) export type Action = Schema.Schema.Type export const Rule = Schema.Struct({ @@ -26,7 +26,7 @@ export const Rule = Schema.Struct({ }).annotate({ identifier: "PermissionRule" }) export type Rule = Schema.Schema.Type -export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionRuleset" }) +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) export type Ruleset = Schema.Schema.Type export class Request extends Schema.Class("PermissionRequest")({ @@ -122,11 +122,11 @@ interface PendingEntry { interface State { pending: Map - approved: Ruleset + approved: Rule[] } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - return evalRule(permission, pattern, ...rulesets) + return PermissionV2.evaluate(permission, pattern, ...rulesets) } export class Service extends Context.Service()("@opencode/Permission") {} @@ -142,7 +142,7 @@ export const layer = Layer.effect( ) const state = { pending: new Map(), - approved: row?.data ?? [], + approved: [...(row?.data ?? [])], } yield* Effect.addFinalizer(() => @@ -271,7 +271,7 @@ function expand(pattern: string): string { } export function fromConfig(permission: ConfigPermission.Info) { - const ruleset: Ruleset = [] + const ruleset: Rule[] = [] for (const [key, value] of Object.entries(permission)) { if (typeof value === "string") { ruleset.push({ permission: key, action: value, pattern: "*" }) @@ -284,21 +284,12 @@ export function fromConfig(permission: ConfigPermission.Info) { return ruleset } -export function merge(...rulesets: Ruleset[]): Ruleset { - return rulesets.flat() +export function merge(...rulesets: Ruleset[]): Rule[] { + return [...PermissionV2.merge(...rulesets)] } -const EDIT_TOOLS = ["edit", "write", "apply_patch"] - export function disabled(tools: string[], ruleset: Ruleset): Set { - const result = new Set() - for (const tool of tools) { - const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) - if (!rule) continue - if (rule.pattern === "*" && rule.action === "deny") result.add(tool) - } - return result + return PermissionV2.disabled(tools, ruleset) } export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 6509aa2b1f..2e3617d146 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -159,9 +159,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (body.trim().length === 0) return yield* create({}) const json = yield* tryParseJson(body) - const payload = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe( + const decoded = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe( Effect.mapError(() => new HttpApiError.BadRequest({})), ) + const payload = decoded + ? { + ...decoded, + permission: decoded.permission ? [...decoded.permission] : undefined, + } + : decoded return yield* create({ payload }) }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c09ee86284..e39d0016ab 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1216,7 +1216,7 @@ export const layer = Layer.effect( const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) - const permissions: Permission.Ruleset = [] + const permissions: Permission.Rule[] = [] for (const [t, enabled] of Object.entries(input.tools ?? {})) { permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index ae7fb8b0eb..4edadbdd23 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -101,7 +101,7 @@ export function fromRow(row: SessionRow): Info { }, share, revert, - permission: row.permission ?? undefined, + permission: row.permission ? [...row.permission] : undefined, time: { created: row.time_created, updated: row.time_updated, @@ -542,7 +542,7 @@ export const layer: Layer.Layer< title: input.title ?? createDefaultTitle(!!input.parentID), agent: input.agent, model: input.model, - permission: input.permission, + permission: input.permission ? [...input.permission] : undefined, cost: 0, tokens: EmptyTokens, time: { @@ -734,7 +734,7 @@ export const layer: Layer.Layer< sessionID: SessionID permission: Permission.Ruleset }) { - yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } }) + yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }) }) const setRevert = Effect.fn("Session.setRevert")(function* (input: { diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 47c944b4f4..a13b6c9deb 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -4,7 +4,7 @@ import { SyncEvent } from "@/sync" import { Effect, Layer, Scope, Context } from "effect" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" -import * as ShareNext from "./share-next" +import { ShareNext } from "./share-next" export interface Interface { readonly create: (input?: Session.CreateInput) => Effect.Effect diff --git a/packages/opencode/test/server/httpapi-event-diagnostics.test.ts b/packages/opencode/test/server/httpapi-event-diagnostics.test.ts index 95a6941a4a..c3ad3e0584 100644 --- a/packages/opencode/test/server/httpapi-event-diagnostics.test.ts +++ b/packages/opencode/test/server/httpapi-event-diagnostics.test.ts @@ -77,7 +77,7 @@ const publishPartUpdated = (partID: ReturnType) => { const subscribeAllCallback = (handler: (event: BusEvent) => void) => Effect.acquireRelease(inApp(Bus.Service.use((svc) => svc.subscribeAllCallback(handler))), (dispose) => - Effect.sync(dispose), + Effect.sync(() => dispose()), ) const openEventStream = (directory: string) => diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ca030e01ce..7825766ca1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3529,7 +3529,7 @@ export type ProviderV2Info = { name: string } | { - via: "auth" + via: "account" service: string } | { diff --git a/specs/v2/instructions.md b/specs/v2/instructions.md new file mode 100644 index 0000000000..e780e9821b --- /dev/null +++ b/specs/v2/instructions.md @@ -0,0 +1,121 @@ +# V2 Core Instructions + +These notes describe how to work on `packages/core` during the v2 port. + +## Direction + +Move behavior out of large application services and into plugins. Core services should become small, typed containers that own state, expose simple operations, and trigger hooks where policy or integration-specific logic belongs. + +The target shape is: + +- `packages/core` contains domain schemas, typed errors, state containers, events, and plugin hook contracts. +- Plugins implement provider-specific, config-specific, auth-specific, model-discovery, and generation behavior. +- Services are hot-reloadable by design: updates are granular, observable, and do not require tearing down the whole process. +- `packages/opencode` becomes thinner over time: UI, server routes, CLI, storage glue, and legacy compatibility should call the core services instead of owning domain logic directly. + +## Service Shape + +Core services should look like `Catalog`, `AccountV2`, and `AgentV2`: + +- define schemas and branded ids at the top of the module +- define typed `Schema.TaggedErrorClass` errors for expected failures +- define an `Interface` with small operations +- expose a `Context.Service` +- implement `layer` with private in-memory state +- expose `defaultLayer` with explicit dependencies +- self-export with `export * as Name from "./file"` + +Prefer a dumb container API: + +- `get`, `all`, `available`, `default`, `update`, `remove`, `activate`, or other small domain verbs +- `update(id, draft => ...)` for registration and mutation +- hook calls before committing mutations when plugins need to enrich, cancel, or validate changes +- events after committing mutations when other services or frontends need to react + +Avoid putting application policy directly in core services unless it is a domain invariant. For example, resolving model endpoint inheritance is catalog-owned; deciding which providers to register is plugin-owned. + +## Plugin Hooks + +Plugins are the extension boundary for v2. Add hooks to `PluginV2.HookSpec` when logic should be provided by integrations instead of the container itself. + +Hook conventions: + +- hooks receive immutable input plus mutable output +- mutable object outputs are exposed as Immer drafts +- include `cancel: boolean` when plugins can prevent a mutation +- trigger hooks sequentially so ordering remains deterministic +- keep hook names domain-oriented, like `provider.update`, `model.update`, `account.activate`, `agent.generate` +- keep hook payloads small and typed with core schemas + +Use hooks for: + +- registering providers and models +- applying env/account/config-derived enablement +- transforming SDK/provider options +- implementing generated behavior such as agent generation +- choosing defaults when the choice is policy rather than state + +Do not use hooks as a dumping ground for transport concerns, UI behavior, or compatibility shims. + +## Plugin Boot + +Built-in core plugins are registered by `packages/core/src/plugin/boot.ts`. + +When a new core service is intended to be available to plugins: + +- add the service to the boot layer dependency type +- yield the service inside the layer +- provide it to each plugin effect in `add` +- add its default layer to `PluginBoot.defaultLayer` only when that does not create a cycle + +Keep boot as composition only. It should not contain provider, account, agent, or model policy itself. + +## Boundaries + +Core should not import from `packages/opencode`. If a type or concept is needed by core, move or remodel the domain shape in core first. + +Avoid moving legacy services over wholesale. Port the domain shape and the container API, then leave specific behavior behind hooks for plugins to implement. + +When porting an opencode service: + +- identify the state it owns +- identify the operations callers actually need +- identify which branches are policy or integration behavior +- model state and operations in `packages/core` +- add hooks for the policy/integration branches +- keep old package code working until callers can migrate incrementally + +## Schemas And Types + +Use Effect schemas as the public contract: + +- branded schemas for ids +- `Schema.Class` or `Schema.Struct` for domain data +- `Schema.TaggedErrorClass` for expected errors +- existing core helpers like `DeepMutable`, `withStatics`, and integer schemas where appropriate + +Prefer `Info` objects as the stored domain records. Add static `empty(...)` constructors when update APIs need to create records on first mutation. + +Keep schemas stable and explicit. Do not rely on opencode config shapes as core domain shapes unless the config shape is actually the domain model. + +## State And Events + +Keep state private to the service layer. Use immutable replacement or Effect refs when persistence/concurrency requires it. + +Publish events for committed domain changes, not for attempted mutations. Event names should describe domain facts, for example `catalog.model.updated`. + +The v2 goal is granular reconfiguration. A model update should let dependents react to that model update; it should not require global reloads. + +## Style + +Follow the local core style: + +- `Effect.gen(function* () { ... })` for composition +- `Effect.fn("Domain.method")` for public service methods +- `Effect.fnUntraced` for small internal mutation helpers +- `yield* new ErrorClass(...)` for typed failures +- minimal helpers unless they name a real concept +- no `any` unless an existing plugin boundary requires it +- no compatibility code without a concrete persisted or external-consumer need + +Prefer the smallest correct port. The goal is to make services easier to replace and reason about, not to recreate the old architecture in a new package. diff --git a/specs/v2/provider-model.md b/specs/v2/provider-model.md index fb4598b58f..4860cb787d 100644 --- a/specs/v2/provider-model.md +++ b/specs/v2/provider-model.md @@ -232,7 +232,7 @@ export interface Interface { } ``` -`ProviderV2.Info.enabled` is stored provider state. Provider plugins set this field after checking env, auth, config, or provider-specific availability. +`ProviderV2.Info.enabled` is stored provider state. Provider plugins set this field after checking env, account, config, or provider-specific availability. `ProviderV2.Endpoint` includes `{ type: "unknown" }`. `CatalogV2.model.get()` and `CatalogV2.model.all()` resolve `unknown` endpoints from the provider before returning models. @@ -256,6 +256,46 @@ const available = provider.enabled && model.status !== "deprecated" ## Plugin Interface ```ts +type HookSpec = { + "account.update": { + input: { + id: AccountV2.ID + serviceID: AccountV2.ServiceID + } + output: { + description: string + credential: AccountV2.Credential + cancel: boolean + } + } + + "account.remove": { + input: { + account: AccountV2.Info + } + output: { + cancel: boolean + } + } + + "account.activate": { + input: {} + output: { + from?: AccountV2.ID + to: AccountV2.ID + cancel: boolean + } + } + + "account.activated": { + input: { + from?: AccountV2.ID + to: AccountV2.ID + } + output: {} + } +} + export type Definition = Effect.Effect< { readonly order: number @@ -280,7 +320,7 @@ export interface Interface { export const Order = { modelsDev: 0, env: 10, - auth: 20, + account: 20, provider: 30, config: 40, discovery: 50, @@ -294,21 +334,21 @@ export const ModelsDevPlugin: PluginV2.Definition -export const AuthPlugin: PluginV2.Definition +export const AccountPlugin: PluginV2.Definition export const ConfigPlugin: PluginV2.Definition -export const AnthropicPlugin: PluginV2.Definition +export const AnthropicPlugin: PluginV2.Definition export const OpenRouterPlugin: PluginV2.Definition -export const AmazonBedrockPlugin: PluginV2.Definition +export const AmazonBedrockPlugin: PluginV2.Definition -export const GoogleVertexPlugin: PluginV2.Definition +export const GoogleVertexPlugin: PluginV2.Definition -export const GitLabPlugin: PluginV2.Definition +export const GitLabPlugin: PluginV2.Definition -export const GitLabDiscoveryPlugin: PluginV2.Definition +export const GitLabDiscoveryPlugin: PluginV2.Definition ``` ## Plugin Hooks