diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml new file mode 100644 index 000000000..786a37744 --- /dev/null +++ b/packages/core/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/preload.ts"] diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index d32f93668..ed982cb6d 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -69,10 +69,10 @@ export const layer = Layer.effect( const policy = yield* Policy.Service const integrations = yield* Integration.Service - const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => { + const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { if (provider.disabled) return false if (typeof provider.request.body.apiKey === "string") return true - if (connected) return true + if (integration?.connections.length) return true return !integration } @@ -183,13 +183,8 @@ export const layer = Layer.effect( available: Effect.fn("CatalogV2.provider.available")(function* () { const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) - const connections = yield* integrations.connection.list() return (yield* result.provider.all()).filter((provider) => - available( - provider, - active.get(Integration.ID.make(provider.id)), - connections.has(Integration.ID.make(provider.id)), - ), + available(provider, active.get(Integration.ID.make(provider.id))), ) }), }, diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 937ec4a51..b01bb1d1f 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -29,33 +29,33 @@ export class Key extends Schema.Class("Credential.Key")({ metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) {} -export const Info = Schema.Union([OAuth, Key]) +export const Value = Schema.Union([OAuth, Key]) .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Credential.Info" }) -export type Info = Schema.Schema.Type + .annotate({ identifier: "Credential.Value" }) +export type Value = Schema.Schema.Type -export class Stored extends Schema.Class("Credential.Stored")({ +export class Info extends Schema.Class("Credential.Info")({ id: ID, integrationID: IntegrationSchema.ID, label: Schema.String, - value: Info, + value: Value, }) {} export interface Interface { /** Returns every stored credential. */ - readonly all: () => Effect.Effect + readonly all: () => Effect.Effect /** Returns stored credentials belonging to one integration. */ - readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect + readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect /** Returns one stored credential by ID. */ - readonly get: (id: ID) => Effect.Effect + readonly get: (id: ID) => Effect.Effect /** Replaces any credential for an integration and returns the new record. */ readonly create: (input: { readonly integrationID: IntegrationSchema.ID - readonly value: Info + readonly value: Value readonly label?: string - }) => Effect.Effect + }) => Effect.Effect /** Updates the label or secret value of a stored credential. */ - readonly update: (id: ID, updates: Partial>) => Effect.Effect + readonly update: (id: ID, updates: Partial>) => Effect.Effect /** Removes a stored credential. */ readonly remove: (id: ID) => Effect.Effect } @@ -66,10 +66,10 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service - const decode = Schema.decodeUnknownSync(Info) + const decode = Schema.decodeUnknownSync(Value) const stored = (row: typeof CredentialTable.$inferSelect) => { if (!row.integration_id) return - return new Stored({ + return new Info({ id: row.id, integrationID: row.integration_id, label: row.label, @@ -106,7 +106,7 @@ export const layer = Layer.effect( return row ? stored(row) : undefined }), create: Effect.fn("Credential.create")(function* (input) { - const credential = new Stored({ + const credential = new Info({ id: ID.create(), integrationID: input.integrationID, label: input.label ?? "default", diff --git a/packages/core/src/credential/sql.ts b/packages/core/src/credential/sql.ts index a849092ea..3afd7284a 100644 --- a/packages/core/src/credential/sql.ts +++ b/packages/core/src/credential/sql.ts @@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", { id: text().$type().primaryKey(), integration_id: text().$type(), label: text().notNull(), - value: text({ mode: "json" }).$type().notNull(), + value: text({ mode: "json" }).$type().notNull(), connector_id: text(), method_id: text(), active: integer({ mode: "boolean" }), diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 5c044ec60..5190e5838 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -241,32 +241,16 @@ export default { `) yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`) yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`) - yield* tx.run( - `CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`, - ) - yield* tx.run( - `CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`, - ) + yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`) + yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) - yield* tx.run( - `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, - ) - yield* tx.run( - `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, - ) - yield* tx.run( - `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`, - ) - yield* tx.run( - `CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`, - ) - yield* tx.run( - `CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`, - ) - yield* tx.run( - `CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`, - ) + yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`) + yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`) + yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`) + yield* tx.run(`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`) + yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`) + yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`) yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`) yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 3257fe884..7f6ae60ae 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -123,6 +123,6 @@ const baseLayer = Layer.effect( }), ) -export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer)) +export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer)) export const locationLayer = layer diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 0f123f5b9..c019b8034 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -232,6 +232,6 @@ export const fffLayer = Layer.effect( }), ) -export const defaultLayer = Layer.unwrap( +export const locationLayer = Layer.unwrap( Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)), ) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 4bd27ffd3..f9081525b 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -108,11 +108,11 @@ export type OAuthAuthorization = { } & ( | { readonly mode: "auto" - readonly callback: Effect.Effect + readonly callback: Effect.Effect } | { readonly mode: "code" - readonly callback: (code: string) => Effect.Effect + readonly callback: (code: string) => Effect.Effect } ) @@ -214,8 +214,6 @@ export interface Interface extends State.Transformable { /** Returns all integrations with their methods and current connections. */ readonly list: () => Effect.Effect readonly connection: { - /** Returns active connections for every registered or credential-backed integration. */ - readonly list: () => Effect.Effect> /** Returns the active connection for one integration. */ readonly forIntegration: (id: ID) => Effect.Effect /** Runs a key method and stores the resulting credential. */ @@ -241,7 +239,7 @@ export interface Interface extends State.Transformable { /** Updates a stored credential exposed as a connection. */ readonly update: ( credentialID: Credential.ID, - updates: Partial>, + updates: Partial>, ) => Effect.Effect /** Removes a stored credential connection. */ readonly remove: (credentialID: Credential.ID) => Effect.Effect @@ -353,39 +351,25 @@ export const locationLayer = Layer.effect( finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), }) - const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => { - const connected = saved.map((credential) => ({ + const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => { + const credentials = saved.map((credential) => ({ type: "credential" as const, id: credential.id, label: credential.label, - })) - const detected = entry.methods + })).toReversed() + const env = (entry?.methods ?? []) .filter((method) => method.type === "env") .flatMap((method) => method.names.filter((name) => process.env[name])) .map((name) => ({ type: "env" as const, name })) - return [...connected, ...detected] + return [...credentials, ...env] } - const activeConnection = ( - entry: Entry | undefined, - saved: readonly Credential.Stored[], - ): IntegrationConnection.Info | undefined => { - const credential = saved.at(-1) - if (credential) return { type: "credential", id: credential.id, label: credential.label } - if (!entry) return - const name = entry.methods - .filter((method) => method.type === "env") - .flatMap((method) => method.names) - .find((name) => process.env[name]) - if (name) return { type: "env", name } - } - - const project = (entry: Entry, saved: readonly Credential.Stored[]) => + const project = (entry: Entry, connections: IntegrationConnection.Info[]) => new Info({ id: entry.ref.id, name: entry.ref.name, methods: entry.methods, - connections: connections(entry, saved), + connections, }) const authorize = (effect: Effect.Effect) => @@ -399,7 +383,7 @@ export const locationLayer = Layer.effect( return error instanceof Error ? error.message : String(error) } - const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { + const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { const now = yield* Clock.currentTimeMillis const result = yield* SynchronizedRef.modify(attempts, (current) => { const attempt = current.get(attemptID) @@ -450,28 +434,18 @@ export const locationLayer = Layer.effect( get: Effect.fn("Integration.get")(function* (id) { const entry = state.get().integrations.get(id) if (!entry) return undefined - return project(entry, yield* credentials.list(id)) + return project(entry, resolveConnections(entry, yield* credentials.list(id))) }), list: Effect.fn("Integration.list")(function* () { - return (yield* Effect.forEach(state.get().integrations.values(), (entry) => - Effect.gen(function* () { - return project(entry, yield* credentials.list(entry.ref.id)) - }), - )).toSorted((a, b) => a.name.localeCompare(b.name)) + const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) + return Array.from(state.get().integrations.values(), (entry) => + project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])), + ).toSorted((a, b) => a.name.localeCompare(b.name)) }), connection: { - list: Effect.fn("Integration.connection.list")(function* () { - const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) - return new Map( - new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => { - const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? []) - return connection ? [[id, connection] as const] : [] - }), - ) - }), forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) { const entry = state.get().integrations.get(id) - return activeConnection(entry, yield* credentials.list(id)) + return resolveConnections(entry, yield* credentials.list(id))[0] }), key: Effect.fn("Integration.connection.key")(function* (input) { const method = state diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index d4e617ebf..787c62c19 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -44,7 +44,7 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) -const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => { +const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => { if (credential?.value.type === "key") return Auth.value(credential.value.key) if (credential?.value.type === "oauth") return Auth.value(credential.value.access) const value = model.request.body.apiKey ?? model.api.settings?.apiKey @@ -85,7 +85,7 @@ const apiName = (model: ModelV2.Info) => export const fromCatalogModel = ( model: ModelV2.Info, connection?: IntegrationConnection.Info, - credential?: Credential.Stored, + credential?: Credential.Info, ): Effect.Effect => { const resolved = credential?.value.metadata === undefined diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index cc1051bc2..bb4b256f8 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -11,7 +11,11 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -import { required } from "./plugin/provider-helper" + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} const locationLayer = Layer.succeed( Location.Service, @@ -21,12 +25,7 @@ const it = testEffect( Catalog.locationLayer.pipe( Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.succeed([]), - list: () => Effect.succeed([]), - }), - ), + Layer.provideMerge(Credential.defaultLayer), ), ) @@ -48,38 +47,30 @@ describe("CatalogV2", () => { it.effect("derives availability from active credentials without changing provider state", () => { const integrationID = Integration.ID.make("test") - const first = { - id: Credential.ID.create(), - integrationID, - label: "First", - value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }), - } - const second = { - id: Credential.ID.create(), - integrationID, - label: "Second", - value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }), - } - let active = first const layer = Catalog.locationLayer.pipe( Layer.fresh, Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.sync(() => [active]), - list: () => Effect.sync(() => [active]), - }), - ), + Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)), ) return Effect.gen(function* () { const catalog = yield* Catalog.Service + const credentials = yield* Credential.Service yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* credentials.create({ + integrationID, + label: "First", + value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }), + }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) - active = second + yield* credentials.create({ + integrationID, + label: "Second", + value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }), + }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) }).pipe(Effect.provide(layer)) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 054c6871d..19311363e 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -1,14 +1,52 @@ import { describe, expect } from "bun:test" -import { Effect, Option, Schema } from "effect" +import { Effect, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, required, withEnv } from "../plugin/provider-helper" -import { catalogHost, host, integrationHost } from "../plugin/host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* (config: Config.Interface) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ + ...ConfigProviderPlugin.Plugin, + effect: ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)), + }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} function request(headers: Record, variant?: string) { return { @@ -23,8 +61,6 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("partitions existing model variant bodies without changing config shape", () => Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") const config = Config.Service.of({ @@ -57,12 +93,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe(Effect.provideService(Config.Service, config)), - }) + yield* addPlugin(config) const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants).toMatchObject([ @@ -82,8 +113,6 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("uses the effective provider package across layered config", () => Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") const config = Config.Service.of({ @@ -116,12 +145,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe(Effect.provideService(Config.Service, config)), - }) + yield* addPlugin(config) const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants[0]).toMatchObject({ @@ -137,7 +161,6 @@ describe("ConfigProviderPlugin.Plugin", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("custom") const modelID = ModelV2.ID.make("chat") const config = Config.Service.of({ @@ -217,12 +240,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe(Effect.provideService(Config.Service, config)), - }) + yield* addPlugin(config) const provider = required(yield* catalog.provider.get(providerID)) const model = required(yield* catalog.model.get(providerID, modelID)) diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index c03859854..6c7f08e11 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -1,26 +1,14 @@ -import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Credential } from "@opencode-ai/core/credential" -import { Database } from "@opencode-ai/core/database/database" import { Integration } from "@opencode-ai/core/integration" -import { tmpdir } from "./fixture/tmpdir" -import { it } from "./lib/effect" +import { testEffect } from "./lib/effect" -function layer(directory: string) { - return Credential.layer.pipe( - Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)), - ) -} +const it = testEffect(Credential.defaultLayer) describe("Credential", () => { - it.live("stores, updates, lists, and removes credentials", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { + it.effect("stores, updates, lists, and removes credentials", () => + Effect.gen(function* () { const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const created = yield* credentials.create({ @@ -42,8 +30,6 @@ describe("Credential", () => { yield* credentials.remove(replacement.id) expect(yield* credentials.list(integrationID)).toEqual([]) - }).pipe(Effect.provide(layer(tmp.path))), - ), - ), + }), ) }) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index fb5e195e2..2001de4ef 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -420,13 +420,12 @@ describe("EventV2", () => { const readStarted = yield* Deferred.make() const continueRead = yield* Deferred.make() let pause = true - const database = Database.layerFromPath(":memory:") const eventLayer = EventV2.layerWith({ beforeAggregateRead: () => pause ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) : Effect.void, - }).pipe(Layer.provide(database)) + }).pipe(Layer.provide(Database.defaultLayer)) yield* Effect.gen(function* () { const events = yield* EventV2.Service @@ -441,7 +440,7 @@ describe("EventV2", () => { expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ [0, { id: aggregateID, text: "during handoff" }], ]) - }).pipe(Effect.provide(Layer.mergeAll(database, eventLayer))) + }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer))) }), ) diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index 10f61d8a9..31371b0ac 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -5,7 +5,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" import path from "path" -const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)) +const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer) const { effect: it } = testEffect(live) describe("FSUtil", () => { diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index cdc8344de..77d0a9e33 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -22,7 +22,7 @@ describe("Ripgrep", () => { yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src"))) yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n")) const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 }) - expect(result.map((item) => item.path)).toEqual([RelativePath.make(path.join("src", "match.ts"))]) + expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")]) }), ), ) @@ -35,7 +35,7 @@ describe("Ripgrep", () => { yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n")) const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 }) expect(result).toHaveLength(1) - expect(result[0]?.entry.path).toBe(RelativePath.make(path.join("src", "match.ts"))) + expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts")) expect(result[0]?.submatches[0]?.text).toBe("needle") }), ), diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts index 00b3ffbd1..40d8ed9dc 100644 --- a/packages/core/test/fixture/location.ts +++ b/packages/core/test/fixture/location.ts @@ -1,6 +1,8 @@ import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Effect, Layer } from "effect" +import { tmpdir } from "./tmpdir" export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { return { @@ -10,3 +12,15 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute vcs: input.vcs, } satisfies Location.Interface } + +export const tempLocationLayer = Layer.unwrap( + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.map((tmp) => { + const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) + return Layer.succeed(Location.Service, Location.Service.of(location(ref))) + }), + ), +) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index ac9cd33e8..c95dbdf38 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -4,45 +4,15 @@ import * as TestClock from "effect/testing/TestClock" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" -import { it } from "./lib/effect" +import { testEffect } from "./lib/effect" -const layer = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: () => Effect.die("unexpected credential creation"), - list: () => Effect.succeed([]), - }), +const it = testEffect( + Integration.locationLayer.pipe( + Layer.provideMerge(Credential.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), ), ) -function connectionLayer( - created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }>, -) { - return Integration.locationLayer.pipe( - Layer.provideMerge(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: (input) => - Effect.sync(() => { - created.push(input) - return new Credential.Stored({ - id: Credential.ID.create(), - integrationID: input.integrationID, - label: input.label ?? "default", - value: input.value, - }) - }), - list: () => Effect.succeed([]), - }), - ), - ) -} - describe("Integration", () => { it.effect("registers integrations through the editor", () => Effect.gen(function* () { @@ -59,7 +29,7 @@ describe("Integration", () => { yield* Scope.close(scope, Exit.void) expect(yield* integrations.get(openai)).toBeUndefined() - }).pipe(Effect.provide(layer)), + }), ) it.effect("reveals the previous registration when an override closes", () => @@ -80,7 +50,7 @@ describe("Integration", () => { yield* Scope.close(second, Exit.void) expect((yield* integrations.get(id))?.name).toBe("OpenAI") expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id]) - }).pipe(Effect.provide(layer)), + }), ) it.effect("registers and overrides methods independently", () => @@ -128,17 +98,13 @@ describe("Integration", () => { yield* Scope.close(second, Exit.void) expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" }) expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })]) - }).pipe(Effect.provide(layer)), + }), ) - it.effect("connects with a key and stores the credential", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("connects with a key and stores the credential", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const events = yield* EventV2.Service const integrationID = Integration.ID.make("openai") yield* integrations.transform((editor) => @@ -158,25 +124,21 @@ describe("Integration", () => { label: "Work", }) - expect(created).toEqual([ - { + expect(yield* credentials.list(integrationID)).toEqual([ + expect.objectContaining({ integrationID, label: "Work", value: new Credential.Key({ type: "key", key: "secret" }), - }, + }), ]) expect((yield* Fiber.join(updated)).length).toBe(1) - }).pipe(Effect.provide(connectionLayer(created))) - }) + }), + ) - it.effect("completes code OAuth once and stores the credential", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("completes code OAuth once and stores the credential", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") yield* integrations.transform((editor) => @@ -212,29 +174,27 @@ describe("Integration", () => { expect(attempt.mode).toBe("code") yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }) - expect(created[0]).toEqual({ - integrationID, - label: "Personal", - value: new Credential.OAuth({ - type: "oauth", - methodID, - access: "access", - refresh: "refresh", - expires: 1, - metadata: { code: "1234" }, + expect((yield* credentials.list(integrationID))[0]).toEqual( + expect.objectContaining({ + integrationID, + label: "Personal", + value: new Credential.OAuth({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 1, + metadata: { code: "1234" }, + }), }), - }) - }).pipe(Effect.provide(connectionLayer(created))) - }) + ) + }), + ) - it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") let closed = false @@ -261,18 +221,14 @@ describe("Integration", () => { expect(closed).toBe(false) yield* integrations.attempt.cancel(attempt.attemptID) expect(closed).toBe(true) - expect(created).toEqual([]) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), + ) - it.effect("completes auto OAuth in the background", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("completes auto OAuth in the background", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") yield* integrations.transform((editor) => @@ -297,18 +253,14 @@ describe("Integration", () => { status: "complete", time: attempt.time, }) - expect(created).toHaveLength(1) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toHaveLength(1) + }), + ) - it.effect("expires abandoned OAuth attempts", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("expires abandoned OAuth attempts", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") let closed = false @@ -337,34 +289,12 @@ describe("Integration", () => { time: attempt.time, }) expect(closed).toBe(true) - expect(created).toEqual([]) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), + ) it.effect("projects credential and env connections", () => { const integrationID = Integration.ID.make("acme") - const rows = [ - { - id: Credential.ID.create(), - integrationID, - label: "Work", - value: new Credential.Key({ type: "key", key: "a" }), - }, - { - id: Credential.ID.create(), - integrationID, - label: "Personal", - value: new Credential.Key({ type: "key", key: "b" }), - }, - ] - const projectionLayer = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - list: () => Effect.succeed(rows.map((row) => new Credential.Stored(row))), - }), - ), - ) return Effect.acquireUseRelease( Effect.sync(() => { const previous = process.env.INTEGRATION_TEST_ACME_KEY @@ -375,6 +305,7 @@ describe("Integration", () => { () => Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service yield* integrations.transform((editor) => editor.method.update({ integrationID, @@ -384,23 +315,33 @@ describe("Integration", () => { }, }), ) + const work = yield* credentials.create({ + integrationID, + label: "Work", + value: new Credential.Key({ type: "key", key: "a" }), + }) + const personal = yield* credentials.create({ + integrationID, + label: "Personal", + value: new Credential.Key({ type: "key", key: "b" }), + }) // Stored credentials and detected env vars appear as connections. expect((yield* integrations.get(integrationID))?.connections).toEqual([ - { type: "credential", id: rows[0]!.id, label: "Work" }, { type: "credential", - id: rows[1]!.id, + id: personal.id, label: "Personal", }, { type: "env", name: "INTEGRATION_TEST_ACME_KEY" }, ]) expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({ type: "credential", - id: rows[1]!.id, + id: personal.id, label: "Personal", }) - }).pipe(Effect.provide(projectionLayer)), + expect(work.id).not.toBe(personal.id) + }), (previous) => Effect.sync(() => { if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 9e75bbb64..21acc40ee 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -36,8 +36,7 @@ const it = testEffect( Layer.mergeAll( Project.defaultLayer, EventV2.defaultLayer, - Credential.defaultLayer, - Credential.layer.pipe(Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh))), + Credential.defaultLayer.pipe(Layer.fresh), Npm.defaultLayer, ModelsDev.defaultLayer, FSUtil.defaultLayer, diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 5f7fbb16d..84efa4cba 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -21,34 +21,39 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) -const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events)) const project = Project.layer.pipe( - Layer.provide(database), + Layer.provide(Database.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), - Layer.provide(directories), + Layer.provide(ProjectDirectories.defaultLayer), ) -const store = SessionStore.layer.pipe(Layer.provide(database)) const sessions = SessionV2.layer.pipe( - Layer.provide(database), - Layer.provide(events), + Layer.provide(Database.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(project), - Layer.provide(store), + Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionExecution.noopLayer), ) const layer = MoveSession.layer.pipe( - Layer.provide(database), + Layer.provide(Database.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), - Layer.provide(events), + Layer.provide(EventV2.defaultLayer), Layer.provide(project), Layer.provide(sessions), ) const it = testEffect( - Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions), + Layer.mergeAll( + layer, + Database.defaultLayer, + EventV2.defaultLayer, + ProjectDirectories.defaultLayer, + project, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + SessionExecution.noopLayer, + sessions, + ), ) function abs(input: string) { diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 2120a9f51..0f07ed547 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -18,29 +18,25 @@ import { eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") const current = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/project") })), ) -const events = EventV2.layer.pipe(Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(SessionExecution.noopLayer), ) -const saved = PermissionSaved.layer.pipe(Layer.provide(database)) const layer = PermissionV2.locationLayer.pipe( - Layer.provideMerge(database), - Layer.provideMerge(store), - Layer.provideMerge(events), + Layer.provideMerge(Database.defaultLayer), + Layer.provideMerge(SessionStore.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(current), Layer.provideMerge(sessions), Layer.provideMerge(SessionExecution.noopLayer), - Layer.provideMerge(saved), + Layer.provideMerge(PermissionSaved.defaultLayer), ) const it = testEffect(layer) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts new file mode 100644 index 000000000..3faa65a65 --- /dev/null +++ b/packages/core/test/plugin/fixture.ts @@ -0,0 +1,48 @@ +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { CommandV2 } from "@opencode-ai/core/command" +import { Credential } from "@opencode-ai/core/credential" +import { EventV2 } from "@opencode-ai/core/event" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { Npm } from "@opencode-ai/core/npm" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { Reference } from "@opencode-ai/core/reference" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { SkillV2 } from "@opencode-ai/core/skill" +import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" +import { Effect, Layer } from "effect" +import { tempLocationLayer } from "../fixture/location" + +export const PluginTestLayer = Layer.mergeAll( + AgentV2.locationLayer, + CommandV2.locationLayer, + Catalog.locationLayer, + FileSystem.locationLayer, + PluginV2.locationLayer, + Reference.locationLayer, + SkillV2.locationLayer, +).pipe( + Layer.provideMerge( + Layer.mergeAll( + Credential.defaultLayer, + EventV2.defaultLayer, + FSUtil.defaultLayer, + Global.defaultLayer, + Layer.succeed( + Npm.Service, + Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }), + ), + RepositoryCache.defaultLayer, + SkillDiscovery.defaultLayer, + Ripgrep.defaultLayer, + tempLocationLayer, + ), + ), +) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 6b3e153c3..c872b6fe6 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -24,11 +24,7 @@ const locationLayer = Layer.succeed( ) const plugins = PluginV2.layer.pipe(Layer.provide(events)) const policy = Policy.layer.pipe(Layer.provide(locationLayer)) -const connections = Credential.layer.pipe( - Layer.fresh, - Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), - Layer.provide(events), -) +const connections = Credential.defaultLayer.pipe(Layer.fresh) const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) const catalog = Catalog.layer.pipe( Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts index 017f60fff..5fb8b16bf 100644 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ b/packages/core/test/plugin/provider-alibaba.test.ts @@ -3,17 +3,35 @@ import { createAlibaba } from "@ai-sdk/alibaba" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AlibabaPlugin.id, effect: AlibabaPlugin.effect(host) }) +}) describe("AlibabaPlugin", () => { it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/alibaba", + options: { name: "alibaba" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -23,10 +41,17 @@ describe("AlibabaPlugin", () => { it.effect("ignores non-Alibaba SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "alibaba" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -36,11 +61,14 @@ describe("AlibabaPlugin", () => { it.effect("matches the old bundled Alibaba SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-alibaba", "qwen"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/alibaba", options: { name: "custom-alibaba", apiKey: "test" }, }, @@ -56,8 +84,11 @@ describe("AlibabaPlugin", () => { it.effect("uses the old default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) - const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } }) + yield* addPlugin() + const item = new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" }, + }) const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) const language = result.sdk?.languageModel(item.api.id) expect(language?.modelId).toBe("qwen-plus") diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index aadefcb5c..7b3cea5c1 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -1,10 +1,61 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" 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 { PluginHost } from "@opencode-ai/core/plugin/host" import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AmazonBedrockPlugin.id, effect: AmazonBedrockPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) @@ -28,11 +79,10 @@ function openAIUrl(language: unknown, path: string, modelId: string) { describe("AmazonBedrockPlugin", () => { it.effect("moves endpoint option to api URL", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) yield* catalog.transform((catalog) => { - const bedrock = provider("amazon-bedrock", { + const bedrock = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock), api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, request: { headers: {}, @@ -44,6 +94,7 @@ describe("AmazonBedrockPlugin", () => { item.request = bedrock.request }) }) + yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)) expect(result.api).toEqual({ type: "aisdk", @@ -58,11 +109,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -83,11 +137,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -117,11 +174,21 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.amazonBedrock, + ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, }, @@ -137,11 +204,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", region: "eu-west-1" }, }, @@ -156,11 +226,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, }, @@ -175,11 +248,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, }, @@ -195,11 +271,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -224,11 +303,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -252,12 +334,17 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "openai.gpt-5.5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, }), package: "@ai-sdk/amazon-bedrock/mantle", options: { @@ -281,12 +368,17 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "openai.gpt-5.5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, }), sdk: fakeSelectorSdk(calls), options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, @@ -296,8 +388,16 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "openai.gpt-oss-safeguard-120b", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.amazonBedrock, + ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), + ), + api: { + id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, }), sdk: fakeSelectorSdk(calls), options: { region: "us-east-1" }, @@ -311,12 +411,17 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores other Bedrock provider subpaths", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/anthropic" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/anthropic", + }, }), package: "@ai-sdk/amazon-bedrock/anthropic", options: { name: "amazon-bedrock" }, @@ -340,11 +445,21 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.amazonBedrock, + ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -371,11 +486,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -384,7 +502,10 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, }, @@ -393,7 +514,17 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.amazonBedrock, + ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, }, @@ -402,7 +533,10 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "ap-northeast-1" }, }, @@ -411,7 +545,10 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "ap-southeast-2" }, }, @@ -432,11 +569,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -517,12 +657,15 @@ describe("AmazonBedrockPlugin", () => { expected: "au.anthropic.claude-sonnet-4-5", }, ] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() for (const item of cases) { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", item.modelID), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), + api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: item.region }, }, @@ -537,11 +680,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("openai", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, }, diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index d31574d0f..389fa8c6f 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -1,19 +1,34 @@ 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 { PluginHost } from "@opencode-ai/core/plugin/host" import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AnthropicPlugin.id, effect: AnthropicPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} describe("AnthropicPlugin", () => { it.effect("applies legacy beta headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AnthropicPlugin) yield* catalog.transform((catalog) => { - const item = provider("anthropic", { + const item = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.anthropic), api: { type: "aisdk", package: "@ai-sdk/anthropic" }, request: { headers: { Existing: "1" }, body: {} }, }) @@ -22,6 +37,7 @@ describe("AnthropicPlugin", () => { draft.request = item.request }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", ) @@ -31,10 +47,9 @@ describe("AnthropicPlugin", () => { it.effect("ignores non-Anthropic providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AnthropicPlugin) - yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {})) + yield* addPlugin() expect( required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"], ).toBeUndefined() @@ -44,54 +59,43 @@ describe("AnthropicPlugin", () => { it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, AnthropicPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("anthropic-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + }), package: "@ai-sdk/anthropic", options: { name: "custom-anthropic", apiKey: "test" }, }, {}, ) - expect(providers).toEqual(["custom-anthropic"]) + expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("custom-anthropic") }), ) it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, AnthropicPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("anthropic-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + }), package: "@ai-sdk/anthropic", options: { name: "anthropic", apiKey: "test" }, }, {}, ) - expect(providers).toEqual(["anthropic"]) + expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic") }), ) }) 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 6d9139c73..222e25e9b 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -1,23 +1,73 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" 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 { PluginHost } from "@opencode-ai/core/plugin/host" import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AzureCognitiveServicesPlugin.id, effect: AzureCognitiveServicesPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("AzureCognitiveServicesPlugin", () => { it.effect("maps the resource env var to the Azure SDK baseURL", () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } }) }) + yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) expect(result.api).toEqual({ type: "aisdk", @@ -33,14 +83,16 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("leaves baseURL unset without resource env and ignores other providers", () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* catalog.transform((catalog) => { - const azure = provider("azure-cognitive-services", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")), api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, }) - const openai = provider("openai") + const openai = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "test-provider" }, + }) catalog.provider.update(azure.id, (item) => { item.api = azure.api }) @@ -48,6 +100,7 @@ describe("AzureCognitiveServicesPlugin", () => { item.api = openai.api }) }) + yield* addPlugin() const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai)) expect(azure.request.body.baseURL).toBeUndefined() @@ -62,11 +115,17 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("deployment"), + ), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true }, }, @@ -80,15 +139,32 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("deployment"), + ), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) const ignored = yield* plugin.trigger( "aisdk.language", - { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual(["responses:deployment"]) @@ -101,11 +177,17 @@ describe("AzureCognitiveServicesPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "messages-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("messages-deployment"), + ), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, options: {}, }, @@ -114,7 +196,13 @@ describe("AzureCognitiveServicesPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "chat-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("chat-deployment"), + ), + api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, options: {}, }, @@ -123,7 +211,13 @@ describe("AzureCognitiveServicesPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "language-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("language-deployment"), + ), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: sdk.languageModel }, options: {}, }, diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index baa6d4f73..10c2a005d 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,23 +1,73 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" 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 { PluginHost } from "@opencode-ai/core/plugin/host" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AzurePlugin.id, effect: AzurePlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} 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* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.api = { type: "aisdk", package: "@ai-sdk/azure" } }) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), @@ -26,10 +76,10 @@ describe("AzurePlugin", () => { it.effect("keeps explicit resourceName over env and ignores other providers", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { - const azure = provider("azure", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "from-config" } }, }) @@ -39,7 +89,7 @@ describe("AzurePlugin", () => { }) catalog.provider.update(ProviderV2.ID.openai, () => {}) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config") expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() }), @@ -49,10 +99,10 @@ describe("AzurePlugin", () => { it.effect("falls back to env when configured resourceName is blank", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { - const azure = provider("azure", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "" } }, }) @@ -61,7 +111,7 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), @@ -70,10 +120,10 @@ describe("AzurePlugin", () => { it.effect("falls back to env when configured resourceName is whitespace", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { - const azure = provider("azure", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: " " } }, }) @@ -82,7 +132,7 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), @@ -92,11 +142,14 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("azure", "deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/azure", options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, }, @@ -111,11 +164,18 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() const exit = yield* plugin .trigger( "aisdk.sdk", - { model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/azure", + options: { name: "azure" }, + }, {}, ) .pipe(Effect.exit) @@ -128,10 +188,17 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }, {}, ) expect(calls).toEqual(["chat:deployment"]) @@ -142,10 +209,17 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }, {}, ) expect(calls).toEqual(["chat:deployment"]) @@ -156,11 +230,13 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure", "deployment", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { useCompletionUrls: true } }, }), sdk: fakeSelectorSdk(calls), @@ -176,15 +252,29 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) const ignored = yield* plugin.trigger( "aisdk.language", - { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual(["responses:deployment"]) @@ -200,11 +290,14 @@ describe("AzurePlugin", () => { calls.push(`${method}:${id}`) return { modelId: id, provider: method, specificationVersion: "v3" } } - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure", "messages-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, options: {}, }, @@ -212,7 +305,14 @@ describe("AzurePlugin", () => { ) yield* plugin.trigger( "aisdk.language", - { model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: make("languageModel") }, + options: {}, + }, {}, ) expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"]) diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index 5bcb9f7a0..5501ad39e 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -1,12 +1,22 @@ import { describe, expect, mock } 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 { PluginHost } from "@opencode-ai/core/plugin/host" import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const cerebrasOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CerebrasPlugin.id, effect: CerebrasPlugin.effect(host) }) +}) void mock.module("@ai-sdk/cerebras", () => ({ createCerebras: (options: Record) => { @@ -21,16 +31,15 @@ void mock.module("@ai-sdk/cerebras", () => ({ describe("CerebrasPlugin", () => { it.effect("applies the legacy integration header", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, CerebrasPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/cerebras" } item.request.headers.Existing = "1" }) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.request.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode", }) @@ -39,11 +48,10 @@ describe("CerebrasPlugin", () => { it.effect("ignores non-Cerebras providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, CerebrasPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({}) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.request.headers).toEqual({}) }), ) @@ -51,11 +59,21 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CerebrasPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/cerebras", options: { name: "custom-cerebras", apiKey: "test" }, }, @@ -70,11 +88,21 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CerebrasPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/cerebras", options: { name: "configured-cerebras", apiKey: "test" }, }, @@ -88,11 +116,21 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CerebrasPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/groq", options: { name: "custom-cerebras", apiKey: "test" }, }, diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 2332a3ca2..34e6261d3 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,8 +1,41 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" -import { addPlugin, it, model, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CloudflareAIGatewayPlugin.id, effect: CloudflareAIGatewayPlugin.effect(host) }) +}) + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} const aiGatewayCalls: Record[] = [] const unifiedCalls: string[] = [] @@ -78,11 +111,17 @@ describe("CloudflareAIGatewayPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -98,12 +137,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -142,12 +187,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -171,12 +222,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -208,12 +265,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -239,12 +302,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -261,12 +330,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -284,12 +359,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -313,12 +394,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, }, @@ -336,12 +423,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -364,12 +461,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-ai-gateway" }, }, 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 8e27781d0..f6da837d8 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -3,9 +3,59 @@ 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 { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CloudflareWorkersAIPlugin.id, effect: CloudflareWorkersAIPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( @@ -37,12 +87,15 @@ describe("CloudflareWorkersAIPlugin", () => { provider.api = { type: "aisdk", package: "test-provider" } }), ) - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))) const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { api: provider.api }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { id: ModelV2.ID.make("@cf/model"), ...provider.api }, + }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, }, @@ -61,14 +114,13 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } }), ) - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", @@ -82,12 +134,18 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", + }, }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, @@ -102,7 +160,6 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("uses env account ID over configured account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { @@ -110,7 +167,7 @@ describe("CloudflareWorkersAIPlugin", () => { provider.request.body.accountId = "configured-acct" }), ) - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", @@ -124,12 +181,18 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", + }, }), package: "@ai-sdk/openai-compatible", options: { @@ -153,12 +216,14 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), api: { + id: ModelV2.ID.make("@cf/model"), type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", @@ -183,11 +248,14 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("cloudflare-workers-ai", "alias", { api: { id: ModelV2.ID.make("@cf/api-model") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -202,12 +270,18 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/anthropic", + url: "https://proxy.example/v1", + }, }), package: "@ai-sdk/anthropic", options: { name: "cloudflare-workers-ai" }, diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts index c653f65a0..f0d09b841 100644 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ b/packages/core/test/plugin/provider-cohere.test.ts @@ -2,10 +2,34 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const cohereOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CoherePlugin.id, effect: CoherePlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} void mock.module("@ai-sdk/cohere", () => ({ createCohere: (options: Record) => { @@ -24,18 +48,32 @@ describe("CoherePlugin", () => { it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CoherePlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", - { model: model("cohere", "command"), package: "@ai-sdk/openai-compatible", options: { name: "cohere" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cohere" }, + }, {}, ) expect(ignored.sdk).toBeUndefined() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("cohere", "command"), package: "@ai-sdk/cohere", options: { name: "cohere" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/cohere", + options: { name: "cohere" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -45,11 +83,14 @@ describe("CoherePlugin", () => { it.effect("uses the model provider ID as the bundled SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CoherePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cohere", "command-r-plus"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/cohere", options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, }, @@ -70,10 +111,17 @@ describe("CoherePlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* addPlugin(plugin, CoherePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }, {}, ) diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts index 7e2b6322f..db7dd1042 100644 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ b/packages/core/test/plugin/provider-deepinfra.test.ts @@ -1,20 +1,25 @@ import { describe, expect, mock } from "bun:test" -import { Effect, Layer } from "effect" -import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { addPlugin, it, model } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const itAISDK = testEffect( - Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), -) -const deepinfraOptions: Record[] = [] +const it = testEffect(PluginTestLayer) +const deepinfraOptions: Record[] = [] const deepinfraLanguageModels: string[] = [] +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: DeepInfraPlugin.id, effect: DeepInfraPlugin.effect(host) }) +}) + void mock.module("@ai-sdk/deepinfra", () => ({ - createDeepInfra: (options: Record) => { + createDeepInfra: (options: Record) => { const captured = { ...options } deepinfraOptions.push(captured) return { @@ -36,10 +41,17 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -50,11 +62,14 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-deepinfra", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), package: "@ai-sdk/deepinfra", options: { name: "custom-deepinfra", apiKey: "test" }, }, @@ -69,11 +84,14 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("deepinfra", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), package: "@ai-sdk/deepinfra", options: { name: "deepinfra", apiKey: "test" }, }, @@ -88,7 +106,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const packages = [ "unmatched-package", "@ai-sdk/deepinfra-compatible", @@ -98,7 +116,14 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { const ignored = yield* plugin.trigger( "aisdk.sdk", - { model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: item, + options: { name: "deepinfra" }, + }, {}, ) expect(ignored.sdk).toBeUndefined() @@ -106,7 +131,14 @@ describe("DeepInfraPlugin", () => { ) const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -114,17 +146,36 @@ describe("DeepInfraPlugin", () => { }), ) - itAISDK.effect("uses the default languageModel selection for DeepInfra models", () => + it.effect("uses the default languageModel selection for DeepInfra models", () => Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(plugin, DeepInfraPlugin) - const language = yield* aisdk.language( - model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", { - api: { type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), + yield* addPlugin() + const sdkEvent = yield* plugin.trigger( + "aisdk.sdk", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("deepinfra"), + ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), + ), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), + type: "aisdk", + package: "@ai-sdk/deepinfra", + }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }, + {}, ) + const result = yield* plugin.trigger( + "aisdk.language", + { model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }, + {}, + ) + const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.provider).toBe("deepinfra.chat") expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) }), diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index f3b0bf898..150c9ea84 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -1,43 +1,40 @@ import { Npm } from "@opencode-ai/core/npm" import { describe, expect } from "bun:test" -import { Cause, Effect, Layer, Option } from "effect" +import { Cause, Effect, Layer } from "effect" import fs from "fs/promises" import os from "os" import path from "path" import { fileURLToPath } from "url" import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { host } from "./host" -import { fixtureProvider, it, model, npmLayer } from "./provider-helper" +import { PluginTestLayer } from "./fixture" +const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href const fixtureProviderPath = fileURLToPath(fixtureProvider) -const itWithAISDK = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) +const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginTestLayer))) -function npmEntrypointLayer(entrypoint?: string) { - return Layer.succeed( - Npm.Service, - Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint }), - install: () => Effect.void, - which: () => Effect.succeed(undefined), - }), - ) +function npmEntrypoint(entrypoint?: string) { + return Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }) } -function dynamicPlugin(layer = npmLayer) { - return { +const addPlugin = Effect.fn(function* (npm?: Npm.Interface) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: DynamicProviderPlugin.id, - effect: Effect.gen(function* () { - yield* DynamicProviderPlugin.effect(host({ npm: yield* Npm.Service })) - }).pipe(Effect.provide(layer)), - } -} + effect: DynamicProviderPlugin.effect(npm ? { ...host, npm } : host), + }) +}) function tempEntrypoint(source: string) { return Effect.acquireRelease( @@ -55,11 +52,14 @@ describe("DynamicProviderPlugin", () => { it.effect("creates an SDK from a provider factory export", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), package: fixtureProvider, options: { name: "custom", marker: "dynamic" }, }, @@ -74,11 +74,14 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const sdk = { marker: "existing" } - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), package: fixtureProvider, options: { name: "custom", marker: "dynamic" }, }, @@ -91,11 +94,14 @@ describe("DynamicProviderPlugin", () => { it.effect("injects the provider ID as the SDK factory name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-provider", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), package: fixtureProvider, options: { name: "custom-provider", marker: "dynamic" }, }, @@ -108,11 +114,14 @@ describe("DynamicProviderPlugin", () => { it.effect("loads npm packages through their resolved import entrypoint", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(fixtureProviderPath))) + yield* addPlugin(npmEntrypoint(fixtureProviderPath)) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("npm-provider", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" }, + }), package: "fixture-provider", options: { name: "npm-provider", marker: "npm" }, }, @@ -126,9 +135,14 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer())) + yield* addPlugin(npmEntrypoint()) const exit = yield* aisdk - .language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) + .language( + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + }), + ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") @@ -139,10 +153,13 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const exit = yield* aisdk .language( - model("bad-import", "alias", { api: { type: "aisdk", package: "file:///missing/provider-factory.js" } }), + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" }, + }), ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") @@ -155,9 +172,14 @@ describe("DynamicProviderPlugin", () => { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n") - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(tmp.entrypoint))) + yield* addPlugin(npmEntrypoint(tmp.entrypoint)) const exit = yield* aisdk - .language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) + .language( + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + }), + ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") @@ -168,9 +190,10 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const language = yield* aisdk.language( - model("custom", "alias", { + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider }, }), ) diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts index 6627d185a..3bd6d2496 100644 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ b/packages/core/test/plugin/provider-gateway.test.ts @@ -1,11 +1,22 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const gatewayCalls: Record[] = [] const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GatewayPlugin.id, effect: GatewayPlugin.effect(host) }) +}) mock.module("@ai-sdk/gateway", () => ({ createGateway(options: Record) { @@ -27,10 +38,17 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "gateway" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -42,12 +60,22 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("vercel", "anthropic/claude-sonnet-4"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("vercel"), + ModelV2.ID.make("anthropic/claude-sonnet-4"), + ), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/gateway", options: { name: "vercel", apiKey: "test-key" }, }, @@ -63,19 +91,33 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GatewayPlugin) + yield* addPlugin() for (const modelID of vercelGatewayModels) { const ignored = yield* plugin.trigger( "aisdk.sdk", - { model: model("vercel", modelID), package: "@ai-sdk/vercel", options: { name: "vercel" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/vercel", + options: { name: "vercel" }, + }, {}, ) expect(ignored.sdk).toBeUndefined() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("vercel", modelID), package: "@ai-sdk/gateway", options: { name: "vercel" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "vercel" }, + }, {}, ) expect(result.sdk).toBeDefined() diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index d23672f6f..f7ca619ea 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -3,19 +3,51 @@ 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 { PluginHost } from "@opencode-ai/core/plugin/host" import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GithubCopilotPlugin.id, effect: GithubCopilotPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("GithubCopilotPlugin", () => { it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", { - model: model("github-copilot", "gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "github-copilot" }, }, @@ -24,7 +56,10 @@ describe("GithubCopilotPlugin", () => { const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("github-copilot", "gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/github-copilot", options: { name: "github-copilot" }, }, @@ -39,11 +74,14 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "claude-sonnet-4"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -57,11 +95,14 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "alias", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -75,30 +116,68 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), + api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), + api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("github-copilot"), + ModelV2.ID.make("gpt-5-mini-2025-08-07"), + ), + api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([ @@ -115,11 +194,14 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "default", { api: { id: ModelV2.ID.make("gpt-5") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -128,7 +210,10 @@ describe("GithubCopilotPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "small", { api: { id: ModelV2.ID.make("gpt-5-mini") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -137,7 +222,10 @@ describe("GithubCopilotPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "sonnet", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -149,13 +237,12 @@ describe("GithubCopilotPlugin", () => { 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* addPlugin(plugin, GithubCopilotPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) .enabled, @@ -165,13 +252,12 @@ describe("GithubCopilotPlugin", () => { 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* addPlugin(plugin, GithubCopilotPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) .enabled, @@ -183,10 +269,17 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index b4277d140..1940bba93 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,12 +1,43 @@ import { describe, expect, mock } 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 { PluginHost } from "@opencode-ai/core/plugin/host" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const gitlabSDKOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GitLabPlugin.id, effect: GitLabPlugin.effect(host) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} void mock.module("gitlab-ai-provider", () => ({ VERSION: "test-version", @@ -32,10 +63,17 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", - { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }, {}, ) expect(gitlabSDKOptions).toHaveLength(1) @@ -65,10 +103,17 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", - { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }, {}, ) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example") @@ -86,11 +131,14 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("gitlab", "claude"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), package: "gitlab-ai-provider", options: { name: "gitlab", @@ -127,10 +175,17 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "gitlab" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -142,11 +197,13 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "duo-workflow-custom", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { workflowRef: "ref", workflowDefinition: "definition" }, @@ -178,11 +235,14 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "duo-workflow-exact"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), + api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" }, + }), sdk: { workflowChat: (id: string, options: unknown) => { calls.push([id, options]) @@ -205,11 +265,13 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "duo-workflow-custom", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { featureFlags: { request_flag: true } }, @@ -234,11 +296,13 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "claude", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, request: { headers: { h: "v" }, body: {} }, }), sdk: { diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index 57c90e814..fe9b0b0d9 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -1,10 +1,50 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" 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 { PluginHost } from "@opencode-ai/core/plugin/host" import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: definition.id, effect: definition.effect(host) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function selector(calls: string[]) { + return (id: string) => { + calls.push(`languageModel:${id}`) + return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 + } +} describe("GoogleVertexAnthropicPlugin", () => { it.effect("resolves legacy project and location env on provider update", () => @@ -19,17 +59,19 @@ describe("GoogleVertexAnthropicPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } }), ) - const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))) - expect(provider.request.body.project).toBe("cloud-project") - expect(provider.request.body.location).toBe("cloud-location") + yield* addPlugin(GoogleVertexAnthropicPlugin) + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project, + ).toBe("cloud-project") + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, + ).toBe("cloud-location") }), ), ) @@ -37,9 +79,7 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured project and location over env fallback", () => 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* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } @@ -47,9 +87,13 @@ describe("GoogleVertexAnthropicPlugin", () => { provider.request.body.location = "configured-location" }), ) - const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))) - expect(provider.request.body.project).toBe("configured-project") - expect(provider.request.body.location).toBe("configured-location") + yield* addPlugin(GoogleVertexAnthropicPlugin) + expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe( + "configured-project", + ) + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, + ).toBe("configured-location") }), ), ) @@ -67,11 +111,17 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex-anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex-anthropic" }, }, @@ -90,11 +140,17 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex-anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex-anthropic" }, }, @@ -110,11 +166,14 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "eu" }, }, @@ -129,11 +188,14 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured baseURL for google-vertex Anthropic models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, }, @@ -146,12 +208,15 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("selects google-vertex Anthropic language models through V2 plugins", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexPlugin) - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const sdkResult = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", " claude-sonnet-4-5 "), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "us" }, }, @@ -160,7 +225,10 @@ describe("GoogleVertexAnthropicPlugin", () => { const languageResult = yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex", " claude-sonnet-4-5 "), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), sdk: sdkResult.sdk, options: {}, }, @@ -178,12 +246,18 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex-anthropic", " claude-sonnet-4-5 "), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make(" claude-sonnet-4-5 "), + ), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, options: {}, }, {}, @@ -196,12 +270,15 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex", "claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, options: {}, }, {}, diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index bebfa1dc8..f5f62f8df 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -1,13 +1,63 @@ import { describe, expect, mock } 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 { PluginHost } from "@opencode-ai/core/plugin/host" import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const vertexOptions: Record[] = [] const googleAuthOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GoogleVertexPlugin.id, effect: GoogleVertexPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} void mock.module("@ai-sdk/google-vertex", () => ({ createVertex: (options: Record) => { @@ -37,9 +87,7 @@ void mock.module("google-auth-library", () => ({ describe("GoogleVertexPlugin", () => { it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.opencode, (provider) => { provider.api = { @@ -49,6 +97,7 @@ describe("GoogleVertexPlugin", () => { } }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode)) expect(provider.request.body).toEqual({}) @@ -67,9 +116,7 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -79,6 +126,7 @@ describe("GoogleVertexPlugin", () => { } }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("google-cloud-project") expect(provider.request.body.location).toBe("google-vertex-location") @@ -107,7 +155,6 @@ describe("GoogleVertexPlugin", () => { vertexOptions.length = 0 const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -117,12 +164,18 @@ describe("GoogleVertexPlugin", () => { } }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, }), package: "@ai-sdk/google-vertex", options: { name: "google-vertex" }, @@ -154,9 +207,7 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -168,6 +219,7 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "global" }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("global") @@ -182,9 +234,7 @@ describe("GoogleVertexPlugin", () => { it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -196,6 +246,7 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "eu" }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.api).toEqual({ type: "aisdk", @@ -217,15 +268,14 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.request.body.project = "config-project" }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("us-central1") @@ -243,12 +293,17 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, }), package: "@ai-sdk/google-vertex", options: { name: "google-vertex" }, @@ -268,21 +323,17 @@ describe("GoogleVertexPlugin", () => { googleAuthOptions.length = 0 const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("capture-openai-compatible"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.promise(async () => { - if (evt.model.providerID !== "google-vertex") return - if (evt.package !== "@ai-sdk/openai-compatible") return - expect(typeof evt.options.fetch).toBe("function") - await evt.options.fetch("https://vertex.example", { - headers: { "x-test": "1" }, - }) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (evt) => + Effect.promise(async () => { + if (evt.model.providerID !== "google-vertex") return + if (evt.package !== "@ai-sdk/openai-compatible") return + expect(typeof evt.options.fetch).toBe("function") + await evt.options.fetch("https://vertex.example", { + headers: { "x-test": "1" }, + }) }), - }) + ) const originalFetch = fetch ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async ( input: Parameters[0], @@ -297,8 +348,13 @@ describe("GoogleVertexPlugin", () => { plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + }, }), package: "@ai-sdk/openai-compatible", options: { name: "google-vertex" }, @@ -322,11 +378,14 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GoogleVertexPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex", " gemini-2.5-pro "), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), + api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index c1fab4201..1197957f5 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -1,26 +1,33 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { addPlugin, it, model } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const itWithAISDK = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GooglePlugin.id, effect: GooglePlugin.effect(host) }) +}) describe("GooglePlugin", () => { it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GooglePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-google", "gemini"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), package: "@ai-sdk/google", options: { name: "custom-google", apiKey: "test" }, }, @@ -34,34 +41,49 @@ describe("GooglePlugin", () => { it.effect("ignores non-Google SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GooglePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google" }, + }, {}, ) expect(result.sdk).toBeUndefined() }), ) - itWithAISDK.effect("uses default languageModel loading with provider ID parity", () => + it.effect("uses default languageModel loading with provider ID parity", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(plugin, GooglePlugin) - const language = yield* aisdk.language( - model("custom-google", "alias", { - api: { - id: ModelV2.ID.make("gemini-api"), - type: "aisdk", - package: "@ai-sdk/google", - }, - request: { - headers: {}, - body: { apiKey: "test" }, - }, - }), + yield* addPlugin() + const sdkEvent = yield* plugin.trigger( + "aisdk.sdk", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }, + {}, ) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: sdkEvent.model, + sdk: sdkEvent.sdk, + options: sdkEvent.options, + }, + {}, + ) + const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.modelId).toBe("gemini-api") expect(language.provider).toBe("custom-google") }), diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts index 71eb1eeab..dbc97205b 100644 --- a/packages/core/test/plugin/provider-groq.test.ts +++ b/packages/core/test/plugin/provider-groq.test.ts @@ -1,26 +1,37 @@ import { describe, expect } from "bun:test" import { createGroq } from "@ai-sdk/groq" -import { Effect, Layer } from "effect" -import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const aisdkIt = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GroqPlugin.id, effect: GroqPlugin.effect(host) }) +}) describe("GroqPlugin", () => { it.effect("creates a Groq SDK for @ai-sdk/groq", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq", + options: { name: "groq" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -30,10 +41,17 @@ describe("GroqPlugin", () => { it.effect("ignores non-Groq SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "groq" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -43,10 +61,17 @@ describe("GroqPlugin", () => { it.effect("only matches the bundled @ai-sdk/groq package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq/compat", + options: { name: "groq" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -56,11 +81,14 @@ describe("GroqPlugin", () => { it.effect("matches the old bundled Groq SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-groq", "llama"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), package: "@ai-sdk/groq", options: { name: "custom-groq", apiKey: "test" }, }, @@ -75,26 +103,32 @@ describe("GroqPlugin", () => { }), ) - aisdkIt.effect("uses the default languageModel(api.id) behavior", () => + it.effect("uses the default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(plugin, GroqPlugin) - const result = yield* aisdk.language( - model("groq", "alias", { - api: { - id: ModelV2.ID.make("llama-api"), - type: "aisdk", - package: "@ai-sdk/groq", - }, - request: { - headers: {}, - body: { apiKey: "test" }, - }, - }), + yield* addPlugin() + const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters[0] & { + name: string + }) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), + api: { + id: ModelV2.ID.make("llama-api"), + type: "aisdk", + package: "@ai-sdk/groq", + }, + }), + sdk, + options: { name: "groq", apiKey: "test" }, + }, + {}, ) - expect(result.modelId).toBe("llama-api") - expect(result.provider).toBe("groq.chat") + const language = result.language ?? sdk.languageModel(result.model.api.id) + expect(language.modelId).toBe("llama-api") + expect(language.provider).toBe("groq.chat") }), ) }) diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts deleted file mode 100644 index d30286bc0..000000000 --- a/packages/core/test/plugin/provider-helper.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { Npm } from "@opencode-ai/core/npm" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" -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 { Integration } from "@opencode-ai/core/integration" -import { Credential } from "@opencode-ai/core/credential" -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 { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { aisdkHost, catalogHost, host, integrationHost } from "./host" - -export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href - -export function required(value: T | undefined): T { - if (value === undefined) throw new Error("Expected value") - return value -} - -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("test") })), -) - -export const npmLayer = Layer.succeed( - Npm.Service, - Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint: undefined }), - install: () => Effect.void, - which: () => Effect.succeed(undefined), - }), -) - -export const catalogLayer = Layer.succeed( - Catalog.Service, - Catalog.Service.of({ - transform: (_transform) => Effect.die("unexpected catalog.transform"), - rebuild: () => Effect.die("unexpected catalog.rebuild"), - 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(undefined), - small: () => Effect.succeed(undefined), - }, - }), -) - -const integrations = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: () => Effect.die("unexpected credential creation"), - all: () => Effect.succeed([]), - list: () => Effect.succeed([]), - }), - ), -) - -export const it = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(integrations), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.succeed([]), - }), - ), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge(npmLayer), - Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), - ), -) - -export function addPlugin(plugin: PluginV2.Interface, definition: Plugin) { - return Effect.gen(function* () { - const catalog = yield* Effect.serviceOption(Catalog.Service) - const integration = yield* Effect.serviceOption(Integration.Service) - const npm = yield* Effect.serviceOption(Npm.Service) - const effect = - typeof definition.effect === "function" - ? definition.effect( - host({ - aisdk: aisdkHost(plugin), - ...(Option.isSome(catalog) ? { catalog: catalogHost(catalog.value) } : {}), - ...(Option.isSome(integration) ? { integration: integrationHost(integration.value) } : {}), - ...(Option.isSome(npm) ? { npm: npm.value } : {}), - }), - ) - : definition.effect - yield* plugin.add({ id: definition.id, effect }) - }) -} - -type ProviderInput = Partial> & { - api?: ProviderV2.Api - request?: ProviderV2.Request -} - -type ModelInput = Partial> & { - api?: (ProviderV2.Api & { id?: ModelV2.ID }) | { id: ModelV2.ID } - request?: ModelV2.Info["request"] -} - -export function provider(providerID: string, options?: ProviderInput) { - return new ProviderV2.Info({ - ...ProviderV2.Info.empty(ProviderV2.ID.make(providerID)), - api: options?.api ?? { - type: "aisdk", - package: "test-provider", - }, - ...options, - request: { - headers: {}, - body: {}, - ...options?.request, - }, - }) -} - -export function model(providerID: string, modelID: string, options?: ModelInput) { - return new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), - ...options, - api: - options?.api && "type" in options.api - ? { id: ModelV2.ID.make(modelID), ...options.api } - : { - id: ModelV2.ID.make(modelID), - ...options?.api, - type: "aisdk", - package: "test-provider", - }, - request: { - headers: {}, - body: {}, - ...options?.request, - }, - }) -} - -export function withEnv(vars: Record, fx: () => Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) - for (const [key, value] of Object.entries(vars)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - return previous - }), - () => fx(), - (previous) => - Effect.sync(() => { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - }), - ) -} - -export function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -export function expectPluginRegistered(ids: string[], id: string) { - expect(ids).toContain(PluginV2.ID.make(id)) -} diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index d54bf3134..5e7a7c2d2 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -2,96 +2,98 @@ 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 { PluginHost } from "@opencode-ai/core/plugin/host" 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 { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: KiloPlugin.id, effect: KiloPlugin.effect(host) }) +}) describe("KiloPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "kilo", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))), ) it.effect("applies legacy referer headers only to kilo", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, KiloPlugin) yield* catalog.transform((catalog) => { - const kilo = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - request: { headers: { Existing: "value" }, body: {} }, + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) - catalog.provider.update(kilo.id, (draft) => { - draft.api = kilo.api - draft.request = kilo.request - }) - catalog.provider.update(provider("openrouter").id, () => {}) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.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* addPlugin(plugin, KiloPlugin) yield* catalog.transform((catalog) => { - const item = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } }) }) + yield* addPlugin() - const result = required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))) - expect(result.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(result.request.headers).not.toHaveProperty("http-referer") - expect(result.request.headers).not.toHaveProperty("x-title") - expect(result.request.headers).not.toHaveProperty("X-Source") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty( + "http-referer", + ) + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("x-title") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.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* addPlugin(plugin, KiloPlugin) yield* catalog.transform((catalog) => { - const kilo = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } }) - catalog.provider.update(kilo.id, (draft) => { - draft.api = kilo.api - }) - const custom = provider("custom-kilo", { - api: { type: "aisdk", package: "kilo" }, - }) - catalog.provider.update(custom.id, (draft) => { - draft.api = custom.api + catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => { + provider.api = { type: "aisdk", package: "kilo" } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 456880c19..0fc22c523 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -3,36 +3,28 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" 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, required } from "./provider-helper" -import { catalogHost, host, integrationHost } from "./host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: LLMGatewayPlugin.id, effect: LLMGatewayPlugin.effect(host) }) +}) describe("LLMGatewayPlugin", () => { - const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) { - const integrations = yield* Integration.Service - const catalog = yield* Catalog.Service - yield* plugin.add({ - ...LLMGatewayPlugin, - effect: LLMGatewayPlugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ), - }) - }) - it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "llmgateway", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))), ) it.effect("applies legacy referer headers only to enabled llmgateway", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service const integrations = yield* Integration.Service yield* integrations.transform((editor) => { @@ -40,43 +32,48 @@ describe("LLMGatewayPlugin", () => { editor.update(Integration.ID.make("openrouter"), () => {}) }) yield* catalog.transform((catalog) => { - const llmgateway = provider("llmgateway", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(llmgateway.id, (draft) => { - draft.api = llmgateway.api - draft.request = llmgateway.request + catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.llmgateway.io/v1", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - yield* add(plugin) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-Source": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.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* add(plugin) + const integrations = yield* Integration.Service + yield* integrations.transform((editor) => { + editor.update(Integration.ID.make("llmgateway"), () => {}) + }) yield* catalog.transform((catalog) => { - const item = provider("llmgateway", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { + provider.disabled = true + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.llmgateway.io/v1", + } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts index ea3b3a670..f09e0e62c 100644 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ b/packages/core/test/plugin/provider-mistral.test.ts @@ -1,18 +1,37 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: MistralPlugin.id, effect: MistralPlugin.effect(host) }) +}) describe("MistralPlugin", () => { it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, MistralPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -22,11 +41,14 @@ describe("MistralPlugin", () => { it.effect("ignores non-Mistral SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, MistralPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("mistral", "mistral-large"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "mistral" }, }, @@ -40,19 +62,22 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* addPlugin(plugin, MistralPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("mistral-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("mistral-large").provider) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (event) => + Effect.sync(() => { + providers.push(event.sdk.languageModel("mistral-large").provider) }), - }) + ) const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -64,20 +89,19 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* addPlugin(plugin, MistralPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("mistral-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("mistral-large").provider) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (event) => + Effect.sync(() => { + providers.push(event.sdk.languageModel("mistral-large").provider) }), - }) + ) yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-mistral", "mistral-large"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/mistral", options: { name: "custom-mistral" }, }, @@ -91,11 +115,23 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - const sdk = fakeSelectorSdk(calls) - yield* addPlugin(plugin, MistralPlugin) + const sdk = { + languageModel: (id: string) => { + calls.push(`languageModel:${id}`) + return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 + }, + } + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }, {}, ) const language = result.language ?? sdk.languageModel(result.model.api.id) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index c5c986f62..ee16e5a2b 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -2,64 +2,66 @@ 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 { PluginHost } from "@opencode-ai/core/plugin/host" 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 { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: NvidiaPlugin.id, effect: NvidiaPlugin.effect(host) }) +}) describe("NvidiaPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "nvidia", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))), ) it.effect("applies NVIDIA tracking headers only to nvidia", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, NvidiaPlugin) yield* catalog.transform((catalog) => { - const nvidia = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { headers: { Existing: "value" }, body: {} }, + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) - catalog.provider.update(nvidia.id, (draft) => { - draft.api = nvidia.api - draft.request = nvidia.request - }) - catalog.provider.update(provider("openrouter").id, () => {}) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.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* addPlugin(plugin, NvidiaPlugin) yield* catalog.transform((catalog) => { - const item = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { headers: {}, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", @@ -69,24 +71,23 @@ 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* addPlugin(plugin, NvidiaPlugin) yield* catalog.transform((catalog) => { - const item = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } + provider.request = { headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, body: { baseURL: "https://integrate.api.nvidia.com/v1" }, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin", diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index 7e695c89c..c0601c2ba 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -1,23 +1,45 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: OpenAICompatiblePlugin.id, effect: OpenAICompatiblePlugin.effect(host) }) +}) describe("OpenAICompatiblePlugin", () => { it.effect("preserves explicit includeUsage false and defaults it to true", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, OpenAICompatiblePlugin) + yield* addPlugin() const defaulted = yield* plugin.trigger( "aisdk.sdk", - { model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom" }, + }, {}, ) const disabled = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "custom", includeUsage: false }, }, @@ -31,11 +53,14 @@ describe("OpenAICompatiblePlugin", () => { it.effect("defaults includeUsage for OpenAI-compatible package matches", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, OpenAICompatiblePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", options: { name: "custom" }, }, @@ -49,20 +74,19 @@ describe("OpenAICompatiblePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const observed: string[] = [] - yield* addPlugin(plugin, OpenAICompatiblePlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (event) => + Effect.sync(() => { + observed.push(event.sdk.languageModel("model").provider) }), - }) + ) yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-provider", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "custom-provider", baseURL: "https://example.com/v1" }, }, @@ -85,11 +109,14 @@ describe("OpenAICompatiblePlugin", () => { }), }), }) - yield* addPlugin(plugin, OpenAICompatiblePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai" }, }, diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index d41a856ce..a9911d2cc 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,28 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, required } from "./provider-helper" -import { host, integrationHost } from "./host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -function add(plugin: PluginV2.Interface, integrations: Integration.Interface) { - return plugin.add({ +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + const integrations = yield* Integration.Service + yield* plugin.add({ id: OpenAIPlugin.id, - effect: OpenAIPlugin.effect(host({ integration: integrationHost(integrations) })).pipe( - Effect.provideService(Integration.Service, integrations), - ), + effect: OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)), }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } } describe("OpenAIPlugin", () => { it.effect("registers browser and headless ChatGPT OAuth methods", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([ { id: Integration.MethodID.make("chatgpt-browser"), @@ -41,11 +63,14 @@ describe("OpenAIPlugin", () => { it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-openai", "gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai", options: { name: "custom-openai", apiKey: "test" }, }, @@ -58,10 +83,17 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "openai" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -72,11 +104,12 @@ describe("OpenAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("openai", "alias", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, }), sdk: fakeSelectorSdk(calls), @@ -93,10 +126,17 @@ describe("OpenAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([]) @@ -106,17 +146,19 @@ describe("OpenAIPlugin", () => { it.effect("disables gpt-5-chat-latest during catalog transforms", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin, yield* Integration.Service) yield* catalog.transform((catalog) => { - const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } }) + const item = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "@ai-sdk/openai" }, + }) catalog.provider.update(item.id, (draft) => { draft.api = item.api }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) expect( required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled, @@ -126,14 +168,18 @@ describe("OpenAIPlugin", () => { 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* add(plugin, yield* Integration.Service) yield* catalog.transform((catalog) => { - const item = provider("custom-openai") - catalog.provider.update(item.id, () => {}) + const item = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")), + api: { type: "aisdk", package: "test-provider" }, + }) + catalog.provider.update(item.id, (draft) => { + draft.api = item.api + }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))) .enabled, diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 9fe4be973..750b71463 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,45 +1,72 @@ import { describe, expect } from "bun:test" -import { Effect, Layer, Option } from "effect" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Credential } from "@opencode-ai/core/credential" -import { EventV2 } from "@opencode-ai/core/event" import { Integration } from "@opencode-ai/core/integration" -import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { it, model, provider, required, withEnv } from "./provider-helper" -import { catalogHost, host, integrationHost } from "./host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: OpencodePlugin.id, effect: OpencodePlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("test") })), -) - -const pluginWithIntegrations = (catalog: Catalog.Interface, integrations: Integration.Interface) => ({ - ...OpencodePlugin, - effect: OpencodePlugin.effect(host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) })), -}) describe("OpencodePlugin", () => { 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(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((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] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) }), @@ -49,17 +76,23 @@ describe("OpencodePlugin", () => { it.effect("keeps free models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((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] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")), + api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" }, + cost: cost(0), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) }), @@ -69,17 +102,23 @@ describe("OpencodePlugin", () => { it.effect("treats output-only cost as free without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((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] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")), + api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" }, + cost: cost(0, 1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe( true, @@ -91,17 +130,23 @@ describe("OpencodePlugin", () => { it.effect("uses OPENCODE_API_KEY as credentials", () => withEnv({ OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((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] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -111,10 +156,8 @@ describe("OpencodePlugin", () => { it.effect("uses configured provider env vars as credentials", () => withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - yield* plugin.add(pluginWithIntegrations(catalog, integrations)) yield* integrations.transform((editor) => { editor.method.update({ integrationID: Integration.ID.make("opencode"), @@ -122,13 +165,21 @@ describe("OpencodePlugin", () => { }) }) yield* catalog.transform((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] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -138,24 +189,29 @@ describe("OpencodePlugin", () => { it.effect("uses configured apiKey as credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("opencode", { + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { apiKey: "configured" }, }, }) - catalog.provider.update(item.id, (draft) => { - draft.request = item.request + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), }) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + catalog.provider.update(provider.id, (draft) => { + draft.request = provider.request + }) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -165,17 +221,23 @@ describe("OpencodePlugin", () => { it.effect("ignores non-opencode providers and models", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((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] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -206,8 +268,6 @@ describe("OpencodePlugin", () => { const selected = yield* catalog.model.small(providerID) expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano")) - }).pipe( - Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))), - ), + }), ) }) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 49b5875b5..5761827c4 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -3,56 +3,59 @@ 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 { PluginHost } from "@opencode-ai/core/plugin/host" 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 { addPlugin, expectPluginRegistered, it, model, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: OpenRouterPlugin.id, effect: OpenRouterPlugin.effect(host) }) +}) describe("OpenRouterPlugin", () => { it.effect("is registered so legacy OpenRouter behavior can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "openrouter", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))), ) it.effect("applies legacy referer headers only to openrouter", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, OpenRouterPlugin) yield* catalog.transform((catalog) => { - const openrouter = provider("openrouter", { - api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(openrouter.id, (item) => { - item.api = openrouter.api - item.request = openrouter.request + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } + provider.request = { headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {}) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({}) }), ) it.effect("creates an SDK only for the OpenRouter package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, OpenRouterPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", { - model: model("openrouter", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "openrouter" }, }, @@ -62,7 +65,14 @@ describe("OpenRouterPlugin", () => { const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@openrouter/ai-sdk-provider", + options: { name: "custom" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -71,52 +81,37 @@ 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* addPlugin(plugin, OpenRouterPlugin) yield* catalog.transform((catalog) => { - const openrouter = provider("openrouter", { - api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, - }) - catalog.provider.update(openrouter.id, (item) => { - item.api = openrouter.api + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } }) 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, () => {}) - } + catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) + catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5"), () => {}) + catalog.model.update(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) }) + yield* addPlugin() - expect( - required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))) - .enabled, - ).toBe(false) - expect( - required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled, - ).toBe(true) - expect( - required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled, - ).toBe(true) + expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe( + false, + ) + expect((yield* catalog.model.get(ProviderV2.ID.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 disable gpt-5-chat-latest for non-OpenRouter providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, OpenRouterPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( - required( - yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")), - ).enabled, + (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-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts index 35498d5e9..eeb00093e 100644 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ b/packages/core/test/plugin/provider-perplexity.test.ts @@ -1,18 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: PerplexityPlugin.id, effect: PerplexityPlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("PerplexityPlugin", () => { it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, PerplexityPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -22,11 +54,14 @@ describe("PerplexityPlugin", () => { it.effect("ignores packages that are not the bundled Perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, PerplexityPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("perplexity", "sonar"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/perplexity-compatible", options: { name: "perplexity" }, }, @@ -39,50 +74,40 @@ describe("PerplexityPlugin", () => { it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, PerplexityPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("perplexity-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("sonar").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }, {}, ) - expect(providers).toEqual(["perplexity"]) + expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, PerplexityPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("custom-perplexity-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("sonar").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-perplexity", "sonar"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/perplexity", options: { name: "custom-perplexity" }, }, {}, ) - expect(providers).toEqual(["perplexity"]) + expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) @@ -90,11 +115,14 @@ describe("PerplexityPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, PerplexityPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("perplexity", "alias", { api: { id: ModelV2.ID.make("sonar") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 51103167b..a6fe38718 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -1,16 +1,54 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { Npm } from "@opencode-ai/core/npm" import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" -import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper" -import { host } from "./host" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const pluginWithNpm = { - id: SapAICorePlugin.id, - effect: Effect.gen(function* () { - yield* SapAICorePlugin.effect(host({ npm: yield* Npm.Service })) - }).pipe(Effect.provide(npmLayer)), +const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href +const it = testEffect(PluginTestLayer) +const npm = Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), +}) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: SapAICorePlugin.id, effect: SapAICorePlugin.effect({ ...host, npm }) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + for (const [key, value] of Object.entries(vars)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + return previous + }), + effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) +} + +function model(providerID: string) { + return new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")), + api: { id: ModelV2.ID.make("sap-model"), type: "aisdk", package: fixtureProvider }, + }) } describe("SapAICorePlugin", () => { @@ -20,11 +58,11 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("sap-ai-core", "sap-model"), + model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core", serviceKey: "service-key" }, }, @@ -46,11 +84,11 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("sap-ai-core", "sap-model"), + model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core", serviceKey: "option-service-key" }, }, @@ -68,10 +106,10 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", - { model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } }, + { model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core" } }, {}, ) expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() @@ -83,7 +121,7 @@ describe("SapAICorePlugin", () => { it.effect("uses the callable SDK for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), { languageModel() { throw new Error("SAP AI Core should call the SDK directly") @@ -91,7 +129,7 @@ describe("SapAICorePlugin", () => { }) const language = yield* plugin.trigger( "aisdk.language", - { model: model("sap-ai-core", "sap-model"), sdk, options: {} }, + { model: model("sap-ai-core"), sdk, options: {} }, {}, ) expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" }) @@ -104,11 +142,11 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("openai", "sap-model"), + model: model("openai"), package: fixtureProvider, options: { name: "openai", serviceKey: "service-key" }, }, @@ -117,7 +155,7 @@ describe("SapAICorePlugin", () => { const language = yield* plugin.trigger( "aisdk.language", { - model: model("openai", "sap-model"), + model: model("openai"), sdk: () => { throw new Error("SAP AI Core should ignore other providers") }, diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index 5de7ae065..c376a6947 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -1,18 +1,48 @@ import { describe, expect, it as bun_it } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" -import { addPlugin, expectPluginRegistered, it, model, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: SnowflakeCortexPlugin.id, effect: SnowflakeCortexPlugin.effect(host) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} describe("SnowflakeCortexPlugin", () => { it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => Effect.sync(() => { - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "snowflake-cortex", - ) - const ids = ProviderPlugins.map((p) => p.id as string) + expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex")) + const ids = ProviderPlugins.map((p) => p.id) expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible")) }), ) @@ -20,10 +50,17 @@ describe("SnowflakeCortexPlugin", () => { it.effect("ignores non-snowflake-cortex providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), + api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "openai" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -34,11 +71,17 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("snowflake-cortex"), + ModelV2.ID.make("claude-sonnet-4-6"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, }, @@ -53,11 +96,17 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("snowflake-cortex"), + ModelV2.ID.make("claude-sonnet-4-6"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", @@ -76,11 +125,17 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("snowflake-cortex"), + ModelV2.ID.make("claude-sonnet-4-6"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, }, @@ -95,11 +150,17 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("snowflake-cortex"), + ModelV2.ID.make("claude-sonnet-4-6"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", @@ -118,27 +179,23 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const captured: Record[] = [] - yield* addPlugin(plugin, SnowflakeCortexPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - captured.push({ ...evt.options }) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("snowflake-cortex"), + ModelV2.ID.make("claude-sonnet-4-6"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, }, {}, ) - expect(captured[0]?.includeUsage).toBe(true) + expect(result.options.includeUsage).toBe(true) }), ), ) diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts index 19757e126..b780124a6 100644 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ b/packages/core/test/plugin/provider-togetherai.test.ts @@ -1,17 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: TogetherAIPlugin.id, effect: TogetherAIPlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("TogetherAIPlugin", () => { it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, TogetherAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -21,12 +54,15 @@ describe("TogetherAIPlugin", () => { it.effect("matches the old bundled provider package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, TogetherAIPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", { - model: model("togetherai", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "file:///tmp/@ai-sdk/togetherai-provider.js", options: { name: "togetherai" }, }, @@ -36,7 +72,14 @@ describe("TogetherAIPlugin", () => { const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -46,29 +89,22 @@ describe("TogetherAIPlugin", () => { it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const observed: string[] = [] - yield* addPlugin(plugin, TogetherAIPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), - }), - }) + yield* addPlugin() - yield* plugin.trigger( + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-togetherai", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/togetherai", options: { name: "custom-togetherai" }, }, {}, ) - expect(observed).toEqual(["togetherai.chat"]) + expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat") }), ) @@ -76,12 +112,22 @@ describe("TogetherAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, TogetherAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("togetherai"), + ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + ), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + type: "aisdk", + package: "test-provider", + }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts index 148a30ee4..639543af5 100644 --- a/packages/core/test/plugin/provider-venice.test.ts +++ b/packages/core/test/plugin/provider-venice.test.ts @@ -1,17 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: VenicePlugin.id, effect: VenicePlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("VenicePlugin", () => { it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, VenicePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "venice-ai-sdk-provider", + options: { name: "venice" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -21,39 +54,35 @@ describe("VenicePlugin", () => { it.effect("uses the model provider ID as the bundled Venice SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const observed: string[] = [] - yield* addPlugin(plugin, VenicePlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), - }), - }) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-venice", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "venice-ai-sdk-provider", options: { name: "custom-venice", apiKey: "test" }, }, {}, ) expect(result.sdk).toBeDefined() - expect(observed).toEqual(["custom-venice.chat"]) + expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat") }), ) it.effect("only handles the bundled venice-ai-sdk-provider package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, VenicePlugin) + yield* addPlugin() const similar = yield* plugin.trigger( "aisdk.sdk", { - model: model("venice", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "file:///tmp/venice-ai-sdk-provider.js", options: { name: "venice" }, }, @@ -61,7 +90,14 @@ describe("VenicePlugin", () => { ) const other = yield* plugin.trigger( "aisdk.sdk", - { model: model("venice", "model"), package: "@ai-sdk/openai-compatible", options: { name: "venice" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "venice" }, + }, {}, ) expect(similar.sdk).toBeUndefined() @@ -73,10 +109,17 @@ describe("VenicePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, VenicePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index c958d139e..b3cb5f289 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -1,28 +1,34 @@ 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 { PluginHost } from "@opencode-ai/core/plugin/host" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: VercelPlugin.id, effect: VercelPlugin.effect(host) }) +}) describe("VercelPlugin", () => { it.effect("applies legacy lower-case referer headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, VercelPlugin) yield* catalog.transform((catalog) => { - const item = provider("vercel", { - api: { type: "aisdk", package: "@ai-sdk/vercel" }, - request: { headers: { Existing: "1" }, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } + provider.request.headers.Existing = "1" }) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).toEqual({ Existing: "1", "http-referer": "https://opencode.ai/", "x-title": "opencode", @@ -32,19 +38,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* addPlugin(plugin, VercelPlugin) - yield* catalog.transform((catalog) => { - const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - }) - }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( + yield* catalog.transform((catalog) => + catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } + }), + ) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty( "HTTP-Referer", ) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty( "X-Title", ) }), @@ -53,10 +57,17 @@ describe("VercelPlugin", () => { it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, VercelPlugin) + yield* addPlugin() const event = yield* plugin.trigger( "aisdk.sdk", - { model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), + api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" }, + }), + package: "@ai-sdk/vercel", + options: { name: "custom-vercel" }, + }, {}, ) expect(event.sdk).toBeDefined() @@ -66,11 +77,10 @@ describe("VercelPlugin", () => { it.effect("ignores non-Vercel providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, VercelPlugin) - yield* catalog.transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({}) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {})) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index 4ac5cf34f..a978381de 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -1,37 +1,66 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { addPlugin, fakeSelectorSdk } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) +const it = testEffect(PluginTestLayer) -const model = new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), - api: { - id: ModelV2.ID.make("grok-4"), - type: "aisdk", - package: "@ai-sdk/xai", - }, +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: XAIPlugin.id, effect: XAIPlugin.effect(host) }) }) +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} + describe("XAIPlugin", () => { it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, XAIPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", - { model, package: "@ai-sdk/openai-compatible", options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/openai-compatible", + options: {}, + }, {}, ) - const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {}) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/xai", + options: {}, + }, + {}, + ) expect(ignored.sdk).toBeUndefined() expect(typeof result.sdk?.responses).toBe("function") @@ -41,32 +70,22 @@ describe("XAIPlugin", () => { it.effect("creates xAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] + yield* addPlugin() - yield* addPlugin(plugin, XAIPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("xai-sdk-name-observer"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (!evt.sdk) return - providers.push(evt.sdk.responses("grok-4").provider) - }), - } - }), - }) - - yield* plugin.trigger( + const result = yield* plugin.trigger( "aisdk.sdk", { - model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), package: "@ai-sdk/xai", options: {}, }, {}, ) - expect(providers).toEqual(["xai.responses"]) + expect(result.sdk.responses("grok-4").provider).toBe("xai.responses") }), ) @@ -75,11 +94,14 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, XAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias") }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -96,11 +118,14 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, XAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 4bfdc7b0e..3313cd048 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -2,34 +2,44 @@ 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 { PluginHost } from "@opencode-ai/core/plugin/host" 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 { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: ZenmuxPlugin.id, effect: ZenmuxPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} describe("ZenmuxPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "zenmux", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))), ) it.effect("applies the exact legacy Zenmux headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } }) }) + yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))) expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) @@ -38,19 +48,18 @@ describe("ZenmuxPlugin", () => { it.effect("merges legacy Zenmux headers with existing headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } + provider.request.headers.Existing = "value" }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ Existing: "value", @@ -62,22 +71,18 @@ 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* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - request: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } + provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", @@ -88,20 +93,13 @@ 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* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("openrouter", { - request: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.request = item.request + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts new file mode 100644 index 000000000..8a7fd8ca7 --- /dev/null +++ b/packages/core/test/preload.ts @@ -0,0 +1 @@ +process.env.OPENCODE_DB = ":memory:" diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index 47f2176c3..8c01e92c5 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -16,17 +16,16 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const databaseLayer = Database.layerFromPath(":memory:") -const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer)) -const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) const copyLayer = ProjectCopy.layer.pipe( - Layer.provide(databaseLayer), - Layer.provide(directoriesLayer), - Layer.provide(eventLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(ProjectDirectories.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), ) -const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer)) +const it = testEffect( + Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer), +) function abs(input: string) { return AbsolutePath.make(input) diff --git a/packages/core/test/project-directories.test.ts b/packages/core/test/project-directories.test.ts index c1d2d8801..491c2e260 100644 --- a/packages/core/test/project-directories.test.ts +++ b/packages/core/test/project-directories.test.ts @@ -8,10 +8,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, directories)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer)) const projectID = Project.ID.make("project-directories") const directory = AbsolutePath.make("/tmp/project-directories") diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 45fba0d18..7f0e9389a 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -13,19 +13,8 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const databaseLayer = Database.layerFromPath(":memory:") -const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) const it = testEffect( - Layer.mergeAll( - ProjectV2.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(directoriesLayer), - Layer.provide(databaseLayer), - ), - databaseLayer, - directoriesLayer, - ), + Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer), ) function remoteID(remote: string) { diff --git a/packages/core/test/question.test.ts b/packages/core/test/question.test.ts index 57bf39966..3ad95456f 100644 --- a/packages/core/test/question.test.ts +++ b/packages/core/test/question.test.ts @@ -6,10 +6,8 @@ import { QuestionV2 } from "@opencode-ai/core/question" import { SessionV2 } from "@opencode-ai/core/session" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const questions = QuestionV2.layer.pipe(Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, questions)) +const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, questions)) const sessionID = SessionV2.ID.make("ses_question_test") const question: QuestionV2.Info = { diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 96c7c9bd2..6fd80c60d 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -25,8 +25,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { testEffect } from "./lib/effect" import { tmpdir } from "./fixture/tmpdir" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) const projects = Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ @@ -35,17 +33,23 @@ const projects = Layer.succeed( commit: () => Effect.void, }), ) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(projects), Layer.provide(SessionExecution.noopLayer), ) const it = testEffect( - Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions), + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + projects, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + SessionExecution.noopLayer, + sessions, + ), ) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const id = SessionV2.ID.create() diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index df9ac731b..a0894d07e 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -21,10 +21,7 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const it = testEffect(Layer.mergeAll(database, events, projector)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer)) const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } @@ -113,10 +110,10 @@ describe("SessionProjector", () => { }).pipe( Effect.provide( SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), Layer.provide(Project.defaultLayer), - Layer.provide(SessionStore.layer.pipe(Layer.provide(database))), + Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionExecution.noopLayer), ), ), diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index b2aec228e..c84a3ab30 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -18,10 +18,6 @@ import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode- import { SessionStore } from "@opencode-ai/core/session/store" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const executionCalls: SessionV2.ID[] = [] const interruptCalls: SessionV2.ID[] = [] const interruptSeqs: Array = [] @@ -47,13 +43,22 @@ const execution = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(execution), ) -const it = testEffect(Layer.mergeAll(database, events, projector, store, execution, sessions)) +const it = testEffect( + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + execution, + sessions, + ), +) const sessionID = SessionV2.ID.make("ses_prompt_test") const messageID = SessionMessage.ID.create() diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index a03ec77f0..50e60a361 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -268,7 +268,7 @@ describe("SessionRunnerModel", () => { it.effect("prefers stored credentials over configured auth", () => Effect.gen(function* () { - const credential = new Credential.Stored({ + const credential = new Credential.Info({ id: Credential.ID.create(), integrationID: Integration.ID.make("test-provider"), label: "Work", diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index e8da56a3a..91d7a2447 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -32,10 +32,6 @@ import { Effect, Layer } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const cassette = process.env.RECORD === "true" ? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", { @@ -74,9 +70,9 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) const runner = SessionRunnerLLM.defaultLayer.pipe( - Layer.provide(database), - Layer.provide(store), - Layer.provide(events), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(client), Layer.provide(registry), Layer.provide(models), @@ -101,18 +97,18 @@ const execution = Layer.effect( ), ).pipe(Layer.provide(coordinator)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(execution), ) const it = testEffect( Layer.mergeAll( - database, - events, - projector, - store, + Database.defaultLayer, + EventV2.defaultLayer, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, executor, client, permission, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index eb5ccb277..862bb56d3 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -56,11 +56,7 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const questions = QuestionV2.layer.pipe(Layer.provide(events)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) +const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) const requests: LLMRequest[] = [] let response: LLMEvent[] = [] let responses: LLMEvent[][] | undefined @@ -235,9 +231,9 @@ const config = Layer.succeed( }), ) const runner = SessionRunnerLLM.layer.pipe( - Layer.provide(database), - Layer.provide(store), - Layer.provide(events), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(client), Layer.provide(registry), Layer.provide(models), @@ -262,19 +258,19 @@ const execution = Layer.effect( ), ).pipe(Layer.provide(coordinator)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(execution), ) const it = testEffect( Layer.mergeAll( - database, - events, + Database.defaultLayer, + EventV2.defaultLayer, questions, - projector, - store, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, client, permission, applications, diff --git a/packages/core/test/session-todo.test.ts b/packages/core/test/session-todo.test.ts index d1d656af3..ff10c4050 100644 --- a/packages/core/test/session-todo.test.ts +++ b/packages/core/test/session-todo.test.ts @@ -11,10 +11,7 @@ import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql" import { SessionTodo } from "@opencode-ai/core/session/todo" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, todos)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer)) const sessionID = SessionV2.ID.make("ses_todo_test") const setup = Effect.gen(function* () { diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 09cc159a2..85dcb604a 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -16,10 +16,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const it = testEffect(Layer.mergeAll(database, events, projector)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer)) const timestamp = DateTime.makeUnsafe(1) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 57a354fc7..d8f96a580 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -404,7 +404,7 @@ test("keeps the locked edit schema, semantics docstring, and deferred TODOs visi expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"]) expect(source).toContain( - "Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.", + "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", ) for (const todo of [ "Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.", diff --git a/packages/core/test/tool-read-filesystem.test.ts b/packages/core/test/tool-read-filesystem.test.ts index 2bc175416..c897786e8 100644 --- a/packages/core/test/tool-read-filesystem.test.ts +++ b/packages/core/test/tool-read-filesystem.test.ts @@ -6,7 +6,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { testEffect } from "./lib/effect" -const it = testEffect(FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))) +const it = testEffect(Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer)) const fixture = Effect.gen(function* () { const fs = yield* FSUtil.Service const files = yield* FileSystem.FileSystem diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index c85b7e359..fcbec061b 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect } from "bun:test" +import path from "path" import { Effect, Exit, Layer, PlatformError } from "effect" import { Config } from "@opencode-ai/core/config" import { ConfigAttachments } from "@opencode-ai/core/config/attachments" @@ -19,7 +20,7 @@ import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/to const assertions: PermissionV2.AssertInput[] = [] const missingPath = "__missing_read_target__.txt" -const missingAbsolutePath = `${process.cwd()}/${missingPath}` +const missingAbsolutePath = path.join(process.cwd(), missingPath) const readCalls: { input: AbsolutePath page: ReadToolFileSystem.PageInput @@ -164,7 +165,12 @@ describe("ReadTool", () => { }, }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }]) - expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/README.md`), page: {} }]) + expect(readCalls).toEqual([ + { + input: AbsolutePath.make(path.join(process.cwd(), "README.md")), + page: { offset: undefined, limit: undefined }, + }, + ]) }), ) @@ -193,7 +199,12 @@ describe("ReadTool", () => { { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" }, ], }) - expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/pixel.png`), page: {} }]) + expect(readCalls).toEqual([ + { + input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")), + page: { offset: undefined, limit: undefined }, + }, + ]) const settled = yield* settleTool(registry, { sessionID, @@ -449,7 +460,7 @@ describe("ReadTool", () => { }), ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) expect(readCalls).toEqual([ - { input: AbsolutePath.make(`${process.cwd()}/archive.dat`), page: { offset: 2, limit: 1 } }, + { input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } }, ]) }), ) @@ -589,7 +600,7 @@ describe("ReadTool", () => { value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, }) expect(readCalls).toEqual([ - { input: AbsolutePath.make(`${process.cwd()}/large.txt`), page: { offset: 2, limit: 1 } }, + { input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } }, ]) }), ) diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index c8d179901..38ba1900a 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -32,12 +32,15 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos)) -const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool)) +const tool = TodoWriteTool.layer.pipe( + Layer.provide(registry), + Layer.provide(permission), + Layer.provide(SessionTodo.defaultLayer), +) +const it = testEffect( + Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer, permission, registry, tool), +) const setup = Effect.gen(function* () { assertions.length = 0 diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index de5c7c264..a81b32d37 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -279,7 +279,7 @@ test("keeps the locked write schema, semantics docstring, and deferred UX TODOs expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"]) expect(source).toContain( - "Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.", + "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", ) for (const todo of [ "Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.", diff --git a/turbo.json b/turbo.json index 220cd96e5..c9255902c 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,10 @@ "outputs": [], "passThroughEnv": ["*"] }, + "@opencode-ai/core#test": { + "dependsOn": ["^build"], + "outputs": [] + }, "@opencode-ai/app#test": { "dependsOn": ["^build"], "outputs": []