refactor(core): simplify integration test fixtures (#33292)

This commit is contained in:
Dax
2026-06-22 04:15:34 +00:00
committed by GitHub
parent cdc6d01c5a
commit 2bb4311042
76 changed files with 2864 additions and 1599 deletions
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./test/preload.ts"]
+3 -8
View File
@@ -69,10 +69,10 @@ export const layer = Layer.effect(
const policy = yield* Policy.Service const policy = yield* Policy.Service
const integrations = yield* Integration.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 (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true if (typeof provider.request.body.apiKey === "string") return true
if (connected) return true if (integration?.connections.length) return true
return !integration return !integration
} }
@@ -183,13 +183,8 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.provider.available")(function* () { available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) 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) => return (yield* result.provider.all()).filter((provider) =>
available( available(provider, active.get(Integration.ID.make(provider.id))),
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
) )
}), }),
}, },
+14 -14
View File
@@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), 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")) .pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Info" }) .annotate({ identifier: "Credential.Value" })
export type Info = Schema.Schema.Type<typeof Info> export type Value = Schema.Schema.Type<typeof Value>
export class Stored extends Schema.Class<Stored>("Credential.Stored")({ export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID, id: ID,
integrationID: IntegrationSchema.ID, integrationID: IntegrationSchema.ID,
label: Schema.String, label: Schema.String,
value: Info, value: Value,
}) {} }) {}
export interface Interface { export interface Interface {
/** Returns every stored credential. */ /** Returns every stored credential. */
readonly all: () => Effect.Effect<Stored[]> readonly all: () => Effect.Effect<Info[]>
/** Returns stored credentials belonging to one integration. */ /** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]> readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */ /** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Stored | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Replaces any credential for an integration and returns the new record. */ /** Replaces any credential for an integration and returns the new record. */
readonly create: (input: { readonly create: (input: {
readonly integrationID: IntegrationSchema.ID readonly integrationID: IntegrationSchema.ID
readonly value: Info readonly value: Value
readonly label?: string readonly label?: string
}) => Effect.Effect<Stored> }) => Effect.Effect<Info>
/** Updates the label or secret value of a stored credential. */ /** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void> readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */ /** Removes a stored credential. */
readonly remove: (id: ID) => Effect.Effect<void> readonly remove: (id: ID) => Effect.Effect<void>
} }
@@ -66,10 +66,10 @@ export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => { const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return if (!row.integration_id) return
return new Stored({ return new Info({
id: row.id, id: row.id,
integrationID: row.integration_id, integrationID: row.integration_id,
label: row.label, label: row.label,
@@ -106,7 +106,7 @@ export const layer = Layer.effect(
return row ? stored(row) : undefined return row ? stored(row) : undefined
}), }),
create: Effect.fn("Credential.create")(function* (input) { create: Effect.fn("Credential.create")(function* (input) {
const credential = new Stored({ const credential = new Info({
id: ID.create(), id: ID.create(),
integrationID: input.integrationID, integrationID: input.integrationID,
label: input.label ?? "default", label: input.label ?? "default",
+1 -1
View File
@@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
id: text().$type<Credential.ID>().primaryKey(), id: text().$type<Credential.ID>().primaryKey(),
integration_id: text().$type<IntegrationSchema.ID>(), integration_id: text().$type<IntegrationSchema.ID>(),
label: text().notNull(), label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Info>().notNull(), value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
connector_id: text(), connector_id: text(),
method_id: text(), method_id: text(),
active: integer({ mode: "boolean" }), active: integer({ mode: "boolean" }),
+8 -24
View File
@@ -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 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 INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run( yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`)
`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 \`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_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 \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run( yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`)
`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( yield* tx.run(`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_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 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_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_project_idx\` ON \`session\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`) yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
+1 -1
View File
@@ -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 export const locationLayer = layer
+1 -1
View File
@@ -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)), Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
) )
+17 -43
View File
@@ -108,11 +108,11 @@ export type OAuthAuthorization = {
} & ( } & (
| { | {
readonly mode: "auto" readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Info, unknown> readonly callback: Effect.Effect<Credential.Value, unknown>
} }
| { | {
readonly mode: "code" readonly mode: "code"
readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown> readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
} }
) )
@@ -214,8 +214,6 @@ export interface Interface extends State.Transformable<Draft> {
/** Returns all integrations with their methods and current connections. */ /** Returns all integrations with their methods and current connections. */
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
readonly connection: { readonly connection: {
/** Returns active connections for every registered or credential-backed integration. */
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
/** Returns the active connection for one integration. */ /** Returns the active connection for one integration. */
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined> readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Runs a key method and stores the resulting credential. */ /** Runs a key method and stores the resulting credential. */
@@ -241,7 +239,7 @@ export interface Interface extends State.Transformable<Draft> {
/** Updates a stored credential exposed as a connection. */ /** Updates a stored credential exposed as a connection. */
readonly update: ( readonly update: (
credentialID: Credential.ID, credentialID: Credential.ID,
updates: Partial<Pick<Credential.Stored, "label">>, updates: Partial<Pick<Credential.Info, "label">>,
) => Effect.Effect<void> ) => Effect.Effect<void>
/** Removes a stored credential connection. */ /** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void> readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
@@ -353,39 +351,25 @@ export const locationLayer = Layer.effect(
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
}) })
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => { const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const connected = saved.map((credential) => ({ const credentials = saved.map((credential) => ({
type: "credential" as const, type: "credential" as const,
id: credential.id, id: credential.id,
label: credential.label, label: credential.label,
})) })).toReversed()
const detected = entry.methods const env = (entry?.methods ?? [])
.filter((method) => method.type === "env") .filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name])) .flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name })) .map((name) => ({ type: "env" as const, name }))
return [...connected, ...detected] return [...credentials, ...env]
} }
const activeConnection = ( const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
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[]) =>
new Info({ new Info({
id: entry.ref.id, id: entry.ref.id,
name: entry.ref.name, name: entry.ref.name,
methods: entry.methods, methods: entry.methods,
connections: connections(entry, saved), connections,
}) })
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) => const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -399,7 +383,7 @@ export const locationLayer = Layer.effect(
return error instanceof Error ? error.message : String(error) return error instanceof Error ? error.message : String(error)
} }
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) { const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
const now = yield* Clock.currentTimeMillis const now = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(attempts, (current) => { const result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID) const attempt = current.get(attemptID)
@@ -450,28 +434,18 @@ export const locationLayer = Layer.effect(
get: Effect.fn("Integration.get")(function* (id) { get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id) const entry = state.get().integrations.get(id)
if (!entry) return undefined 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* () { list: Effect.fn("Integration.list")(function* () {
return (yield* Effect.forEach(state.get().integrations.values(), (entry) => const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
Effect.gen(function* () { return Array.from(state.get().integrations.values(), (entry) =>
return project(entry, yield* credentials.list(entry.ref.id)) project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
}), ).toSorted((a, b) => a.name.localeCompare(b.name))
)).toSorted((a, b) => a.name.localeCompare(b.name))
}), }),
connection: { 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) { forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
const entry = state.get().integrations.get(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) { key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state const method = state
+2 -2
View File
@@ -44,7 +44,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Test or embedding seam for supplying a model resolver directly. */ /** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) 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 === "key") return Auth.value(credential.value.key)
if (credential?.value.type === "oauth") return Auth.value(credential.value.access) if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
const value = model.request.body.apiKey ?? model.api.settings?.apiKey const value = model.request.body.apiKey ?? model.api.settings?.apiKey
@@ -85,7 +85,7 @@ const apiName = (model: ModelV2.Info) =>
export const fromCatalogModel = ( export const fromCatalogModel = (
model: ModelV2.Info, model: ModelV2.Info,
connection?: IntegrationConnection.Info, connection?: IntegrationConnection.Info,
credential?: Credential.Stored, credential?: Credential.Info,
): Effect.Effect<Model, UnsupportedApiError> => { ): Effect.Effect<Model, UnsupportedApiError> => {
const resolved = const resolved =
credential?.value.metadata === undefined credential?.value.metadata === undefined
+18 -27
View File
@@ -11,7 +11,11 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { required } from "./plugin/provider-helper"
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
const locationLayer = Layer.succeed( const locationLayer = Layer.succeed(
Location.Service, Location.Service,
@@ -21,12 +25,7 @@ const it = testEffect(
Catalog.locationLayer.pipe( Catalog.locationLayer.pipe(
Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer), Layer.provideMerge(locationLayer),
Layer.provideMerge( Layer.provideMerge(Credential.defaultLayer),
Layer.mock(Credential.Service)({
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}),
),
), ),
) )
@@ -48,38 +47,30 @@ describe("CatalogV2", () => {
it.effect("derives availability from active credentials without changing provider state", () => { it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test") 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( const layer = Catalog.locationLayer.pipe(
Layer.fresh, Layer.fresh,
Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer), Layer.provideMerge(locationLayer),
Layer.provideMerge( Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
Layer.mock(Credential.Service)({
all: () => Effect.sync(() => [active]),
list: () => Effect.sync(() => [active]),
}),
),
) )
return Effect.gen(function* () { return Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) 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((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({}) 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((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({}) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
}).pipe(Effect.provide(layer)) }).pipe(Effect.provide(layer))
+44 -26
View File
@@ -1,14 +1,52 @@
import { describe, expect } from "bun:test" 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 { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, required, withEnv } from "../plugin/provider-helper" import { testEffect } from "../lib/effect"
import { catalogHost, host, integrationHost } from "../plugin/host" 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<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
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<string, string>, variant?: string) { function request(headers: Record<string, string>, variant?: string) {
return { return {
@@ -23,8 +61,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("partitions existing model variant bodies without changing config shape", () => it.effect("partitions existing model variant bodies without changing config shape", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next") const modelID = ModelV2.ID.make("alpha-gpt-next")
const config = Config.Service.of({ const config = Config.Service.of({
@@ -57,12 +93,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
]), ]),
}) })
yield* plugin.add({ yield* addPlugin(config)
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
})
const model = required(yield* catalog.model.get(providerID, modelID)) const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.variants).toMatchObject([ expect(model.variants).toMatchObject([
@@ -82,8 +113,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("uses the effective provider package across layered config", () => it.effect("uses the effective provider package across layered config", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next") const modelID = ModelV2.ID.make("alpha-gpt-next")
const config = Config.Service.of({ const config = Config.Service.of({
@@ -116,12 +145,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
]), ]),
}) })
yield* plugin.add({ yield* addPlugin(config)
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
})
const model = required(yield* catalog.model.get(providerID, modelID)) const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.variants[0]).toMatchObject({ expect(model.variants[0]).toMatchObject({
@@ -137,7 +161,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom") const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat") const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({ const config = Config.Service.of({
@@ -217,12 +240,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
]), ]),
}) })
yield* plugin.add({ yield* addPlugin(config)
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
})
const provider = required(yield* catalog.provider.get(providerID)) const provider = required(yield* catalog.provider.get(providerID))
const model = required(yield* catalog.model.get(providerID, modelID)) const model = required(yield* catalog.model.get(providerID, modelID))
+6 -20
View File
@@ -1,26 +1,14 @@
import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect } from "effect"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect"
import { it } from "./lib/effect"
function layer(directory: string) { const it = testEffect(Credential.defaultLayer)
return Credential.layer.pipe(
Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)),
)
}
describe("Credential", () => { describe("Credential", () => {
it.live("stores, updates, lists, and removes credentials", () => it.effect("stores, updates, lists, and removes credentials", () =>
Effect.acquireRelease( Effect.gen(function* () {
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
const created = yield* credentials.create({ const created = yield* credentials.create({
@@ -42,8 +30,6 @@ describe("Credential", () => {
yield* credentials.remove(replacement.id) yield* credentials.remove(replacement.id)
expect(yield* credentials.list(integrationID)).toEqual([]) expect(yield* credentials.list(integrationID)).toEqual([])
}).pipe(Effect.provide(layer(tmp.path))), }),
),
),
) )
}) })
+2 -3
View File
@@ -420,13 +420,12 @@ describe("EventV2", () => {
const readStarted = yield* Deferred.make<void>() const readStarted = yield* Deferred.make<void>()
const continueRead = yield* Deferred.make<void>() const continueRead = yield* Deferred.make<void>()
let pause = true let pause = true
const database = Database.layerFromPath(":memory:")
const eventLayer = EventV2.layerWith({ const eventLayer = EventV2.layerWith({
beforeAggregateRead: () => beforeAggregateRead: () =>
pause pause
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void, : Effect.void,
}).pipe(Layer.provide(database)) }).pipe(Layer.provide(Database.defaultLayer))
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
const events = yield* EventV2.Service 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([ expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, { id: aggregateID, text: "during handoff" }], [0, { id: aggregateID, text: "during handoff" }],
]) ])
}).pipe(Effect.provide(Layer.mergeAll(database, eventLayer))) }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
}), }),
) )
@@ -5,7 +5,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import path from "path" 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) const { effect: it } = testEffect(live)
describe("FSUtil", () => { describe("FSUtil", () => {
+2 -2
View File
@@ -22,7 +22,7 @@ describe("Ripgrep", () => {
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src"))) yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n")) 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 }) 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")) 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 }) const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
expect(result).toHaveLength(1) 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") expect(result[0]?.submatches[0]?.text).toBe("needle")
}), }),
), ),
+14
View File
@@ -1,6 +1,8 @@
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema" 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 } = {}) { export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return { return {
@@ -10,3 +12,15 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
vcs: input.vcs, vcs: input.vcs,
} satisfies Location.Interface } 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)))
}),
),
)
+67 -126
View File
@@ -4,45 +4,15 @@ import * as TestClock from "effect/testing/TestClock"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect" import { testEffect } from "./lib/effect"
const layer = Integration.locationLayer.pipe( const it = testEffect(
Layer.provide(EventV2.defaultLayer), Integration.locationLayer.pipe(
Layer.provide( Layer.provideMerge(Credential.defaultLayer),
Layer.mock(Credential.Service)({ Layer.provideMerge(EventV2.defaultLayer),
create: () => Effect.die("unexpected credential creation"),
list: () => Effect.succeed([]),
}),
), ),
) )
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", () => { describe("Integration", () => {
it.effect("registers integrations through the editor", () => it.effect("registers integrations through the editor", () =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -59,7 +29,7 @@ describe("Integration", () => {
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
expect(yield* integrations.get(openai)).toBeUndefined() expect(yield* integrations.get(openai)).toBeUndefined()
}).pipe(Effect.provide(layer)), }),
) )
it.effect("reveals the previous registration when an override closes", () => it.effect("reveals the previous registration when an override closes", () =>
@@ -80,7 +50,7 @@ describe("Integration", () => {
yield* Scope.close(second, Exit.void) yield* Scope.close(second, Exit.void)
expect((yield* integrations.get(id))?.name).toBe("OpenAI") expect((yield* integrations.get(id))?.name).toBe("OpenAI")
expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id]) expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id])
}).pipe(Effect.provide(layer)), }),
) )
it.effect("registers and overrides methods independently", () => it.effect("registers and overrides methods independently", () =>
@@ -128,17 +98,13 @@ describe("Integration", () => {
yield* Scope.close(second, Exit.void) yield* Scope.close(second, Exit.void)
expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" }) expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" })
expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })]) 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", () => { it.effect("connects with a key and stores the credential", () =>
const created: Array<{ Effect.gen(function* () {
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
yield* integrations.transform((editor) => yield* integrations.transform((editor) =>
@@ -158,25 +124,21 @@ describe("Integration", () => {
label: "Work", label: "Work",
}) })
expect(created).toEqual([ expect(yield* credentials.list(integrationID)).toEqual([
{ expect.objectContaining({
integrationID, integrationID,
label: "Work", label: "Work",
value: new Credential.Key({ type: "key", key: "secret" }), value: new Credential.Key({ type: "key", key: "secret" }),
}, }),
]) ])
expect((yield* Fiber.join(updated)).length).toBe(1) expect((yield* Fiber.join(updated)).length).toBe(1)
}).pipe(Effect.provide(connectionLayer(created))) }),
}) )
it.effect("completes code OAuth once and stores the credential", () => { it.effect("completes code OAuth once and stores the credential", () =>
const created: Array<{ Effect.gen(function* () {
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt") const methodID = Integration.MethodID.make("chatgpt")
yield* integrations.transform((editor) => yield* integrations.transform((editor) =>
@@ -212,29 +174,27 @@ describe("Integration", () => {
expect(attempt.mode).toBe("code") expect(attempt.mode).toBe("code")
yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }) yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" })
expect(created[0]).toEqual({ expect((yield* credentials.list(integrationID))[0]).toEqual(
integrationID, expect.objectContaining({
label: "Personal", integrationID,
value: new Credential.OAuth({ label: "Personal",
type: "oauth", value: new Credential.OAuth({
methodID, type: "oauth",
access: "access", methodID,
refresh: "refresh", access: "access",
expires: 1, refresh: "refresh",
metadata: { code: "1234" }, 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", () => { it.effect("keeps code attempts open when the code is missing and closes them on cancel", () =>
const created: Array<{ Effect.gen(function* () {
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt") const methodID = Integration.MethodID.make("chatgpt")
let closed = false let closed = false
@@ -261,18 +221,14 @@ describe("Integration", () => {
expect(closed).toBe(false) expect(closed).toBe(false)
yield* integrations.attempt.cancel(attempt.attemptID) yield* integrations.attempt.cancel(attempt.attemptID)
expect(closed).toBe(true) expect(closed).toBe(true)
expect(created).toEqual([]) expect(yield* credentials.list(integrationID)).toEqual([])
}).pipe(Effect.provide(connectionLayer(created))) }),
}) )
it.effect("completes auto OAuth in the background", () => { it.effect("completes auto OAuth in the background", () =>
const created: Array<{ Effect.gen(function* () {
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("browser") const methodID = Integration.MethodID.make("browser")
yield* integrations.transform((editor) => yield* integrations.transform((editor) =>
@@ -297,18 +253,14 @@ describe("Integration", () => {
status: "complete", status: "complete",
time: attempt.time, time: attempt.time,
}) })
expect(created).toHaveLength(1) expect(yield* credentials.list(integrationID)).toHaveLength(1)
}).pipe(Effect.provide(connectionLayer(created))) }),
}) )
it.effect("expires abandoned OAuth attempts", () => { it.effect("expires abandoned OAuth attempts", () =>
const created: Array<{ Effect.gen(function* () {
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("browser") const methodID = Integration.MethodID.make("browser")
let closed = false let closed = false
@@ -337,34 +289,12 @@ describe("Integration", () => {
time: attempt.time, time: attempt.time,
}) })
expect(closed).toBe(true) expect(closed).toBe(true)
expect(created).toEqual([]) expect(yield* credentials.list(integrationID)).toEqual([])
}).pipe(Effect.provide(connectionLayer(created))) }),
}) )
it.effect("projects credential and env connections", () => { it.effect("projects credential and env connections", () => {
const integrationID = Integration.ID.make("acme") 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( return Effect.acquireUseRelease(
Effect.sync(() => { Effect.sync(() => {
const previous = process.env.INTEGRATION_TEST_ACME_KEY const previous = process.env.INTEGRATION_TEST_ACME_KEY
@@ -375,6 +305,7 @@ describe("Integration", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
yield* integrations.transform((editor) => yield* integrations.transform((editor) =>
editor.method.update({ editor.method.update({
integrationID, 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. // Stored credentials and detected env vars appear as connections.
expect((yield* integrations.get(integrationID))?.connections).toEqual([ expect((yield* integrations.get(integrationID))?.connections).toEqual([
{ type: "credential", id: rows[0]!.id, label: "Work" },
{ {
type: "credential", type: "credential",
id: rows[1]!.id, id: personal.id,
label: "Personal", label: "Personal",
}, },
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }, { type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
]) ])
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({ expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
type: "credential", type: "credential",
id: rows[1]!.id, id: personal.id,
label: "Personal", label: "Personal",
}) })
}).pipe(Effect.provide(projectionLayer)), expect(work.id).not.toBe(personal.id)
}),
(previous) => (previous) =>
Effect.sync(() => { Effect.sync(() => {
if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY
+1 -2
View File
@@ -36,8 +36,7 @@ const it = testEffect(
Layer.mergeAll( Layer.mergeAll(
Project.defaultLayer, Project.defaultLayer,
EventV2.defaultLayer, EventV2.defaultLayer,
Credential.defaultLayer, Credential.defaultLayer.pipe(Layer.fresh),
Credential.layer.pipe(Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh))),
Npm.defaultLayer, Npm.defaultLayer,
ModelsDev.defaultLayer, ModelsDev.defaultLayer,
FSUtil.defaultLayer, FSUtil.defaultLayer,
+18 -13
View File
@@ -21,34 +21,39 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" 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( const project = Project.layer.pipe(
Layer.provide(database), Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer), Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.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( const sessions = SessionV2.layer.pipe(
Layer.provide(database), Layer.provide(Database.defaultLayer),
Layer.provide(events), Layer.provide(EventV2.defaultLayer),
Layer.provide(project), Layer.provide(project),
Layer.provide(store), Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionExecution.noopLayer), Layer.provide(SessionExecution.noopLayer),
) )
const layer = MoveSession.layer.pipe( const layer = MoveSession.layer.pipe(
Layer.provide(database), Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer), Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
Layer.provide(events), Layer.provide(EventV2.defaultLayer),
Layer.provide(project), Layer.provide(project),
Layer.provide(sessions), Layer.provide(sessions),
) )
const it = testEffect( 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) { function abs(input: string) {
+7 -11
View File
@@ -18,29 +18,25 @@ import { eq } from "drizzle-orm"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const current = Layer.succeed( const current = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })), 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( const sessions = SessionV2.layer.pipe(
Layer.provide(events), Layer.provide(EventV2.defaultLayer),
Layer.provide(database), Layer.provide(Database.defaultLayer),
Layer.provide(store), Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer), Layer.provide(Project.defaultLayer),
Layer.provide(SessionExecution.noopLayer), Layer.provide(SessionExecution.noopLayer),
) )
const saved = PermissionSaved.layer.pipe(Layer.provide(database))
const layer = PermissionV2.locationLayer.pipe( const layer = PermissionV2.locationLayer.pipe(
Layer.provideMerge(database), Layer.provideMerge(Database.defaultLayer),
Layer.provideMerge(store), Layer.provideMerge(SessionStore.defaultLayer),
Layer.provideMerge(events), Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(current), Layer.provideMerge(current),
Layer.provideMerge(sessions), Layer.provideMerge(sessions),
Layer.provideMerge(SessionExecution.noopLayer), Layer.provideMerge(SessionExecution.noopLayer),
Layer.provideMerge(saved), Layer.provideMerge(PermissionSaved.defaultLayer),
) )
const it = testEffect(layer) const it = testEffect(layer)
+48
View File
@@ -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,
),
),
)
+1 -5
View File
@@ -24,11 +24,7 @@ const locationLayer = Layer.succeed(
) )
const plugins = PluginV2.layer.pipe(Layer.provide(events)) const plugins = PluginV2.layer.pipe(Layer.provide(events))
const policy = Policy.layer.pipe(Layer.provide(locationLayer)) const policy = Policy.layer.pipe(Layer.provide(locationLayer))
const connections = Credential.layer.pipe( const connections = Credential.defaultLayer.pipe(Layer.fresh)
Layer.fresh,
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
Layer.provide(events),
)
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
const catalog = Catalog.layer.pipe( const catalog = Catalog.layer.pipe(
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
@@ -3,17 +3,35 @@ import { createAlibaba } from "@ai-sdk/alibaba"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { 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", () => { describe("AlibabaPlugin", () => {
it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AlibabaPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -23,10 +41,17 @@ describe("AlibabaPlugin", () => {
it.effect("ignores non-Alibaba SDK packages", () => it.effect("ignores non-Alibaba SDK packages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AlibabaPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeUndefined()
@@ -36,11 +61,14 @@ describe("AlibabaPlugin", () => {
it.effect("matches the old bundled Alibaba SDK provider naming", () => it.effect("matches the old bundled Alibaba SDK provider naming", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AlibabaPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/alibaba",
options: { name: "custom-alibaba", apiKey: "test" }, options: { name: "custom-alibaba", apiKey: "test" },
}, },
@@ -56,8 +84,11 @@ describe("AlibabaPlugin", () => {
it.effect("uses the old default languageModel(api.id) behavior", () => it.effect("uses the old default languageModel(api.id) behavior", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AlibabaPlugin) yield* addPlugin()
const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } }) 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 result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {})
const language = result.sdk?.languageModel(item.api.id) const language = result.sdk?.languageModel(item.api.id)
expect(language?.modelId).toBe("qwen-plus") expect(language?.modelId).toBe("qwen-plus")
@@ -1,10 +1,61 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
import { ProviderV2 } from "@opencode-ai/core/provider" 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<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
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") { function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
@@ -28,11 +79,10 @@ function openAIUrl(language: unknown, path: string, modelId: string) {
describe("AmazonBedrockPlugin", () => { describe("AmazonBedrockPlugin", () => {
it.effect("moves endpoint option to api URL", () => it.effect("moves endpoint option to api URL", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* catalog.transform((catalog) => { 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" }, api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
request: { request: {
headers: {}, headers: {},
@@ -44,6 +94,7 @@ describe("AmazonBedrockPlugin", () => {
item.request = bedrock.request item.request = bedrock.request
}) })
}) })
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)) const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock))
expect(result.api).toEqual({ expect(result.api).toEqual({
type: "aisdk", type: "aisdk",
@@ -58,11 +109,14 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/amazon-bedrock",
options: { options: {
name: "amazon-bedrock", name: "amazon-bedrock",
@@ -83,11 +137,14 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/amazon-bedrock",
options: { options: {
name: "amazon-bedrock", name: "amazon-bedrock",
@@ -117,11 +174,21 @@ describe("AmazonBedrockPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" }, options: { name: "amazon-bedrock" },
}, },
@@ -137,11 +204,14 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock", region: "eu-west-1" }, 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" }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" }, options: { name: "amazon-bedrock" },
}, },
@@ -175,11 +248,14 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" }, options: { name: "amazon-bedrock" },
}, },
@@ -195,11 +271,14 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const headers: Array<string | null> = [] const headers: Array<string | null> = []
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/amazon-bedrock",
options: { options: {
name: "amazon-bedrock", name: "amazon-bedrock",
@@ -224,11 +303,14 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const headers: Array<string | null> = [] const headers: Array<string | null> = []
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/amazon-bedrock",
options: { options: {
name: "amazon-bedrock", name: "amazon-bedrock",
@@ -252,12 +334,17 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("amazon-bedrock", "openai.gpt-5.5", { model: new ModelV2.Info({
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, ...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", package: "@ai-sdk/amazon-bedrock/mantle",
options: { options: {
@@ -281,12 +368,17 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: model("amazon-bedrock", "openai.gpt-5.5", { model: new ModelV2.Info({
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, ...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), sdk: fakeSelectorSdk(calls),
options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, 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( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: model("amazon-bedrock", "openai.gpt-oss-safeguard-120b", { model: new ModelV2.Info({
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, ...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), sdk: fakeSelectorSdk(calls),
options: { region: "us-east-1" }, options: { region: "us-east-1" },
@@ -311,12 +411,17 @@ describe("AmazonBedrockPlugin", () => {
it.effect("ignores other Bedrock provider subpaths", () => it.effect("ignores other Bedrock provider subpaths", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5", { model: new ModelV2.Info({
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/anthropic" }, ...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", package: "@ai-sdk/amazon-bedrock/anthropic",
options: { name: "amazon-bedrock" }, options: { name: "amazon-bedrock" },
@@ -340,11 +445,21 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const headers: Array<string | null> = [] const headers: Array<string | null> = []
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/amazon-bedrock",
options: { options: {
name: "amazon-bedrock", name: "amazon-bedrock",
@@ -371,11 +486,14 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -384,7 +502,10 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" }, options: { region: "eu-west-1" },
}, },
@@ -393,7 +514,17 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" }, options: { region: "eu-west-1" },
}, },
@@ -402,7 +533,10 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "ap-northeast-1" }, options: { region: "ap-northeast-1" },
}, },
@@ -411,7 +545,10 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "ap-southeast-2" }, options: { region: "ap-southeast-2" },
}, },
@@ -432,11 +569,14 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -517,12 +657,15 @@ describe("AmazonBedrockPlugin", () => {
expected: "au.anthropic.claude-sonnet-4-5", expected: "au.anthropic.claude-sonnet-4-5",
}, },
] ]
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
for (const item of cases) { for (const item of cases) {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: item.region }, options: { region: item.region },
}, },
@@ -537,11 +680,14 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" }, options: { region: "eu-west-1" },
}, },
@@ -1,19 +1,34 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
import { ProviderV2 } from "@opencode-ai/core/provider" 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<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
describe("AnthropicPlugin", () => { describe("AnthropicPlugin", () => {
it.effect("applies legacy beta headers", () => it.effect("applies legacy beta headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AnthropicPlugin)
yield* catalog.transform((catalog) => { 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" }, api: { type: "aisdk", package: "@ai-sdk/anthropic" },
request: { headers: { Existing: "1" }, body: {} }, request: { headers: { Existing: "1" }, body: {} },
}) })
@@ -22,6 +37,7 @@ describe("AnthropicPlugin", () => {
draft.request = item.request draft.request = item.request
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( 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", "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
) )
@@ -31,10 +47,9 @@ describe("AnthropicPlugin", () => {
it.effect("ignores non-Anthropic providers", () => it.effect("ignores non-Anthropic providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AnthropicPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) yield* addPlugin()
expect( expect(
required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"], required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"],
).toBeUndefined() ).toBeUndefined()
@@ -44,54 +59,43 @@ describe("AnthropicPlugin", () => {
it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () => it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providers: string[] = [] yield* addPlugin()
yield* addPlugin(plugin, AnthropicPlugin) const result = yield* plugin.trigger(
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(
"aisdk.sdk", "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", package: "@ai-sdk/anthropic",
options: { name: "custom-anthropic", apiKey: "test" }, 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", () => it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providers: string[] = [] yield* addPlugin()
yield* addPlugin(plugin, AnthropicPlugin) const result = yield* plugin.trigger(
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(
"aisdk.sdk", "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", package: "@ai-sdk/anthropic",
options: { name: "anthropic", apiKey: "test" }, options: { name: "anthropic", apiKey: "test" },
}, },
{}, {},
) )
expect(providers).toEqual(["anthropic"]) expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic")
}), }),
) )
}) })
@@ -1,23 +1,73 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider" 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<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
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", () => { describe("AzureCognitiveServicesPlugin", () => {
it.effect("maps the resource env var to the Azure SDK baseURL", () => it.effect("maps the resource env var to the Azure SDK baseURL", () =>
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
}) })
}) })
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
expect(result.api).toEqual({ expect(result.api).toEqual({
type: "aisdk", type: "aisdk",
@@ -33,14 +83,16 @@ describe("AzureCognitiveServicesPlugin", () => {
it.effect("leaves baseURL unset without resource env and ignores other providers", () => it.effect("leaves baseURL unset without resource env and ignores other providers", () =>
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* catalog.transform((catalog) => { 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" }, 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) => { catalog.provider.update(azure.id, (item) => {
item.api = azure.api item.api = azure.api
}) })
@@ -48,6 +100,7 @@ describe("AzureCognitiveServicesPlugin", () => {
item.api = openai.api item.api = openai.api
}) })
}) })
yield* addPlugin()
const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai)) const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai))
expect(azure.request.body.baseURL).toBeUndefined() expect(azure.request.body.baseURL).toBeUndefined()
@@ -62,11 +115,17 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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), sdk: fakeSelectorSdk(calls),
options: { useCompletionUrls: true }, options: { useCompletionUrls: true },
}, },
@@ -80,15 +139,32 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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( const ignored = yield* plugin.trigger(
"aisdk.language", "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"]) expect(calls).toEqual(["responses:deployment"])
@@ -101,11 +177,17 @@ describe("AzureCognitiveServicesPlugin", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
const sdk = fakeSelectorSdk(calls) const sdk = fakeSelectorSdk(calls)
yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel },
options: {}, options: {},
}, },
@@ -114,7 +196,13 @@ describe("AzureCognitiveServicesPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { chat: sdk.chat, languageModel: sdk.languageModel },
options: {}, options: {},
}, },
@@ -123,7 +211,13 @@ describe("AzureCognitiveServicesPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: sdk.languageModel },
options: {}, options: {},
}, },
+128 -28
View File
@@ -1,23 +1,73 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider" 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<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
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", () => { describe("AzurePlugin", () => {
it.effect("resolves resourceName from env", () => it.effect("resolves resourceName from env", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => { catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/azure" } 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") 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", () => it.effect("keeps explicit resourceName over env and ignores other providers", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => { 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" }, api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: "from-config" } }, request: { headers: {}, body: { resourceName: "from-config" } },
}) })
@@ -39,7 +89,7 @@ describe("AzurePlugin", () => {
}) })
catalog.provider.update(ProviderV2.ID.openai, () => {}) 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.azure)).request.body.resourceName).toBe("from-config")
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() 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", () => it.effect("falls back to env when configured resourceName is blank", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => { 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" }, api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: "" } }, request: { headers: {}, body: { resourceName: "" } },
}) })
@@ -61,7 +111,7 @@ describe("AzurePlugin", () => {
item.request = azure.request 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") 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", () => it.effect("falls back to env when configured resourceName is whitespace", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => { 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" }, api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: " " } }, request: { headers: {}, body: { resourceName: " " } },
}) })
@@ -82,7 +132,7 @@ describe("AzurePlugin", () => {
item.request = azure.request 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") 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 }, () => withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AzurePlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/azure",
options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, options: { name: "azure", baseURL: "https://proxy.example.com/openai" },
}, },
@@ -111,11 +164,18 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: undefined }, () => withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AzurePlugin) yield* addPlugin()
const exit = yield* plugin const exit = yield* plugin
.trigger( .trigger(
"aisdk.sdk", "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) .pipe(Effect.exit)
@@ -128,10 +188,17 @@ describe("AzurePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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"]) expect(calls).toEqual(["chat:deployment"])
@@ -142,10 +209,17 @@ describe("AzurePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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"]) expect(calls).toEqual(["chat:deployment"])
@@ -156,11 +230,13 @@ describe("AzurePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 } }, request: { headers: {}, body: { useCompletionUrls: true } },
}), }),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
@@ -176,15 +252,29 @@ describe("AzurePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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( const ignored = yield* plugin.trigger(
"aisdk.language", "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"]) expect(calls).toEqual(["responses:deployment"])
@@ -200,11 +290,14 @@ describe("AzurePlugin", () => {
calls.push(`${method}:${id}`) calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } return { modelId: id, provider: method, specificationVersion: "v3" }
} }
yield* addPlugin(plugin, AzurePlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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") }, sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") },
options: {}, options: {},
}, },
@@ -212,7 +305,14 @@ describe("AzurePlugin", () => {
) )
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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"]) expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"])
@@ -1,12 +1,22 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
import { ProviderV2 } from "@opencode-ai/core/provider" 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<string, unknown>[] = [] const cerebrasOptions: Record<string, unknown>[] = []
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", () => ({ void mock.module("@ai-sdk/cerebras", () => ({
createCerebras: (options: Record<string, unknown>) => { createCerebras: (options: Record<string, unknown>) => {
@@ -21,16 +31,15 @@ void mock.module("@ai-sdk/cerebras", () => ({
describe("CerebrasPlugin", () => { describe("CerebrasPlugin", () => {
it.effect("applies the legacy integration header", () => it.effect("applies the legacy integration header", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/cerebras" } item.api = { type: "aisdk", package: "@ai-sdk/cerebras" }
item.request.headers.Existing = "1" 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", Existing: "1",
"X-Cerebras-3rd-Party-Integration": "opencode", "X-Cerebras-3rd-Party-Integration": "opencode",
}) })
@@ -39,11 +48,10 @@ describe("CerebrasPlugin", () => {
it.effect("ignores non-Cerebras providers", () => it.effect("ignores non-Cerebras providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) 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* () { Effect.gen(function* () {
cerebrasOptions.length = 0 cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CerebrasPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/cerebras",
options: { name: "custom-cerebras", apiKey: "test" }, options: { name: "custom-cerebras", apiKey: "test" },
}, },
@@ -70,11 +88,21 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
cerebrasOptions.length = 0 cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CerebrasPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/cerebras",
options: { name: "configured-cerebras", apiKey: "test" }, options: { name: "configured-cerebras", apiKey: "test" },
}, },
@@ -88,11 +116,21 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
cerebrasOptions.length = 0 cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CerebrasPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/groq",
options: { name: "custom-cerebras", apiKey: "test" }, options: { name: "custom-cerebras", apiKey: "test" },
}, },
@@ -1,8 +1,41 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { 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<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
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<string, unknown>[] = [] const aiGatewayCalls: Record<string, unknown>[] = []
const unifiedCalls: string[] = [] const unifiedCalls: string[] = []
@@ -78,11 +111,17 @@ describe("CloudflareAIGatewayPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -98,12 +137,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "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", package: "ai-gateway-provider",
options: { options: {
name: "cloudflare-ai-gateway", name: "cloudflare-ai-gateway",
@@ -142,12 +187,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "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", package: "ai-gateway-provider",
options: { options: {
name: "cloudflare-ai-gateway", name: "cloudflare-ai-gateway",
@@ -171,12 +222,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "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", package: "ai-gateway-provider",
options: { options: {
name: "cloudflare-ai-gateway", name: "cloudflare-ai-gateway",
@@ -208,12 +265,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "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", package: "ai-gateway-provider",
options: { options: {
name: "cloudflare-ai-gateway", name: "cloudflare-ai-gateway",
@@ -239,12 +302,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "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", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -261,12 +330,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -284,12 +359,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -313,12 +394,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" },
}, },
@@ -336,12 +423,22 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -364,12 +461,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -3,9 +3,59 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider" 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<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
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") { function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
@@ -37,12 +87,15 @@ describe("CloudflareWorkersAIPlugin", () => {
provider.api = { type: "aisdk", package: "test-provider" } 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 provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, 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", () => it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } 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({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
@@ -82,12 +134,18 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("cloudflare-workers-ai", "@cf/model", { model: new ModelV2.Info({
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, ...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", package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, 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", () => it.effect("uses env account ID over configured account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
@@ -110,7 +167,7 @@ describe("CloudflareWorkersAIPlugin", () => {
provider.request.body.accountId = "configured-acct" 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({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
@@ -124,12 +181,18 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("cloudflare-workers-ai", "@cf/model", { model: new ModelV2.Info({
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, ...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", package: "@ai-sdk/openai-compatible",
options: { options: {
@@ -153,12 +216,14 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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: { api: {
id: ModelV2.ID.make("@cf/model"),
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
@@ -183,11 +248,14 @@ describe("CloudflareWorkersAIPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, CloudflareWorkersAIPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -202,12 +270,18 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("cloudflare-workers-ai", "@cf/model", { model: new ModelV2.Info({
api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" }, ...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", package: "@ai-sdk/anthropic",
options: { name: "cloudflare-workers-ai" }, options: { name: "cloudflare-workers-ai" },
@@ -2,10 +2,34 @@ import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { 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<string, any>[] = [] const cohereOptions: Record<string, any>[] = []
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", () => ({ void mock.module("@ai-sdk/cohere", () => ({
createCohere: (options: Record<string, any>) => { createCohere: (options: Record<string, any>) => {
@@ -24,18 +48,32 @@ describe("CoherePlugin", () => {
it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CoherePlugin) yield* addPlugin()
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "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() expect(ignored.sdk).toBeUndefined()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -45,11 +83,14 @@ describe("CoherePlugin", () => {
it.effect("uses the model provider ID as the bundled SDK name", () => it.effect("uses the model provider ID as the bundled SDK name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CoherePlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/cohere",
options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" },
}, },
@@ -70,10 +111,17 @@ describe("CoherePlugin", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
const sdk = fakeSelectorSdk(calls) const sdk = fakeSelectorSdk(calls)
yield* addPlugin(plugin, CoherePlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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: {},
},
{}, {},
) )
@@ -1,20 +1,25 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect" import { Effect } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk" import { ModelV2 } from "@opencode-ai/core/model"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { addPlugin, it, model } from "./provider-helper" import { PluginTestLayer } from "./fixture"
const itAISDK = testEffect( const it = testEffect(PluginTestLayer)
Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), const deepinfraOptions: Record<string, unknown>[] = []
)
const deepinfraOptions: Record<string, any>[] = []
const deepinfraLanguageModels: string[] = [] 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", () => ({ void mock.module("@ai-sdk/deepinfra", () => ({
createDeepInfra: (options: Record<string, any>) => { createDeepInfra: (options: Record<string, unknown>) => {
const captured = { ...options } const captured = { ...options }
deepinfraOptions.push(captured) deepinfraOptions.push(captured)
return { return {
@@ -36,10 +41,17 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetDeepInfraMock() resetDeepInfraMock()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, DeepInfraPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -50,11 +62,14 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetDeepInfraMock() resetDeepInfraMock()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, DeepInfraPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/deepinfra",
options: { name: "custom-deepinfra", apiKey: "test" }, options: { name: "custom-deepinfra", apiKey: "test" },
}, },
@@ -69,11 +84,14 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetDeepInfraMock() resetDeepInfraMock()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, DeepInfraPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/deepinfra",
options: { name: "deepinfra", apiKey: "test" }, options: { name: "deepinfra", apiKey: "test" },
}, },
@@ -88,7 +106,7 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetDeepInfraMock() resetDeepInfraMock()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, DeepInfraPlugin) yield* addPlugin()
const packages = [ const packages = [
"unmatched-package", "unmatched-package",
"@ai-sdk/deepinfra-compatible", "@ai-sdk/deepinfra-compatible",
@@ -98,7 +116,14 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "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() expect(ignored.sdk).toBeUndefined()
@@ -106,7 +131,14 @@ describe("DeepInfraPlugin", () => {
) )
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() 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* () { Effect.gen(function* () {
resetDeepInfraMock() resetDeepInfraMock()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service yield* addPlugin()
yield* addPlugin(plugin, DeepInfraPlugin) const sdkEvent = yield* plugin.trigger(
const language = yield* aisdk.language( "aisdk.sdk",
model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", { {
api: { type: "aisdk", package: "@ai-sdk/deepinfra" }, 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(language.provider).toBe("deepinfra.chat")
expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"])
}), }),
@@ -1,43 +1,40 @@
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { describe, expect } from "bun:test" 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 fs from "fs/promises"
import os from "os" import os from "os"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { AISDK } from "@opencode-ai/core/aisdk" import { AISDK } from "@opencode-ai/core/aisdk"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { host } from "./host" import { PluginTestLayer } from "./fixture"
import { fixtureProvider, it, model, npmLayer } from "./provider-helper"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const fixtureProviderPath = fileURLToPath(fixtureProvider) const fixtureProviderPath = fileURLToPath(fixtureProvider)
const itWithAISDK = testEffect( const it = testEffect(PluginTestLayer)
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginTestLayer)))
)
function npmEntrypointLayer(entrypoint?: string) { function npmEntrypoint(entrypoint?: string) {
return Layer.succeed( return Npm.Service.of({
Npm.Service, add: () => Effect.succeed({ directory: "", entrypoint }),
Npm.Service.of({ install: () => Effect.void,
add: () => Effect.succeed({ directory: "", entrypoint }), which: () => Effect.succeed(undefined),
install: () => Effect.void, })
which: () => Effect.succeed(undefined),
}),
)
} }
function dynamicPlugin(layer = npmLayer) { const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
return { const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({
id: DynamicProviderPlugin.id, id: DynamicProviderPlugin.id,
effect: Effect.gen(function* () { effect: DynamicProviderPlugin.effect(npm ? { ...host, npm } : host),
yield* DynamicProviderPlugin.effect(host({ npm: yield* Npm.Service })) })
}).pipe(Effect.provide(layer)), })
}
}
function tempEntrypoint(source: string) { function tempEntrypoint(source: string) {
return Effect.acquireRelease( return Effect.acquireRelease(
@@ -55,11 +52,14 @@ describe("DynamicProviderPlugin", () => {
it.effect("creates an SDK from a provider factory export", () => it.effect("creates an SDK from a provider factory export", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* plugin.add(dynamicPlugin()) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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, package: fixtureProvider,
options: { name: "custom", marker: "dynamic" }, options: { name: "custom", marker: "dynamic" },
}, },
@@ -74,11 +74,14 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const sdk = { marker: "existing" } const sdk = { marker: "existing" }
yield* plugin.add(dynamicPlugin()) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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, package: fixtureProvider,
options: { name: "custom", marker: "dynamic" }, options: { name: "custom", marker: "dynamic" },
}, },
@@ -91,11 +94,14 @@ describe("DynamicProviderPlugin", () => {
it.effect("injects the provider ID as the SDK factory name", () => it.effect("injects the provider ID as the SDK factory name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* plugin.add(dynamicPlugin()) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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, package: fixtureProvider,
options: { name: "custom-provider", marker: "dynamic" }, options: { name: "custom-provider", marker: "dynamic" },
}, },
@@ -108,11 +114,14 @@ describe("DynamicProviderPlugin", () => {
it.effect("loads npm packages through their resolved import entrypoint", () => it.effect("loads npm packages through their resolved import entrypoint", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(fixtureProviderPath))) yield* addPlugin(npmEntrypoint(fixtureProviderPath))
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "fixture-provider",
options: { name: "npm-provider", marker: "npm" }, options: { name: "npm-provider", marker: "npm" },
}, },
@@ -126,9 +135,14 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
yield* plugin.add(dynamicPlugin(npmEntrypointLayer())) yield* addPlugin(npmEntrypoint())
const exit = yield* aisdk 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) .pipe(Effect.exit)
expect(exit._tag).toBe("Failure") expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
@@ -139,10 +153,13 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
yield* plugin.add(dynamicPlugin()) yield* addPlugin()
const exit = yield* aisdk const exit = yield* aisdk
.language( .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) .pipe(Effect.exit)
expect(exit._tag).toBe("Failure") expect(exit._tag).toBe("Failure")
@@ -155,9 +172,14 @@ describe("DynamicProviderPlugin", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n") 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 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) .pipe(Effect.exit)
expect(exit._tag).toBe("Failure") expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
@@ -168,9 +190,10 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
yield* plugin.add(dynamicPlugin()) yield* addPlugin()
const language = yield* aisdk.language( 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 }, api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider },
}), }),
) )
@@ -1,11 +1,22 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { 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<string, unknown>[] = [] const gatewayCalls: Record<string, unknown>[] = []
const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] 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", () => ({ mock.module("@ai-sdk/gateway", () => ({
createGateway(options: Record<string, unknown>) { createGateway(options: Record<string, unknown>) {
@@ -27,10 +38,17 @@ describe("GatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gatewayCalls.length = 0 gatewayCalls.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GatewayPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -42,12 +60,22 @@ describe("GatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gatewayCalls.length = 0 gatewayCalls.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GatewayPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/gateway",
options: { name: "vercel", apiKey: "test-key" }, options: { name: "vercel", apiKey: "test-key" },
}, },
@@ -63,19 +91,33 @@ describe("GatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gatewayCalls.length = 0 gatewayCalls.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GatewayPlugin) yield* addPlugin()
for (const modelID of vercelGatewayModels) { for (const modelID of vercelGatewayModels) {
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "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() expect(ignored.sdk).toBeUndefined()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -3,19 +3,51 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { ProviderV2 } from "@opencode-ai/core/provider" 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<T>(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", () => { describe("GithubCopilotPlugin", () => {
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GithubCopilotPlugin) yield* addPlugin()
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { name: "github-copilot" }, options: { name: "github-copilot" },
}, },
@@ -24,7 +56,10 @@ describe("GithubCopilotPlugin", () => {
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/github-copilot",
options: { name: "github-copilot" }, options: { name: "github-copilot" },
}, },
@@ -39,11 +74,14 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, GithubCopilotPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -57,11 +95,14 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, GithubCopilotPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -75,30 +116,68 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, GithubCopilotPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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( yield* plugin.trigger(
"aisdk.language", "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( yield* plugin.trigger(
"aisdk.language", "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( yield* plugin.trigger(
"aisdk.language", "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( yield* plugin.trigger(
"aisdk.language", "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([ expect(calls).toEqual([
@@ -115,11 +194,14 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, GithubCopilotPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -128,7 +210,10 @@ describe("GithubCopilotPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -137,7 +222,10 @@ describe("GithubCopilotPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -149,13 +237,12 @@ describe("GithubCopilotPlugin", () => {
it.effect("disables gpt-5-chat-latest before Copilot language selection", () => it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
}) })
yield* addPlugin()
expect( expect(
required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
.enabled, .enabled,
@@ -165,13 +252,12 @@ describe("GithubCopilotPlugin", () => {
it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () => it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
}) })
yield* addPlugin()
expect( expect(
required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
.enabled, .enabled,
@@ -183,10 +269,17 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, GithubCopilotPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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([]) expect(calls).toEqual([])
@@ -1,12 +1,43 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider" 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<string, unknown>[] = [] const gitlabSDKOptions: Record<string, unknown>[] = []
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<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
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", () => ({ void mock.module("gitlab-ai-provider", () => ({
VERSION: "test-version", VERSION: "test-version",
@@ -32,10 +63,17 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GitLabPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "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) expect(gitlabSDKOptions).toHaveLength(1)
@@ -65,10 +103,17 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GitLabPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "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") expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example")
@@ -86,11 +131,14 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GitLabPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "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", package: "gitlab-ai-provider",
options: { options: {
name: "gitlab", name: "gitlab",
@@ -127,10 +175,17 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GitLabPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeUndefined()
@@ -142,11 +197,13 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = [] const calls: [string, unknown][] = []
yield* addPlugin(plugin, GitLabPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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: { request: {
headers: {}, headers: {},
body: { workflowRef: "ref", workflowDefinition: "definition" }, body: { workflowRef: "ref", workflowDefinition: "definition" },
@@ -178,11 +235,14 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = [] const calls: [string, unknown][] = []
yield* addPlugin(plugin, GitLabPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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: { sdk: {
workflowChat: (id: string, options: unknown) => { workflowChat: (id: string, options: unknown) => {
calls.push([id, options]) calls.push([id, options])
@@ -205,11 +265,13 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = [] const calls: [string, unknown][] = []
yield* addPlugin(plugin, GitLabPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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: { request: {
headers: {}, headers: {},
body: { featureFlags: { request_flag: true } }, body: { featureFlags: { request_flag: true } },
@@ -234,11 +296,13 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = [] const calls: [string, unknown][] = []
yield* addPlugin(plugin, GitLabPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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: {} }, request: { headers: { h: "v" }, body: {} },
}), }),
sdk: { sdk: {
@@ -1,10 +1,50 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider" 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<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
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", () => { describe("GoogleVertexAnthropicPlugin", () => {
it.effect("resolves legacy project and location env on provider update", () => it.effect("resolves legacy project and location env on provider update", () =>
@@ -19,17 +59,19 @@ describe("GoogleVertexAnthropicPlugin", () => {
}, },
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
}), }),
) )
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))) yield* addPlugin(GoogleVertexAnthropicPlugin)
expect(provider.request.body.project).toBe("cloud-project") expect(
expect(provider.request.body.location).toBe("cloud-location") (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", () => it.effect("keeps configured project and location over env fallback", () =>
withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () => withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
@@ -47,9 +87,13 @@ describe("GoogleVertexAnthropicPlugin", () => {
provider.request.body.location = "configured-location" provider.request.body.location = "configured-location"
}), }),
) )
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))) yield* addPlugin(GoogleVertexAnthropicPlugin)
expect(provider.request.body.project).toBe("configured-project") expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe(
expect(provider.request.body.location).toBe("configured-location") "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* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex-anthropic" }, options: { name: "google-vertex-anthropic" },
}, },
@@ -90,11 +140,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/google-vertex/anthropic",
options: { name: "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", () => it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "eu" }, options: { name: "google-vertex", project: "project", location: "eu" },
}, },
@@ -129,11 +188,14 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("keeps configured baseURL for google-vertex Anthropic models", () => it.effect("keeps configured baseURL for google-vertex Anthropic models", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, 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", () => it.effect("selects google-vertex Anthropic language models through V2 plugins", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexPlugin) yield* addPlugin(GoogleVertexPlugin)
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* addPlugin(GoogleVertexAnthropicPlugin)
const sdkResult = yield* plugin.trigger( const sdkResult = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "us" }, options: { name: "google-vertex", project: "project", location: "us" },
}, },
@@ -160,7 +225,10 @@ describe("GoogleVertexAnthropicPlugin", () => {
const languageResult = yield* plugin.trigger( const languageResult = yield* plugin.trigger(
"aisdk.language", "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, sdk: sdkResult.sdk,
options: {}, options: {},
}, },
@@ -178,12 +246,18 @@ describe("GoogleVertexAnthropicPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* addPlugin(GoogleVertexAnthropicPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: model("google-vertex-anthropic", " claude-sonnet-4-5 "), model: new ModelV2.Info({
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, ...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: {}, options: {},
}, },
{}, {},
@@ -196,12 +270,15 @@ describe("GoogleVertexAnthropicPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: model("google-vertex", "claude-sonnet-4-5"), model: new ModelV2.Info({
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, ...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: {}, options: {},
}, },
{}, {},
@@ -1,13 +1,63 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider" 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<string, any>[] = [] const vertexOptions: Record<string, any>[] = []
const googleAuthOptions: Record<string, any>[] = [] const googleAuthOptions: Record<string, any>[] = []
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<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
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", () => ({ void mock.module("@ai-sdk/google-vertex", () => ({
createVertex: (options: Record<string, any>) => { createVertex: (options: Record<string, any>) => {
@@ -37,9 +87,7 @@ void mock.module("google-auth-library", () => ({
describe("GoogleVertexPlugin", () => { describe("GoogleVertexPlugin", () => {
it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () => it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.opencode, (provider) => { catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
provider.api = { provider.api = {
@@ -49,6 +97,7 @@ describe("GoogleVertexPlugin", () => {
} }
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode)) const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode))
expect(provider.request.body).toEqual({}) expect(provider.request.body).toEqual({})
@@ -67,9 +116,7 @@ describe("GoogleVertexPlugin", () => {
}, },
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = { provider.api = {
@@ -79,6 +126,7 @@ describe("GoogleVertexPlugin", () => {
} }
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) 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.project).toBe("google-cloud-project")
expect(provider.request.body.location).toBe("google-vertex-location") expect(provider.request.body.location).toBe("google-vertex-location")
@@ -107,7 +155,6 @@ describe("GoogleVertexPlugin", () => {
vertexOptions.length = 0 vertexOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = { provider.api = {
@@ -117,12 +164,18 @@ describe("GoogleVertexPlugin", () => {
} }
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("google-vertex", "gemini", { model: new ModelV2.Info({
api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, ...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", package: "@ai-sdk/google-vertex",
options: { name: "google-vertex" }, options: { name: "google-vertex" },
@@ -154,9 +207,7 @@ describe("GoogleVertexPlugin", () => {
}, },
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = { provider.api = {
@@ -168,6 +219,7 @@ describe("GoogleVertexPlugin", () => {
provider.request.body.location = "global" provider.request.body.location = "global"
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.project).toBe("config-project")
expect(provider.request.body.location).toBe("global") expect(provider.request.body.location).toBe("global")
@@ -182,9 +234,7 @@ describe("GoogleVertexPlugin", () => {
it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () => it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = { provider.api = {
@@ -196,6 +246,7 @@ describe("GoogleVertexPlugin", () => {
provider.request.body.location = "eu" provider.request.body.location = "eu"
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
expect(provider.api).toEqual({ expect(provider.api).toEqual({
type: "aisdk", type: "aisdk",
@@ -217,15 +268,14 @@ describe("GoogleVertexPlugin", () => {
}, },
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" }
provider.request.body.project = "config-project" provider.request.body.project = "config-project"
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.project).toBe("config-project")
expect(provider.request.body.location).toBe("us-central1") expect(provider.request.body.location).toBe("us-central1")
@@ -243,12 +293,17 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
vertexOptions.length = 0 vertexOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("google-vertex", "gemini", { model: new ModelV2.Info({
api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, ...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", package: "@ai-sdk/google-vertex",
options: { name: "google-vertex" }, options: { name: "google-vertex" },
@@ -268,21 +323,17 @@ describe("GoogleVertexPlugin", () => {
googleAuthOptions.length = 0 googleAuthOptions.length = 0
const fetchCalls: { input: Parameters<typeof fetch>[0]; init?: RequestInit }[] = [] const fetchCalls: { input: Parameters<typeof fetch>[0]; init?: RequestInit }[] = []
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexPlugin) yield* addPlugin()
yield* plugin.add({ yield* plugin.hook("aisdk.sdk", (evt) =>
id: PluginV2.ID.make("capture-openai-compatible"), Effect.promise(async () => {
effect: Effect.succeed({ if (evt.model.providerID !== "google-vertex") return
"aisdk.sdk": (evt) => if (evt.package !== "@ai-sdk/openai-compatible") return
Effect.promise(async () => { expect(typeof evt.options.fetch).toBe("function")
if (evt.model.providerID !== "google-vertex") return await evt.options.fetch("https://vertex.example", {
if (evt.package !== "@ai-sdk/openai-compatible") return headers: { "x-test": "1" },
expect(typeof evt.options.fetch).toBe("function") })
await evt.options.fetch("https://vertex.example", {
headers: { "x-test": "1" },
})
}),
}), }),
}) )
const originalFetch = fetch const originalFetch = fetch
;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async ( ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async (
input: Parameters<typeof fetch>[0], input: Parameters<typeof fetch>[0],
@@ -297,8 +348,13 @@ describe("GoogleVertexPlugin", () => {
plugin.trigger( plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("google-vertex", "gemini", { model: new ModelV2.Info({
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, ...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", package: "@ai-sdk/openai-compatible",
options: { name: "google-vertex" }, options: { name: "google-vertex" },
@@ -322,11 +378,14 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, GoogleVertexPlugin) yield* addPlugin()
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -1,26 +1,33 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { addPlugin, it, model } from "./provider-helper" import { PluginTestLayer } from "./fixture"
const itWithAISDK = testEffect( const it = testEffect(PluginTestLayer)
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
) 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", () => { describe("GooglePlugin", () => {
it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GooglePlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/google",
options: { name: "custom-google", apiKey: "test" }, options: { name: "custom-google", apiKey: "test" },
}, },
@@ -34,34 +41,49 @@ describe("GooglePlugin", () => {
it.effect("ignores non-Google SDK packages", () => it.effect("ignores non-Google SDK packages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GooglePlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() 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* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service yield* addPlugin()
yield* addPlugin(plugin, GooglePlugin) const sdkEvent = yield* plugin.trigger(
const language = yield* aisdk.language( "aisdk.sdk",
model("custom-google", "alias", { {
api: { model: new ModelV2.Info({
id: ModelV2.ID.make("gemini-api"), ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
type: "aisdk", api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" },
package: "@ai-sdk/google", }),
}, package: "@ai-sdk/google",
request: { options: { name: "custom-google", apiKey: "test" },
headers: {}, },
body: { 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.modelId).toBe("gemini-api")
expect(language.provider).toBe("custom-google") expect(language.provider).toBe("custom-google")
}), }),
+66 -32
View File
@@ -1,26 +1,37 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { createGroq } from "@ai-sdk/groq" import { createGroq } from "@ai-sdk/groq"
import { Effect, Layer } from "effect" import { Effect } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { 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 { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const aisdkIt = testEffect( const it = testEffect(PluginTestLayer)
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
) 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", () => { describe("GroqPlugin", () => {
it.effect("creates a Groq SDK for @ai-sdk/groq", () => it.effect("creates a Groq SDK for @ai-sdk/groq", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GroqPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -30,10 +41,17 @@ describe("GroqPlugin", () => {
it.effect("ignores non-Groq SDK packages", () => it.effect("ignores non-Groq SDK packages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GroqPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeUndefined()
@@ -43,10 +61,17 @@ describe("GroqPlugin", () => {
it.effect("only matches the bundled @ai-sdk/groq package exactly", () => it.effect("only matches the bundled @ai-sdk/groq package exactly", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GroqPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeUndefined()
@@ -56,11 +81,14 @@ describe("GroqPlugin", () => {
it.effect("matches the old bundled Groq SDK provider naming", () => it.effect("matches the old bundled Groq SDK provider naming", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GroqPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/groq",
options: { name: "custom-groq", apiKey: "test" }, 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* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service yield* addPlugin()
yield* addPlugin(plugin, GroqPlugin) const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters<typeof createGroq>[0] & {
const result = yield* aisdk.language( name: string
model("groq", "alias", { })
api: { const result = yield* plugin.trigger(
id: ModelV2.ID.make("llama-api"), "aisdk.language",
type: "aisdk", {
package: "@ai-sdk/groq", model: new ModelV2.Info({
}, ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")),
request: { api: {
headers: {}, id: ModelV2.ID.make("llama-api"),
body: { apiKey: "test" }, type: "aisdk",
}, package: "@ai-sdk/groq",
}), },
}),
sdk,
options: { name: "groq", apiKey: "test" },
},
{},
) )
expect(result.modelId).toBe("llama-api") const language = result.language ?? sdk.languageModel(result.model.api.id)
expect(result.provider).toBe("groq.chat") expect(language.modelId).toBe("llama-api")
expect(language.provider).toBe("groq.chat")
}), }),
) )
}) })
@@ -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<T>(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<any>) {
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<Omit<ProviderV2.Info, "api" | "request">> & {
api?: ProviderV2.Api
request?: ProviderV2.Request
}
type ModelInput = Partial<Omit<ModelV2.Info, "api" | "request">> & {
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<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
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))
}
+47 -45
View File
@@ -2,96 +2,98 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
import { ProviderV2 } from "@opencode-ai/core/provider" 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", () => { describe("KiloPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () => it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))),
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"kilo",
),
),
) )
it.effect("applies legacy referer headers only to kilo", () => it.effect("applies legacy referer headers only to kilo", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, KiloPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const kilo = provider("kilo", { catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, provider.api = {
request: { headers: { Existing: "value" }, body: {} }, 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) => { catalog.provider.update(ProviderV2.ID.openrouter, () => {})
draft.api = kilo.api
draft.request = kilo.request
})
catalog.provider.update(provider("openrouter").id, () => {})
}) })
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", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "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", () => it.effect("uses the exact legacy Kilo header casing and set", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, KiloPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("kilo", { catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, provider.api = {
}) type: "aisdk",
catalog.provider.update(item.id, (draft) => { package: "@ai-sdk/openai-compatible",
draft.api = item.api url: "https://api.kilo.ai/api/gateway",
}
}) })
}) })
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))) expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
expect(result.request.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect(result.request.headers).not.toHaveProperty("http-referer") expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty(
expect(result.request.headers).not.toHaveProperty("x-title") "http-referer",
expect(result.request.headers).not.toHaveProperty("X-Source") )
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", () => it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, KiloPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const kilo = provider("kilo", { catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.kilo.ai/api/gateway",
}
}) })
catalog.provider.update(kilo.id, (draft) => { catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => {
draft.api = kilo.api provider.api = { type: "aisdk", package: "kilo" }
})
const custom = provider("custom-kilo", {
api: { type: "aisdk", package: "kilo" },
})
catalog.provider.update(custom.id, (draft) => {
draft.api = custom.api
}) })
}) })
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/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "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({})
}), }),
) )
}) })
@@ -3,36 +3,28 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider, required } from "./provider-helper" import { testEffect } from "../lib/effect"
import { catalogHost, host, integrationHost } from "./host" 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", () => { 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", () => it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))),
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"llmgateway",
),
),
) )
it.effect("applies legacy referer headers only to enabled llmgateway", () => it.effect("applies legacy referer headers only to enabled llmgateway", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
yield* integrations.transform((editor) => { yield* integrations.transform((editor) => {
@@ -40,43 +32,48 @@ describe("LLMGatewayPlugin", () => {
editor.update(Integration.ID.make("openrouter"), () => {}) editor.update(Integration.ID.make("openrouter"), () => {})
}) })
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const llmgateway = provider("llmgateway", { catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, provider.api = {
request: { headers: { Existing: "value" }, body: {} }, type: "aisdk",
}) package: "@ai-sdk/openai-compatible",
catalog.provider.update(llmgateway.id, (draft) => { url: "https://api.llmgateway.io/v1",
draft.api = llmgateway.api }
draft.request = llmgateway.request provider.request = { headers: { Existing: "value" }, body: {} }
}) })
catalog.provider.update(ProviderV2.ID.openrouter, () => {}) catalog.provider.update(ProviderV2.ID.openrouter, () => {})
}) })
yield* add(plugin) yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-Source": "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", () => it.effect("does not apply legacy headers to a disabled llmgateway provider", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.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) => { yield* catalog.transform((catalog) => {
const item = provider("llmgateway", { catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, provider.disabled = true
}) provider.api = {
catalog.provider.update(item.id, (draft) => { type: "aisdk",
draft.api = item.api 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((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true)
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({})
}), }),
) )
}) })
@@ -1,18 +1,37 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { 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", () => { describe("MistralPlugin", () => {
it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => it.effect("creates a Mistral SDK for @ai-sdk/mistral", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, MistralPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -22,11 +41,14 @@ describe("MistralPlugin", () => {
it.effect("ignores non-Mistral SDK packages", () => it.effect("ignores non-Mistral SDK packages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, MistralPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { name: "mistral" }, options: { name: "mistral" },
}, },
@@ -40,19 +62,22 @@ describe("MistralPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providers: string[] = [] const providers: string[] = []
yield* addPlugin(plugin, MistralPlugin) yield* addPlugin()
yield* plugin.add({ yield* plugin.hook("aisdk.sdk", (event) =>
id: PluginV2.ID.make("mistral-sdk-inspector"), Effect.sync(() => {
effect: Effect.succeed({ providers.push(event.sdk.languageModel("mistral-large").provider)
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("mistral-large").provider)
}),
}), }),
}) )
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -64,20 +89,19 @@ describe("MistralPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providers: string[] = [] const providers: string[] = []
yield* addPlugin(plugin, MistralPlugin) yield* addPlugin()
yield* plugin.add({ yield* plugin.hook("aisdk.sdk", (event) =>
id: PluginV2.ID.make("mistral-sdk-inspector"), Effect.sync(() => {
effect: Effect.succeed({ providers.push(event.sdk.languageModel("mistral-large").provider)
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("mistral-large").provider)
}),
}), }),
}) )
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/mistral",
options: { name: "custom-mistral" }, options: { name: "custom-mistral" },
}, },
@@ -91,11 +115,23 @@ describe("MistralPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
const sdk = fakeSelectorSdk(calls) const sdk = {
yield* addPlugin(plugin, MistralPlugin) 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( const result = yield* plugin.trigger(
"aisdk.language", "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) const language = result.language ?? sdk.languageModel(result.model.api.id)
@@ -2,64 +2,66 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
import { ProviderV2 } from "@opencode-ai/core/provider" 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", () => { describe("NvidiaPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () => it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))),
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"nvidia",
),
),
) )
it.effect("applies NVIDIA tracking headers only to nvidia", () => it.effect("applies NVIDIA tracking headers only to nvidia", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, NvidiaPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const nvidia = provider("nvidia", { catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, provider.api = {
request: { headers: { Existing: "value" }, body: {} }, 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) => { catalog.provider.update(ProviderV2.ID.openrouter, () => {})
draft.api = nvidia.api
draft.request = nvidia.request
})
catalog.provider.update(provider("openrouter").id, () => {})
}) })
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", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "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", () => it.effect("adds billing origin for custom NVIDIA endpoints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, NvidiaPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("nvidia", { catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, provider.api = {
request: { headers: {}, body: {} }, type: "aisdk",
}) package: "@ai-sdk/openai-compatible",
catalog.provider.update(item.id, (draft) => { url: "https://integrate.api.nvidia.com/v1",
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/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode", "X-BILLING-INVOKE-ORIGIN": "OpenCode",
@@ -69,24 +71,23 @@ describe("NvidiaPlugin", () => {
it.effect("preserves an explicit NVIDIA billing origin header", () => it.effect("preserves an explicit NVIDIA billing origin header", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, NvidiaPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("nvidia", { catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, provider.api = {
request: { type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://integrate.api.nvidia.com/v1",
}
provider.request = {
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
body: { baseURL: "https://integrate.api.nvidia.com/v1" }, 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/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
@@ -1,23 +1,45 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { 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", () => { describe("OpenAICompatiblePlugin", () => {
it.effect("preserves explicit includeUsage false and defaults it to true", () => it.effect("preserves explicit includeUsage false and defaults it to true", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, OpenAICompatiblePlugin) yield* addPlugin()
const defaulted = yield* plugin.trigger( const defaulted = yield* plugin.trigger(
"aisdk.sdk", "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( const disabled = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { name: "custom", includeUsage: false }, options: { name: "custom", includeUsage: false },
}, },
@@ -31,11 +53,14 @@ describe("OpenAICompatiblePlugin", () => {
it.effect("defaults includeUsage for OpenAI-compatible package matches", () => it.effect("defaults includeUsage for OpenAI-compatible package matches", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, OpenAICompatiblePlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "file:///tmp/@ai-sdk/openai-compatible-provider.js",
options: { name: "custom" }, options: { name: "custom" },
}, },
@@ -49,20 +74,19 @@ describe("OpenAICompatiblePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const observed: string[] = [] const observed: string[] = []
yield* addPlugin(plugin, OpenAICompatiblePlugin) yield* addPlugin()
yield* plugin.add({ yield* plugin.hook("aisdk.sdk", (event) =>
id: PluginV2.ID.make("inspector"), Effect.sync(() => {
effect: Effect.succeed({ observed.push(event.sdk.languageModel("model").provider)
"aisdk.sdk": (evt) =>
Effect.sync(() => {
observed.push(evt.sdk.languageModel("model").provider)
}),
}), }),
}) )
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { name: "custom-provider", baseURL: "https://example.com/v1" }, 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( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai" }, options: { name: "cloudflare-workers-ai" },
}, },
@@ -1,28 +1,50 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider, required } from "./provider-helper" import { testEffect } from "../lib/effect"
import { host, integrationHost } from "./host" import { PluginTestLayer } from "./fixture"
function add(plugin: PluginV2.Interface, integrations: Integration.Interface) { const it = testEffect(PluginTestLayer)
return plugin.add({
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, id: OpenAIPlugin.id,
effect: OpenAIPlugin.effect(host({ integration: integrationHost(integrations) })).pipe( effect: OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)),
Effect.provideService(Integration.Service, integrations),
),
}) })
})
function required<T>(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", () => { describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () => it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service yield* addPlugin()
yield* add(plugin, yield* Integration.Service)
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([ expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
{ {
id: Integration.MethodID.make("chatgpt-browser"), 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", () => it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* add(plugin, yield* Integration.Service) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai",
options: { name: "custom-openai", apiKey: "test" }, options: { name: "custom-openai", apiKey: "test" },
}, },
@@ -58,10 +83,17 @@ describe("OpenAIPlugin", () => {
it.effect("ignores non-OpenAI SDK packages", () => it.effect("ignores non-OpenAI SDK packages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* add(plugin, yield* Integration.Service) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeUndefined()
@@ -72,11 +104,12 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* add(plugin, yield* Integration.Service) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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" }, api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}), }),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
@@ -93,10 +126,17 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* add(plugin, yield* Integration.Service) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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([]) expect(calls).toEqual([])
@@ -106,17 +146,19 @@ describe("OpenAIPlugin", () => {
it.effect("disables gpt-5-chat-latest during catalog transforms", () => it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* add(plugin, yield* Integration.Service)
yield* catalog.transform((catalog) => { 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) => { catalog.provider.update(item.id, (draft) => {
draft.api = item.api draft.api = item.api
}) })
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) 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"))).enabled).toBe(true)
expect( expect(
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled, 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", () => it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* add(plugin, yield* Integration.Service)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("custom-openai") const item = new ProviderV2.Info({
catalog.provider.update(item.id, () => {}) ...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"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
}) })
yield* addPlugin()
expect( expect(
required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))) required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest")))
.enabled, .enabled,
@@ -1,45 +1,72 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Option } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" 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 { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "../lib/effect"
import { location } from "../fixture/location" import { PluginTestLayer } from "./fixture"
import { it, model, provider, required, withEnv } from "./provider-helper"
import { catalogHost, host, integrationHost } from "./host" 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<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
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 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", () => { describe("OpencodePlugin", () => {
it.effect("uses a public key and disables paid models without credentials", () => it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("opencode") const provider = new ProviderV2.Info({
catalog.provider.update(item.id, () => {}) ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
const paid = model("opencode", "paid", { cost: cost(1) }) api: { type: "aisdk", package: "test-provider" },
catalog.model.update(item.id, paid.id, (draft) => { })
draft.cost = [...paid.cost] 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.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) 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", () => it.effect("keeps free models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("opencode") const provider = new ProviderV2.Info({
catalog.provider.update(item.id, () => {}) ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
const free = model("opencode", "free", { cost: cost(0) }) api: { type: "aisdk", package: "test-provider" },
catalog.model.update(item.id, free.id, (draft) => { })
draft.cost = [...free.cost] 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.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) 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", () => it.effect("treats output-only cost as free without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("opencode") const provider = new ProviderV2.Info({
catalog.provider.update(item.id, () => {}) ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) }) api: { type: "aisdk", package: "test-provider" },
catalog.model.update(item.id, outputOnly.id, (draft) => { })
draft.cost = [...outputOnly.cost] 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.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( expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
true, true,
@@ -91,17 +130,23 @@ describe("OpencodePlugin", () => {
it.effect("uses OPENCODE_API_KEY as credentials", () => it.effect("uses OPENCODE_API_KEY as credentials", () =>
withEnv({ OPENCODE_API_KEY: "secret" }, () => withEnv({ OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("opencode") const provider = new ProviderV2.Info({
catalog.provider.update(item.id, () => {}) ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
const paid = model("opencode", "paid", { cost: cost(1) }) api: { type: "aisdk", package: "test-provider" },
catalog.model.update(item.id, paid.id, (draft) => { })
draft.cost = [...paid.cost] 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.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) 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", () => it.effect("uses configured provider env vars as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () => withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
yield* plugin.add(pluginWithIntegrations(catalog, integrations))
yield* integrations.transform((editor) => { yield* integrations.transform((editor) => {
editor.method.update({ editor.method.update({
integrationID: Integration.ID.make("opencode"), integrationID: Integration.ID.make("opencode"),
@@ -122,13 +165,21 @@ describe("OpencodePlugin", () => {
}) })
}) })
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("opencode") const provider = new ProviderV2.Info({
catalog.provider.update(item.id, () => {}) ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
const paid = model("opencode", "paid", { cost: cost(1) }) api: { type: "aisdk", package: "test-provider" },
catalog.model.update(item.id, paid.id, (draft) => { })
draft.cost = [...paid.cost] 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.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) 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", () => it.effect("uses configured apiKey as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { 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: { request: {
headers: {}, headers: {},
body: { apiKey: "configured" }, body: { apiKey: "configured" },
}, },
}) })
catalog.provider.update(item.id, (draft) => { const model = new ModelV2.Info({
draft.request = item.request ...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.provider.update(provider.id, (draft) => {
catalog.model.update(item.id, paid.id, (draft) => { draft.request = provider.request
draft.cost = [...paid.cost] })
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.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) 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", () => it.effect("ignores non-opencode providers and models", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("openai") const provider = new ProviderV2.Info({
catalog.provider.update(item.id, () => {}) ...ProviderV2.Info.empty(ProviderV2.ID.openai),
const paid = model("openai", "paid", { cost: cost(1) }) api: { type: "aisdk", package: "test-provider" },
catalog.model.update(item.id, paid.id, (draft) => { })
draft.cost = [...paid.cost] 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.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) 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) const selected = yield* catalog.model.small(providerID)
expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano")) expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe( }),
Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))),
),
) )
}) })
@@ -3,56 +3,59 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
import { ProviderV2 } from "@opencode-ai/core/provider" 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", () => { describe("OpenRouterPlugin", () => {
it.effect("is registered so legacy OpenRouter behavior can be applied", () => it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
Effect.sync(() => Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))),
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"openrouter",
),
),
) )
it.effect("applies legacy referer headers only to openrouter", () => it.effect("applies legacy referer headers only to openrouter", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, OpenRouterPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const openrouter = provider("openrouter", { catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
request: { headers: { Existing: "value" }, body: {} }, 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.make("nvidia"), () => {}) 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", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "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", () => it.effect("creates an SDK only for the OpenRouter package", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, OpenRouterPlugin) yield* addPlugin()
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { name: "openrouter" }, options: { name: "openrouter" },
}, },
@@ -62,7 +65,14 @@ describe("OpenRouterPlugin", () => {
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -71,52 +81,37 @@ describe("OpenRouterPlugin", () => {
it.effect("filters OpenRouter's gpt-5 chat alias", () => it.effect("filters OpenRouter's gpt-5 chat alias", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, OpenRouterPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const openrouter = provider("openrouter", { catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
})
catalog.provider.update(openrouter.id, (item) => {
item.api = openrouter.api
}) })
catalog.provider.update(ProviderV2.ID.openai, () => {}) catalog.provider.update(ProviderV2.ID.openai, () => {})
for (const item of [ catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {})
model("openrouter", "openai/gpt-5-chat"), catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5"), () => {})
model("openrouter", "openai/gpt-5"), catalog.model.update(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"), () => {})
model("openai", "openai/gpt-5-chat"),
]) {
catalog.model.update(item.providerID, item.id, () => {})
}
}) })
yield* addPlugin()
expect( expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(
required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))) false,
.enabled, )
).toBe(false) expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")))?.enabled).toBe(true)
expect( expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(true)
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)
}), }),
) )
it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () => it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, OpenRouterPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
}) })
yield* addPlugin()
expect( expect(
required( (yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")))
yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")), ?.enabled,
).enabled,
).toBe(true) ).toBe(true)
}), }),
) )
@@ -1,18 +1,50 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { 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", () => { describe("PerplexityPlugin", () => {
it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, PerplexityPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -22,11 +54,14 @@ describe("PerplexityPlugin", () => {
it.effect("ignores packages that are not the bundled Perplexity package", () => it.effect("ignores packages that are not the bundled Perplexity package", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, PerplexityPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/perplexity-compatible",
options: { name: "perplexity" }, options: { name: "perplexity" },
}, },
@@ -39,50 +74,40 @@ describe("PerplexityPlugin", () => {
it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providers: string[] = [] yield* addPlugin()
yield* addPlugin(plugin, PerplexityPlugin) const result = yield* plugin.trigger(
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(
"aisdk.sdk", "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", () => it.effect("creates bundled Perplexity SDKs for custom provider IDs", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providers: string[] = [] yield* addPlugin()
yield* addPlugin(plugin, PerplexityPlugin) const result = yield* plugin.trigger(
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(
"aisdk.sdk", "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", package: "@ai-sdk/perplexity",
options: { name: "custom-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* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, PerplexityPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -1,16 +1,54 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core"
import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper" import { ProviderV2 } from "@opencode-ai/core/provider"
import { host } from "./host" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const pluginWithNpm = { const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
id: SapAICorePlugin.id, const it = testEffect(PluginTestLayer)
effect: Effect.gen(function* () { const npm = Npm.Service.of({
yield* SapAICorePlugin.effect(host({ npm: yield* Npm.Service })) add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
}).pipe(Effect.provide(npmLayer)), 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<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
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", () => { describe("SapAICorePlugin", () => {
@@ -20,11 +58,11 @@ describe("SapAICorePlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm) yield* addPlugin()
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("sap-ai-core", "sap-model"), model: model("sap-ai-core"),
package: fixtureProvider, package: fixtureProvider,
options: { name: "sap-ai-core", serviceKey: "service-key" }, options: { name: "sap-ai-core", serviceKey: "service-key" },
}, },
@@ -46,11 +84,11 @@ describe("SapAICorePlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm) yield* addPlugin()
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("sap-ai-core", "sap-model"), model: model("sap-ai-core"),
package: fixtureProvider, package: fixtureProvider,
options: { name: "sap-ai-core", serviceKey: "option-service-key" }, options: { name: "sap-ai-core", serviceKey: "option-service-key" },
}, },
@@ -68,10 +106,10 @@ describe("SapAICorePlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm) yield* addPlugin()
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "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() expect(process.env.AICORE_SERVICE_KEY).toBeUndefined()
@@ -83,7 +121,7 @@ describe("SapAICorePlugin", () => {
it.effect("uses the callable SDK for language selection", () => it.effect("uses the callable SDK for language selection", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm) yield* addPlugin()
const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), { const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), {
languageModel() { languageModel() {
throw new Error("SAP AI Core should call the SDK directly") throw new Error("SAP AI Core should call the SDK directly")
@@ -91,7 +129,7 @@ describe("SapAICorePlugin", () => {
}) })
const language = yield* plugin.trigger( const language = yield* plugin.trigger(
"aisdk.language", "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" }) expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" })
@@ -104,11 +142,11 @@ describe("SapAICorePlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm) yield* addPlugin()
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("openai", "sap-model"), model: model("openai"),
package: fixtureProvider, package: fixtureProvider,
options: { name: "openai", serviceKey: "service-key" }, options: { name: "openai", serviceKey: "service-key" },
}, },
@@ -117,7 +155,7 @@ describe("SapAICorePlugin", () => {
const language = yield* plugin.trigger( const language = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: model("openai", "sap-model"), model: model("openai"),
sdk: () => { sdk: () => {
throw new Error("SAP AI Core should ignore other providers") throw new Error("SAP AI Core should ignore other providers")
}, },
@@ -1,18 +1,48 @@
import { describe, expect, it as bun_it } from "bun:test" import { describe, expect, it as bun_it } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" 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<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
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", () => { describe("SnowflakeCortexPlugin", () => {
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => { Effect.sync(() => {
expectPluginRegistered( expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex"))
ProviderPlugins.map((item) => item.id), const ids = ProviderPlugins.map((p) => p.id)
"snowflake-cortex",
)
const ids = ProviderPlugins.map((p) => p.id as string)
expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible")) expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible"))
}), }),
) )
@@ -20,10 +50,17 @@ describe("SnowflakeCortexPlugin", () => {
it.effect("ignores non-snowflake-cortex providers", () => it.effect("ignores non-snowflake-cortex providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, SnowflakeCortexPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeUndefined()
@@ -34,11 +71,17 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, SnowflakeCortexPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
}, },
@@ -53,11 +96,17 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, SnowflakeCortexPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { options: {
name: "snowflake-cortex", name: "snowflake-cortex",
@@ -76,11 +125,17 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () => withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, SnowflakeCortexPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, 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 }, () => withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, SnowflakeCortexPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { options: {
name: "snowflake-cortex", name: "snowflake-cortex",
@@ -118,27 +179,23 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const captured: Record<string, unknown>[] = [] yield* addPlugin()
yield* addPlugin(plugin, SnowflakeCortexPlugin) const result = yield* plugin.trigger(
yield* plugin.add({
id: PluginV2.ID.make("inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
captured.push({ ...evt.options })
}),
}),
})
yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, 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)
}), }),
), ),
) )
@@ -1,17 +1,50 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { 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", () => { describe("TogetherAIPlugin", () => {
it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, TogetherAIPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -21,12 +54,15 @@ describe("TogetherAIPlugin", () => {
it.effect("matches the old bundled provider package exactly", () => it.effect("matches the old bundled provider package exactly", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, TogetherAIPlugin) yield* addPlugin()
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "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", package: "file:///tmp/@ai-sdk/togetherai-provider.js",
options: { name: "togetherai" }, options: { name: "togetherai" },
}, },
@@ -36,7 +72,14 @@ describe("TogetherAIPlugin", () => {
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -46,29 +89,22 @@ describe("TogetherAIPlugin", () => {
it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const observed: string[] = [] yield* addPlugin()
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* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "@ai-sdk/togetherai",
options: { name: "custom-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* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, TogetherAIPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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 }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -1,17 +1,50 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { 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", () => { describe("VenicePlugin", () => {
it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => it.effect("creates a Venice SDK for venice-ai-sdk-provider", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, VenicePlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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() expect(result.sdk).toBeDefined()
@@ -21,39 +54,35 @@ describe("VenicePlugin", () => {
it.effect("uses the model provider ID as the bundled Venice SDK name", () => it.effect("uses the model provider ID as the bundled Venice SDK name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const observed: string[] = [] yield* addPlugin()
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)
}),
}),
})
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "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", package: "venice-ai-sdk-provider",
options: { name: "custom-venice", apiKey: "test" }, options: { name: "custom-venice", apiKey: "test" },
}, },
{}, {},
) )
expect(result.sdk).toBeDefined() 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", () => it.effect("only handles the bundled venice-ai-sdk-provider package", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, VenicePlugin) yield* addPlugin()
const similar = yield* plugin.trigger( const similar = yield* plugin.trigger(
"aisdk.sdk", "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", package: "file:///tmp/venice-ai-sdk-provider.js",
options: { name: "venice" }, options: { name: "venice" },
}, },
@@ -61,7 +90,14 @@ describe("VenicePlugin", () => {
) )
const other = yield* plugin.trigger( const other = yield* plugin.trigger(
"aisdk.sdk", "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() expect(similar.sdk).toBeUndefined()
@@ -73,10 +109,17 @@ describe("VenicePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, VenicePlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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([]) expect(calls).toEqual([])
@@ -1,28 +1,34 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
import { ProviderV2 } from "@opencode-ai/core/provider" 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", () => { describe("VercelPlugin", () => {
it.effect("applies legacy lower-case referer headers", () => it.effect("applies legacy lower-case referer headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, VercelPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("vercel", { catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/vercel" }, provider.api = { type: "aisdk", package: "@ai-sdk/vercel" }
request: { headers: { Existing: "1" }, body: {} }, provider.request.headers.Existing = "1"
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
draft.request = item.request
}) })
}) })
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", Existing: "1",
"http-referer": "https://opencode.ai/", "http-referer": "https://opencode.ai/",
"x-title": "opencode", "x-title": "opencode",
@@ -32,19 +38,17 @@ describe("VercelPlugin", () => {
it.effect("does not add legacy upper-case referer headers", () => it.effect("does not add legacy upper-case referer headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, VercelPlugin) yield* catalog.transform((catalog) =>
yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } }) provider.api = { type: "aisdk", package: "@ai-sdk/vercel" }
catalog.provider.update(item.id, (draft) => { }),
draft.api = item.api )
}) yield* addPlugin()
}) expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty(
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty(
"HTTP-Referer", "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", "X-Title",
) )
}), }),
@@ -53,10 +57,17 @@ describe("VercelPlugin", () => {
it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () => it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, VercelPlugin) yield* addPlugin()
const event = yield* plugin.trigger( const event = yield* plugin.trigger(
"aisdk.sdk", "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() expect(event.sdk).toBeDefined()
@@ -66,11 +77,10 @@ describe("VercelPlugin", () => {
it.effect("ignores non-Vercel providers", () => it.effect("ignores non-Vercel providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, VercelPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {}))
yield* catalog.transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({})
}), }),
) )
}) })
+60 -35
View File
@@ -1,37 +1,66 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" 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 { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" 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({ const addPlugin = Effect.fn(function* () {
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), const plugin = yield* PluginV2.Service
api: { const host = yield* PluginHost.make()
id: ModelV2.ID.make("grok-4"), yield* plugin.add({ id: XAIPlugin.id, effect: XAIPlugin.effect(host) })
type: "aisdk",
package: "@ai-sdk/xai",
},
}) })
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", () => { describe("XAIPlugin", () => {
it.effect("creates an xAI SDK only for @ai-sdk/xai", () => it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, XAIPlugin) yield* addPlugin()
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "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(ignored.sdk).toBeUndefined()
expect(typeof result.sdk?.responses).toBe("function") expect(typeof result.sdk?.responses).toBe("function")
@@ -41,32 +70,22 @@ describe("XAIPlugin", () => {
it.effect("creates xAI SDKs for custom provider IDs", () => it.effect("creates xAI SDKs for custom provider IDs", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providers: string[] = [] yield* addPlugin()
yield* addPlugin(plugin, XAIPlugin) const result = yield* plugin.trigger(
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(
"aisdk.sdk", "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", package: "@ai-sdk/xai",
options: {}, 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 plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, XAIPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -96,11 +118,14 @@ describe("XAIPlugin", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(plugin, XAIPlugin) yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "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), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -2,34 +2,44 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
import { ProviderV2 } from "@opencode-ai/core/provider" 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<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
describe("ZenmuxPlugin", () => { describe("ZenmuxPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () => it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))),
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"zenmux",
),
),
) )
it.effect("applies the exact legacy Zenmux headers", () => it.effect("applies the exact legacy Zenmux headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("zenmux", { catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, provider.api = {
}) type: "aisdk",
catalog.provider.update(item.id, (draft) => { package: "@ai-sdk/openai-compatible",
draft.api = item.api url: "https://zenmux.ai/api/v1",
}
}) })
}) })
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))) 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(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) 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", () => it.effect("merges legacy Zenmux headers with existing headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("zenmux", { catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, provider.api = {
request: { headers: { Existing: "value" }, body: {} }, type: "aisdk",
}) package: "@ai-sdk/openai-compatible",
catalog.provider.update(item.id, (draft) => { url: "https://zenmux.ai/api/v1",
draft.api = item.api }
draft.request = item.request provider.request.headers.Existing = "value"
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
Existing: "value", Existing: "value",
@@ -62,22 +71,18 @@ describe("ZenmuxPlugin", () => {
it.effect("lets configured Zenmux legacy headers override defaults", () => it.effect("lets configured Zenmux legacy headers override defaults", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("zenmux", { catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, provider.api = {
request: { type: "aisdk",
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, package: "@ai-sdk/openai-compatible",
body: {}, url: "https://zenmux.ai/api/v1",
}, }
}) provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
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("zenmux"))).request.headers).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
"HTTP-Referer": "https://example.com/", "HTTP-Referer": "https://example.com/",
@@ -88,20 +93,13 @@ describe("ZenmuxPlugin", () => {
it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () => it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = provider("openrouter", { catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
request: { provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {},
},
})
catalog.provider.update(item.id, (draft) => {
draft.request = item.request
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({
"HTTP-Referer": "https://example.com/", "HTTP-Referer": "https://example.com/",
+1
View File
@@ -0,0 +1 @@
process.env.OPENCODE_DB = ":memory:"
+6 -7
View File
@@ -16,17 +16,16 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" 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( const copyLayer = ProjectCopy.layer.pipe(
Layer.provide(databaseLayer), Layer.provide(Database.defaultLayer),
Layer.provide(directoriesLayer), Layer.provide(ProjectDirectories.defaultLayer),
Layer.provide(eventLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(FSUtil.defaultLayer), Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.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) { function abs(input: string) {
return AbsolutePath.make(input) return AbsolutePath.make(input)
@@ -8,10 +8,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:") const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer))
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 projectID = Project.ID.make("project-directories") const projectID = Project.ID.make("project-directories")
const directory = AbsolutePath.make("/tmp/project-directories") const directory = AbsolutePath.make("/tmp/project-directories")
+1 -12
View File
@@ -13,19 +13,8 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const databaseLayer = Database.layerFromPath(":memory:")
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
const it = testEffect( const it = testEffect(
Layer.mergeAll( Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer),
ProjectV2.layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(directoriesLayer),
Layer.provide(databaseLayer),
),
databaseLayer,
directoriesLayer,
),
) )
function remoteID(remote: string) { function remoteID(remote: string) {
+2 -4
View File
@@ -6,10 +6,8 @@ import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:") const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
const events = EventV2.layer.pipe(Layer.provide(database)) const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, questions))
const questions = QuestionV2.layer.pipe(Layer.provide(events))
const it = testEffect(Layer.mergeAll(database, events, questions))
const sessionID = SessionV2.ID.make("ses_question_test") const sessionID = SessionV2.ID.make("ses_question_test")
const question: QuestionV2.Info = { const question: QuestionV2.Info = {
+12 -8
View File
@@ -25,8 +25,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projects = Layer.succeed( const projects = Layer.succeed(
ProjectV2.Service, ProjectV2.Service,
ProjectV2.Service.of({ ProjectV2.Service.of({
@@ -35,17 +33,23 @@ const projects = Layer.succeed(
commit: () => Effect.void, 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( const sessions = SessionV2.layer.pipe(
Layer.provide(events), Layer.provide(EventV2.defaultLayer),
Layer.provide(database), Layer.provide(Database.defaultLayer),
Layer.provide(store), Layer.provide(SessionStore.defaultLayer),
Layer.provide(projects), Layer.provide(projects),
Layer.provide(SessionExecution.noopLayer), Layer.provide(SessionExecution.noopLayer),
) )
const it = testEffect( 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 location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const id = SessionV2.ID.create() const id = SessionV2.ID.create()
+4 -7
View File
@@ -21,10 +21,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:") const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer))
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 sessionID = SessionV2.ID.make("ses_projector_test") const sessionID = SessionV2.ID.make("ses_projector_test")
const created = DateTime.makeUnsafe(0) const created = DateTime.makeUnsafe(0)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
@@ -113,10 +110,10 @@ describe("SessionProjector", () => {
}).pipe( }).pipe(
Effect.provide( Effect.provide(
SessionV2.layer.pipe( SessionV2.layer.pipe(
Layer.provide(events), Layer.provide(EventV2.defaultLayer),
Layer.provide(database), Layer.provide(Database.defaultLayer),
Layer.provide(Project.defaultLayer), Layer.provide(Project.defaultLayer),
Layer.provide(SessionStore.layer.pipe(Layer.provide(database))), Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionExecution.noopLayer), Layer.provide(SessionExecution.noopLayer),
), ),
), ),
+13 -8
View File
@@ -18,10 +18,6 @@ import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect" 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 executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = [] const interruptCalls: SessionV2.ID[] = []
const interruptSeqs: Array<number | undefined> = [] const interruptSeqs: Array<number | undefined> = []
@@ -47,13 +43,22 @@ const execution = Layer.succeed(
}), }),
) )
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(events), Layer.provide(EventV2.defaultLayer),
Layer.provide(database), Layer.provide(Database.defaultLayer),
Layer.provide(store), Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer), Layer.provide(Project.defaultLayer),
Layer.provide(execution), 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 sessionID = SessionV2.ID.make("ses_prompt_test")
const messageID = SessionMessage.ID.create() const messageID = SessionMessage.ID.create()
@@ -268,7 +268,7 @@ describe("SessionRunnerModel", () => {
it.effect("prefers stored credentials over configured auth", () => it.effect("prefers stored credentials over configured auth", () =>
Effect.gen(function* () { Effect.gen(function* () {
const credential = new Credential.Stored({ const credential = new Credential.Info({
id: Credential.ID.create(), id: Credential.ID.create(),
integrationID: Integration.ID.make("test-provider"), integrationID: Integration.ID.make("test-provider"),
label: "Work", label: "Work",
@@ -32,10 +32,6 @@ import { Effect, Layer } from "effect"
import path from "node:path" import path from "node:path"
import { testEffect } from "./lib/effect" 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 = const cassette =
process.env.RECORD === "true" process.env.RECORD === "true"
? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", { ? 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 referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const runner = SessionRunnerLLM.defaultLayer.pipe( const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(database), Layer.provide(Database.defaultLayer),
Layer.provide(store), Layer.provide(SessionStore.defaultLayer),
Layer.provide(events), Layer.provide(EventV2.defaultLayer),
Layer.provide(client), Layer.provide(client),
Layer.provide(registry), Layer.provide(registry),
Layer.provide(models), Layer.provide(models),
@@ -101,18 +97,18 @@ const execution = Layer.effect(
), ),
).pipe(Layer.provide(coordinator)) ).pipe(Layer.provide(coordinator))
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(events), Layer.provide(EventV2.defaultLayer),
Layer.provide(database), Layer.provide(Database.defaultLayer),
Layer.provide(store), Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer), Layer.provide(Project.defaultLayer),
Layer.provide(execution), Layer.provide(execution),
) )
const it = testEffect( const it = testEffect(
Layer.mergeAll( Layer.mergeAll(
database, Database.defaultLayer,
events, EventV2.defaultLayer,
projector, SessionProjector.defaultLayer,
store, SessionStore.defaultLayer,
executor, executor,
client, client,
permission, permission,
+11 -15
View File
@@ -56,11 +56,7 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream }
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:") const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
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 requests: LLMRequest[] = [] const requests: LLMRequest[] = []
let response: LLMEvent[] = [] let response: LLMEvent[] = []
let responses: LLMEvent[][] | undefined let responses: LLMEvent[][] | undefined
@@ -235,9 +231,9 @@ const config = Layer.succeed(
}), }),
) )
const runner = SessionRunnerLLM.layer.pipe( const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(database), Layer.provide(Database.defaultLayer),
Layer.provide(store), Layer.provide(SessionStore.defaultLayer),
Layer.provide(events), Layer.provide(EventV2.defaultLayer),
Layer.provide(client), Layer.provide(client),
Layer.provide(registry), Layer.provide(registry),
Layer.provide(models), Layer.provide(models),
@@ -262,19 +258,19 @@ const execution = Layer.effect(
), ),
).pipe(Layer.provide(coordinator)) ).pipe(Layer.provide(coordinator))
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(events), Layer.provide(EventV2.defaultLayer),
Layer.provide(database), Layer.provide(Database.defaultLayer),
Layer.provide(store), Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer), Layer.provide(Project.defaultLayer),
Layer.provide(execution), Layer.provide(execution),
) )
const it = testEffect( const it = testEffect(
Layer.mergeAll( Layer.mergeAll(
database, Database.defaultLayer,
events, EventV2.defaultLayer,
questions, questions,
projector, SessionProjector.defaultLayer,
store, SessionStore.defaultLayer,
client, client,
permission, permission,
applications, applications,
+1 -4
View File
@@ -11,10 +11,7 @@ import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql"
import { SessionTodo } from "@opencode-ai/core/session/todo" import { SessionTodo } from "@opencode-ai/core/session/todo"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:") const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer))
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 sessionID = SessionV2.ID.make("ses_todo_test") const sessionID = SessionV2.ID.make("ses_todo_test")
const setup = Effect.gen(function* () { const setup = Effect.gen(function* () {
@@ -16,10 +16,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql" import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:") const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer))
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 timestamp = DateTime.makeUnsafe(1) const timestamp = DateTime.makeUnsafe(1)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
+1 -1
View File
@@ -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(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"])
expect(source).toContain( 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 [ 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.", "Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.",
@@ -6,7 +6,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { testEffect } from "./lib/effect" 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 fixture = Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const files = yield* FileSystem.FileSystem const files = yield* FileSystem.FileSystem
+16 -5
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect } from "bun:test" import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer, PlatformError } from "effect" import { Effect, Exit, Layer, PlatformError } from "effect"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments" 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 assertions: PermissionV2.AssertInput[] = []
const missingPath = "__missing_read_target__.txt" const missingPath = "__missing_read_target__.txt"
const missingAbsolutePath = `${process.cwd()}/${missingPath}` const missingAbsolutePath = path.join(process.cwd(), missingPath)
const readCalls: { const readCalls: {
input: AbsolutePath input: AbsolutePath
page: ReadToolFileSystem.PageInput page: ReadToolFileSystem.PageInput
@@ -164,7 +165,12 @@ describe("ReadTool", () => {
}, },
}) })
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }]) 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" }, { 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, { const settled = yield* settleTool(registry, {
sessionID, sessionID,
@@ -449,7 +460,7 @@ describe("ReadTool", () => {
}), }),
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
expect(readCalls).toEqual([ 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 }, value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
}) })
expect(readCalls).toEqual([ 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 } },
]) ])
}), }),
) )
+8 -5
View File
@@ -32,12 +32,15 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"), 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 registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos)) const tool = TodoWriteTool.layer.pipe(
const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool)) 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* () { const setup = Effect.gen(function* () {
assertions.length = 0 assertions.length = 0
+1 -1
View File
@@ -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(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"])
expect(source).toContain( 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 [ for (const todo of [
"Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.", "Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.",
+4
View File
@@ -13,6 +13,10 @@
"outputs": [], "outputs": [],
"passThroughEnv": ["*"] "passThroughEnv": ["*"]
}, },
"@opencode-ai/core#test": {
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/app#test": { "@opencode-ai/app#test": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": [] "outputs": []