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

This commit is contained in:
Dax 2026-06-22 00:15:34 -04:00 committed by GitHub
parent cdc6d01c5a
commit 2bb4311042
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 2864 additions and 1599 deletions

View File

@ -0,0 +1,2 @@
[test]
preload = ["./test/preload.ts"]

View File

@ -69,10 +69,10 @@ export const layer = Layer.effect(
const policy = yield* Policy.Service
const integrations = yield* Integration.Service
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true
if (connected) return true
if (integration?.connections.length) return true
return !integration
}
@ -183,13 +183,8 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const connections = yield* integrations.connection.list()
return (yield* result.provider.all()).filter((provider) =>
available(
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
available(provider, active.get(Integration.ID.make(provider.id))),
)
}),
},

View File

@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Info = Schema.Union([OAuth, Key])
export const Value = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Info" })
export type Info = Schema.Schema.Type<typeof Info>
.annotate({ identifier: "Credential.Value" })
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,
integrationID: IntegrationSchema.ID,
label: Schema.String,
value: Info,
value: Value,
}) {}
export interface Interface {
/** Returns every stored credential. */
readonly all: () => Effect.Effect<Stored[]>
readonly all: () => Effect.Effect<Info[]>
/** 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. */
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. */
readonly create: (input: {
readonly integrationID: IntegrationSchema.ID
readonly value: Info
readonly value: Value
readonly label?: string
}) => Effect.Effect<Stored>
}) => Effect.Effect<Info>
/** 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. */
readonly remove: (id: ID) => Effect.Effect<void>
}
@ -66,10 +66,10 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return
return new Stored({
return new Info({
id: row.id,
integrationID: row.integration_id,
label: row.label,
@ -106,7 +106,7 @@ export const layer = Layer.effect(
return row ? stored(row) : undefined
}),
create: Effect.fn("Credential.create")(function* (input) {
const credential = new Stored({
const credential = new Info({
id: ID.create(),
integrationID: input.integrationID,
label: input.label ?? "default",

View File

@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
id: text().$type<Credential.ID>().primaryKey(),
integration_id: text().$type<IntegrationSchema.ID>(),
label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Info>().notNull(),
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
connector_id: text(),
method_id: text(),
active: integer({ mode: "boolean" }),

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 INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
)
yield* tx.run(
`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`,
)
yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`)
yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
)
yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`)
yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`)
yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`)
yield* tx.run(`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)

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

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)),
)

View File

@ -108,11 +108,11 @@ export type OAuthAuthorization = {
} & (
| {
readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Info, unknown>
readonly callback: Effect.Effect<Credential.Value, unknown>
}
| {
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. */
readonly list: () => Effect.Effect<Info[]>
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. */
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** 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. */
readonly update: (
credentialID: Credential.ID,
updates: Partial<Pick<Credential.Stored, "label">>,
updates: Partial<Pick<Credential.Info, "label">>,
) => Effect.Effect<void>
/** Removes a stored credential connection. */
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),
})
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
const connected = saved.map((credential) => ({
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const credentials = saved.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
const detected = entry.methods
})).toReversed()
const env = (entry?.methods ?? [])
.filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name }))
return [...connected, ...detected]
return [...credentials, ...env]
}
const activeConnection = (
entry: Entry | undefined,
saved: readonly Credential.Stored[],
): IntegrationConnection.Info | undefined => {
const credential = saved.at(-1)
if (credential) return { type: "credential", id: credential.id, label: credential.label }
if (!entry) return
const name = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names)
.find((name) => process.env[name])
if (name) return { type: "env", name }
}
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
new Info({
id: entry.ref.id,
name: entry.ref.name,
methods: entry.methods,
connections: connections(entry, saved),
connections,
})
const authorize = <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)
}
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 result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID)
@ -450,28 +434,18 @@ export const locationLayer = Layer.effect(
get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id)
if (!entry) return undefined
return project(entry, yield* credentials.list(id))
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
}),
list: Effect.fn("Integration.list")(function* () {
return (yield* Effect.forEach(state.get().integrations.values(), (entry) =>
Effect.gen(function* () {
return project(entry, yield* credentials.list(entry.ref.id))
}),
)).toSorted((a, b) => a.name.localeCompare(b.name))
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return Array.from(state.get().integrations.values(), (entry) =>
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connection: {
list: Effect.fn("Integration.connection.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return new Map(
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
return connection ? [[id, connection] as const] : []
}),
)
}),
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
const entry = state.get().integrations.get(id)
return activeConnection(entry, yield* credentials.list(id))
return resolveConnections(entry, yield* credentials.list(id))[0]
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state

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. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => {
if (credential?.value.type === "key") return Auth.value(credential.value.key)
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
@ -85,7 +85,7 @@ const apiName = (model: ModelV2.Info) =>
export const fromCatalogModel = (
model: ModelV2.Info,
connection?: IntegrationConnection.Info,
credential?: Credential.Stored,
credential?: Credential.Info,
): Effect.Effect<Model, UnsupportedApiError> => {
const resolved =
credential?.value.metadata === undefined

View File

@ -11,7 +11,11 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
import { required } from "./plugin/provider-helper"
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
const locationLayer = Layer.succeed(
Location.Service,
@ -21,12 +25,7 @@ const it = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(
Layer.mock(Credential.Service)({
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}),
),
Layer.provideMerge(Credential.defaultLayer),
),
)
@ -48,38 +47,30 @@ describe("CatalogV2", () => {
it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test")
const first = {
id: Credential.ID.create(),
integrationID,
label: "First",
value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
}
const second = {
id: Credential.ID.create(),
integrationID,
label: "Second",
value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
}
let active = first
const layer = Catalog.locationLayer.pipe(
Layer.fresh,
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(
Layer.mock(Credential.Service)({
all: () => Effect.sync(() => [active]),
list: () => Effect.sync(() => [active]),
}),
),
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
)
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
yield* credentials.create({
integrationID,
label: "First",
value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
active = second
yield* credentials.create({
integrationID,
label: "Second",
value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
}).pipe(Effect.provide(layer))

View File

@ -1,14 +1,52 @@
import { describe, expect } from "bun:test"
import { Effect, Option, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, required, withEnv } from "../plugin/provider-helper"
import { catalogHost, host, integrationHost } from "../plugin/host"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* (config: Config.Interface) {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)),
})
})
function required<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) {
return {
@ -23,8 +61,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("partitions existing model variant bodies without changing config shape", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
const config = Config.Service.of({
@ -57,12 +93,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
]),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
})
yield* addPlugin(config)
const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.variants).toMatchObject([
@ -82,8 +113,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("uses the effective provider package across layered config", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
const config = Config.Service.of({
@ -116,12 +145,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
]),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
})
yield* addPlugin(config)
const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.variants[0]).toMatchObject({
@ -137,7 +161,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
@ -217,12 +240,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
]),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
})
yield* addPlugin(config)
const provider = required(yield* catalog.provider.get(providerID))
const model = required(yield* catalog.model.get(providerID, modelID))

View File

@ -1,26 +1,14 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { Integration } from "@opencode-ai/core/integration"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { testEffect } from "./lib/effect"
function layer(directory: string) {
return Credential.layer.pipe(
Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)),
)
}
const it = testEffect(Credential.defaultLayer)
describe("Credential", () => {
it.live("stores, updates, lists, and removes credentials", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
it.effect("stores, updates, lists, and removes credentials", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const created = yield* credentials.create({
@ -42,8 +30,6 @@ describe("Credential", () => {
yield* credentials.remove(replacement.id)
expect(yield* credentials.list(integrationID)).toEqual([])
}).pipe(Effect.provide(layer(tmp.path))),
),
),
}),
)
})

View File

@ -420,13 +420,12 @@ describe("EventV2", () => {
const readStarted = yield* Deferred.make<void>()
const continueRead = yield* Deferred.make<void>()
let pause = true
const database = Database.layerFromPath(":memory:")
const eventLayer = EventV2.layerWith({
beforeAggregateRead: () =>
pause
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void,
}).pipe(Layer.provide(database))
}).pipe(Layer.provide(Database.defaultLayer))
yield* Effect.gen(function* () {
const events = yield* EventV2.Service
@ -441,7 +440,7 @@ describe("EventV2", () => {
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, { id: aggregateID, text: "during handoff" }],
])
}).pipe(Effect.provide(Layer.mergeAll(database, eventLayer)))
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
}),
)

View File

@ -5,7 +5,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { testEffect } from "../lib/effect"
import path from "path"
const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer)
const { effect: it } = testEffect(live)
describe("FSUtil", () => {

View File

@ -22,7 +22,7 @@ describe("Ripgrep", () => {
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
expect(result.map((item) => item.path)).toEqual([RelativePath.make(path.join("src", "match.ts"))])
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
}),
),
)
@ -35,7 +35,7 @@ describe("Ripgrep", () => {
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
expect(result).toHaveLength(1)
expect(result[0]?.entry.path).toBe(RelativePath.make(path.join("src", "match.ts")))
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
expect(result[0]?.submatches[0]?.text).toBe("needle")
}),
),

View File

@ -1,6 +1,8 @@
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Layer } from "effect"
import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
@ -10,3 +12,15 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
vcs: input.vcs,
} satisfies Location.Interface
}
export const tempLocationLayer = Layer.unwrap(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.map((tmp) => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
}),
),
)

View File

@ -4,45 +4,15 @@ import * as TestClock from "effect/testing/TestClock"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect"
import { testEffect } from "./lib/effect"
const layer = Integration.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
create: () => Effect.die("unexpected credential creation"),
list: () => Effect.succeed([]),
}),
const it = testEffect(
Integration.locationLayer.pipe(
Layer.provideMerge(Credential.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
),
)
function connectionLayer(
created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}>,
) {
return Integration.locationLayer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
create: (input) =>
Effect.sync(() => {
created.push(input)
return new Credential.Stored({
id: Credential.ID.create(),
integrationID: input.integrationID,
label: input.label ?? "default",
value: input.value,
})
}),
list: () => Effect.succeed([]),
}),
),
)
}
describe("Integration", () => {
it.effect("registers integrations through the editor", () =>
Effect.gen(function* () {
@ -59,7 +29,7 @@ describe("Integration", () => {
yield* Scope.close(scope, Exit.void)
expect(yield* integrations.get(openai)).toBeUndefined()
}).pipe(Effect.provide(layer)),
}),
)
it.effect("reveals the previous registration when an override closes", () =>
@ -80,7 +50,7 @@ describe("Integration", () => {
yield* Scope.close(second, Exit.void)
expect((yield* integrations.get(id))?.name).toBe("OpenAI")
expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id])
}).pipe(Effect.provide(layer)),
}),
)
it.effect("registers and overrides methods independently", () =>
@ -128,17 +98,13 @@ describe("Integration", () => {
yield* Scope.close(second, Exit.void)
expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" })
expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })])
}).pipe(Effect.provide(layer)),
}),
)
it.effect("connects with a key and stores the credential", () => {
const created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
it.effect("connects with a key and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const events = yield* EventV2.Service
const integrationID = Integration.ID.make("openai")
yield* integrations.transform((editor) =>
@ -158,25 +124,21 @@ describe("Integration", () => {
label: "Work",
})
expect(created).toEqual([
{
expect(yield* credentials.list(integrationID)).toEqual([
expect.objectContaining({
integrationID,
label: "Work",
value: new Credential.Key({ type: "key", key: "secret" }),
},
}),
])
expect((yield* Fiber.join(updated)).length).toBe(1)
}).pipe(Effect.provide(connectionLayer(created)))
})
}),
)
it.effect("completes code OAuth once and stores the credential", () => {
const created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
yield* integrations.transform((editor) =>
@ -212,29 +174,27 @@ describe("Integration", () => {
expect(attempt.mode).toBe("code")
yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" })
expect(created[0]).toEqual({
integrationID,
label: "Personal",
value: new Credential.OAuth({
type: "oauth",
methodID,
access: "access",
refresh: "refresh",
expires: 1,
metadata: { code: "1234" },
expect((yield* credentials.list(integrationID))[0]).toEqual(
expect.objectContaining({
integrationID,
label: "Personal",
value: new Credential.OAuth({
type: "oauth",
methodID,
access: "access",
refresh: "refresh",
expires: 1,
metadata: { code: "1234" },
}),
}),
})
}).pipe(Effect.provide(connectionLayer(created)))
})
)
}),
)
it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => {
const created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
it.effect("keeps code attempts open when the code is missing and closes them on cancel", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
let closed = false
@ -261,18 +221,14 @@ describe("Integration", () => {
expect(closed).toBe(false)
yield* integrations.attempt.cancel(attempt.attemptID)
expect(closed).toBe(true)
expect(created).toEqual([])
}).pipe(Effect.provide(connectionLayer(created)))
})
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
it.effect("completes auto OAuth in the background", () => {
const created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
it.effect("completes auto OAuth in the background", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("browser")
yield* integrations.transform((editor) =>
@ -297,18 +253,14 @@ describe("Integration", () => {
status: "complete",
time: attempt.time,
})
expect(created).toHaveLength(1)
}).pipe(Effect.provide(connectionLayer(created)))
})
expect(yield* credentials.list(integrationID)).toHaveLength(1)
}),
)
it.effect("expires abandoned OAuth attempts", () => {
const created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
it.effect("expires abandoned OAuth attempts", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("browser")
let closed = false
@ -337,34 +289,12 @@ describe("Integration", () => {
time: attempt.time,
})
expect(closed).toBe(true)
expect(created).toEqual([])
}).pipe(Effect.provide(connectionLayer(created)))
})
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
it.effect("projects credential and env connections", () => {
const integrationID = Integration.ID.make("acme")
const rows = [
{
id: Credential.ID.create(),
integrationID,
label: "Work",
value: new Credential.Key({ type: "key", key: "a" }),
},
{
id: Credential.ID.create(),
integrationID,
label: "Personal",
value: new Credential.Key({ type: "key", key: "b" }),
},
]
const projectionLayer = Integration.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
list: () => Effect.succeed(rows.map((row) => new Credential.Stored(row))),
}),
),
)
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.INTEGRATION_TEST_ACME_KEY
@ -375,6 +305,7 @@ describe("Integration", () => {
() =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
@ -384,23 +315,33 @@ describe("Integration", () => {
},
}),
)
const work = yield* credentials.create({
integrationID,
label: "Work",
value: new Credential.Key({ type: "key", key: "a" }),
})
const personal = yield* credentials.create({
integrationID,
label: "Personal",
value: new Credential.Key({ type: "key", key: "b" }),
})
// Stored credentials and detected env vars appear as connections.
expect((yield* integrations.get(integrationID))?.connections).toEqual([
{ type: "credential", id: rows[0]!.id, label: "Work" },
{
type: "credential",
id: rows[1]!.id,
id: personal.id,
label: "Personal",
},
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
])
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
type: "credential",
id: rows[1]!.id,
id: personal.id,
label: "Personal",
})
}).pipe(Effect.provide(projectionLayer)),
expect(work.id).not.toBe(personal.id)
}),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY

View File

@ -36,8 +36,7 @@ const it = testEffect(
Layer.mergeAll(
Project.defaultLayer,
EventV2.defaultLayer,
Credential.defaultLayer,
Credential.layer.pipe(Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh))),
Credential.defaultLayer.pipe(Layer.fresh),
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,

View File

@ -21,34 +21,39 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events))
const project = Project.layer.pipe(
Layer.provide(database),
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(directories),
Layer.provide(ProjectDirectories.defaultLayer),
)
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(
Layer.provide(database),
Layer.provide(events),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(project),
Layer.provide(store),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
)
const layer = MoveSession.layer.pipe(
Layer.provide(database),
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(events),
Layer.provide(EventV2.defaultLayer),
Layer.provide(project),
Layer.provide(sessions),
)
const it = testEffect(
Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions),
Layer.mergeAll(
layer,
Database.defaultLayer,
EventV2.defaultLayer,
ProjectDirectories.defaultLayer,
project,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
SessionExecution.noopLayer,
sessions,
),
)
function abs(input: string) {

View File

@ -18,29 +18,25 @@ import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const current = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const events = EventV2.layer.pipe(Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
)
const saved = PermissionSaved.layer.pipe(Layer.provide(database))
const layer = PermissionV2.locationLayer.pipe(
Layer.provideMerge(database),
Layer.provideMerge(store),
Layer.provideMerge(events),
Layer.provideMerge(Database.defaultLayer),
Layer.provideMerge(SessionStore.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(current),
Layer.provideMerge(sessions),
Layer.provideMerge(SessionExecution.noopLayer),
Layer.provideMerge(saved),
Layer.provideMerge(PermissionSaved.defaultLayer),
)
const it = testEffect(layer)

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,
),
),
)

View File

@ -24,11 +24,7 @@ const locationLayer = Layer.succeed(
)
const plugins = PluginV2.layer.pipe(Layer.provide(events))
const policy = Policy.layer.pipe(Layer.provide(locationLayer))
const connections = Credential.layer.pipe(
Layer.fresh,
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
Layer.provide(events),
)
const connections = Credential.defaultLayer.pipe(Layer.fresh)
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
const catalog = Catalog.layer.pipe(
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),

View File

@ -3,17 +3,35 @@ import { createAlibaba } from "@ai-sdk/alibaba"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
import { addPlugin, it, model } from "./provider-helper"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: AlibabaPlugin.id, effect: AlibabaPlugin.effect(host) })
})
describe("AlibabaPlugin", () => {
it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AlibabaPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/alibaba",
options: { name: "alibaba" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -23,10 +41,17 @@ describe("AlibabaPlugin", () => {
it.effect("ignores non-Alibaba SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AlibabaPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "alibaba" },
},
{},
)
expect(result.sdk).toBeUndefined()
@ -36,11 +61,14 @@ describe("AlibabaPlugin", () => {
it.effect("matches the old bundled Alibaba SDK provider naming", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AlibabaPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-alibaba", "qwen"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")),
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/alibaba",
options: { name: "custom-alibaba", apiKey: "test" },
},
@ -56,8 +84,11 @@ describe("AlibabaPlugin", () => {
it.effect("uses the old default languageModel(api.id) behavior", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AlibabaPlugin)
const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } })
yield* addPlugin()
const item = new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" },
})
const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {})
const language = result.sdk?.languageModel(item.api.id)
expect(language?.modelId).toBe("qwen-plus")

View File

@ -1,10 +1,61 @@
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: AmazonBedrockPlugin.id, effect: AmazonBedrockPlugin.effect(host) })
})
function required<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") {
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
@ -28,11 +79,10 @@ function openAIUrl(language: unknown, path: string, modelId: string) {
describe("AmazonBedrockPlugin", () => {
it.effect("moves endpoint option to api URL", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* catalog.transform((catalog) => {
const bedrock = provider("amazon-bedrock", {
const bedrock = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock),
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
request: {
headers: {},
@ -44,6 +94,7 @@ describe("AmazonBedrockPlugin", () => {
item.request = bedrock.request
})
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock))
expect(result.api).toEqual({
type: "aisdk",
@ -58,11 +109,14 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock",
options: {
name: "amazon-bedrock",
@ -83,11 +137,14 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock",
options: {
name: "amazon-bedrock",
@ -117,11 +174,21 @@ describe("AmazonBedrockPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.amazonBedrock,
ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
),
api: {
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" },
},
@ -137,11 +204,14 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock", region: "eu-west-1" },
},
@ -156,11 +226,14 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" },
},
@ -175,11 +248,14 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" },
},
@ -195,11 +271,14 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const headers: Array<string | null> = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock",
options: {
name: "amazon-bedrock",
@ -224,11 +303,14 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const headers: Array<string | null> = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock",
options: {
name: "amazon-bedrock",
@ -252,12 +334,17 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "openai.gpt-5.5", {
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
api: {
id: ModelV2.ID.make("openai.gpt-5.5"),
type: "aisdk",
package: "@ai-sdk/amazon-bedrock/mantle",
},
}),
package: "@ai-sdk/amazon-bedrock/mantle",
options: {
@ -281,12 +368,17 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "openai.gpt-5.5", {
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
api: {
id: ModelV2.ID.make("openai.gpt-5.5"),
type: "aisdk",
package: "@ai-sdk/amazon-bedrock/mantle",
},
}),
sdk: fakeSelectorSdk(calls),
options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" },
@ -296,8 +388,16 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "openai.gpt-oss-safeguard-120b", {
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.amazonBedrock,
ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
),
api: {
id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
type: "aisdk",
package: "@ai-sdk/amazon-bedrock/mantle",
},
}),
sdk: fakeSelectorSdk(calls),
options: { region: "us-east-1" },
@ -311,12 +411,17 @@ describe("AmazonBedrockPlugin", () => {
it.effect("ignores other Bedrock provider subpaths", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5", {
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/anthropic" },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: {
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
type: "aisdk",
package: "@ai-sdk/amazon-bedrock/anthropic",
},
}),
package: "@ai-sdk/amazon-bedrock/anthropic",
options: { name: "amazon-bedrock" },
@ -340,11 +445,21 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const headers: Array<string | null> = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.amazonBedrock,
ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
),
api: {
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/amazon-bedrock",
options: {
name: "amazon-bedrock",
@ -371,11 +486,14 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
@ -384,7 +502,10 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" },
},
@ -393,7 +514,17 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.amazonBedrock,
ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"),
),
api: {
id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"),
type: "aisdk",
package: "test-provider",
},
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" },
},
@ -402,7 +533,10 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "ap-northeast-1" },
},
@ -411,7 +545,10 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "ap-southeast-2" },
},
@ -432,11 +569,14 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
@ -517,12 +657,15 @@ describe("AmazonBedrockPlugin", () => {
expected: "au.anthropic.claude-sonnet-4-5",
},
]
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
for (const item of cases) {
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", item.modelID),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)),
api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: item.region },
},
@ -537,11 +680,14 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("openai", "anthropic.claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" },
},

View File

@ -1,19 +1,34 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, it, model, provider, required } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: AnthropicPlugin.id, effect: AnthropicPlugin.effect(host) })
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
describe("AnthropicPlugin", () => {
it.effect("applies legacy beta headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AnthropicPlugin)
yield* catalog.transform((catalog) => {
const item = provider("anthropic", {
const item = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.anthropic),
api: { type: "aisdk", package: "@ai-sdk/anthropic" },
request: { headers: { Existing: "1" }, body: {} },
})
@ -22,6 +37,7 @@ describe("AnthropicPlugin", () => {
draft.request = item.request
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe(
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
)
@ -31,10 +47,9 @@ describe("AnthropicPlugin", () => {
it.effect("ignores non-Anthropic providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AnthropicPlugin)
yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {}))
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
yield* addPlugin()
expect(
required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"],
).toBeUndefined()
@ -44,54 +59,43 @@ describe("AnthropicPlugin", () => {
it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* addPlugin(plugin, AnthropicPlugin)
yield* plugin.add({
id: PluginV2.ID.make("anthropic-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider)
}),
}),
})
yield* plugin.trigger(
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-anthropic", "claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("custom-anthropic"),
ModelV2.ID.make("claude-sonnet-4-5"),
),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" },
}),
package: "@ai-sdk/anthropic",
options: { name: "custom-anthropic", apiKey: "test" },
},
{},
)
expect(providers).toEqual(["custom-anthropic"])
expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("custom-anthropic")
}),
)
it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* addPlugin(plugin, AnthropicPlugin)
yield* plugin.add({
id: PluginV2.ID.make("anthropic-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider)
}),
}),
})
yield* plugin.trigger(
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("anthropic", "claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" },
}),
package: "@ai-sdk/anthropic",
options: { name: "anthropic", apiKey: "test" },
},
{},
)
expect(providers).toEqual(["anthropic"])
expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic")
}),
)
})

View File

@ -1,23 +1,73 @@
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: AzureCognitiveServicesPlugin.id, effect: AzureCognitiveServicesPlugin.effect(host) })
})
function required<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", () => {
it.effect("maps the resource env var to the Azure SDK baseURL", () =>
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
})
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
expect(result.api).toEqual({
type: "aisdk",
@ -33,14 +83,16 @@ describe("AzureCognitiveServicesPlugin", () => {
it.effect("leaves baseURL unset without resource env and ignores other providers", () =>
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* catalog.transform((catalog) => {
const azure = provider("azure-cognitive-services", {
const azure = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")),
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
})
const openai = provider("openai")
const openai = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
api: { type: "aisdk", package: "test-provider" },
})
catalog.provider.update(azure.id, (item) => {
item.api = azure.api
})
@ -48,6 +100,7 @@ describe("AzureCognitiveServicesPlugin", () => {
item.api = openai.api
})
})
yield* addPlugin()
const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai))
expect(azure.request.body.baseURL).toBeUndefined()
@ -62,11 +115,17 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure-cognitive-services", "deployment"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("azure-cognitive-services"),
ModelV2.ID.make("deployment"),
),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: { useCompletionUrls: true },
},
@ -80,15 +139,32 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("azure-cognitive-services"),
ModelV2.ID.make("deployment"),
),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
const ignored = yield* plugin.trigger(
"aisdk.language",
{ model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual(["responses:deployment"])
@ -101,11 +177,17 @@ describe("AzureCognitiveServicesPlugin", () => {
const plugin = yield* PluginV2.Service
const calls: string[] = []
const sdk = fakeSelectorSdk(calls)
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure-cognitive-services", "messages-deployment"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("azure-cognitive-services"),
ModelV2.ID.make("messages-deployment"),
),
api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel },
options: {},
},
@ -114,7 +196,13 @@ describe("AzureCognitiveServicesPlugin", () => {
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure-cognitive-services", "chat-deployment"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("azure-cognitive-services"),
ModelV2.ID.make("chat-deployment"),
),
api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: { chat: sdk.chat, languageModel: sdk.languageModel },
options: {},
},
@ -123,7 +211,13 @@ describe("AzureCognitiveServicesPlugin", () => {
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure-cognitive-services", "language-deployment"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("azure-cognitive-services"),
ModelV2.ID.make("language-deployment"),
),
api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: sdk.languageModel },
options: {},
},

View File

@ -1,23 +1,73 @@
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: AzurePlugin.id, effect: AzurePlugin.effect(host) })
})
function required<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", () => {
it.effect("resolves resourceName from env", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
})
})
yield* addPlugin(plugin, AzurePlugin)
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
}),
),
@ -26,10 +76,10 @@ describe("AzurePlugin", () => {
it.effect("keeps explicit resourceName over env and ignores other providers", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const azure = provider("azure", {
const azure = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.azure),
api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: "from-config" } },
})
@ -39,7 +89,7 @@ describe("AzurePlugin", () => {
})
catalog.provider.update(ProviderV2.ID.openai, () => {})
})
yield* addPlugin(plugin, AzurePlugin)
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config")
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined()
}),
@ -49,10 +99,10 @@ describe("AzurePlugin", () => {
it.effect("falls back to env when configured resourceName is blank", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const azure = provider("azure", {
const azure = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.azure),
api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: "" } },
})
@ -61,7 +111,7 @@ describe("AzurePlugin", () => {
item.request = azure.request
})
})
yield* addPlugin(plugin, AzurePlugin)
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
}),
),
@ -70,10 +120,10 @@ describe("AzurePlugin", () => {
it.effect("falls back to env when configured resourceName is whitespace", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const azure = provider("azure", {
const azure = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.azure),
api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: " " } },
})
@ -82,7 +132,7 @@ describe("AzurePlugin", () => {
item.request = azure.request
})
})
yield* addPlugin(plugin, AzurePlugin)
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
}),
),
@ -92,11 +142,14 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AzurePlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("azure", "deployment"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/azure",
options: { name: "azure", baseURL: "https://proxy.example.com/openai" },
},
@ -111,11 +164,18 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AzurePlugin)
yield* addPlugin()
const exit = yield* plugin
.trigger(
"aisdk.sdk",
{ model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/azure",
options: { name: "azure" },
},
{},
)
.pipe(Effect.exit)
@ -128,10 +188,17 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: { useCompletionUrls: true },
},
{},
)
expect(calls).toEqual(["chat:deployment"])
@ -142,10 +209,17 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: { useCompletionUrls: true },
},
{},
)
expect(calls).toEqual(["chat:deployment"])
@ -156,11 +230,13 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure", "deployment", {
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
request: { headers: {}, body: { useCompletionUrls: true } },
}),
sdk: fakeSelectorSdk(calls),
@ -176,15 +252,29 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
const ignored = yield* plugin.trigger(
"aisdk.language",
{ model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual(["responses:deployment"])
@ -200,11 +290,14 @@ describe("AzurePlugin", () => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" }
}
yield* addPlugin(plugin, AzurePlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure", "messages-deployment"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")),
api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") },
options: {},
},
@ -212,7 +305,14 @@ describe("AzurePlugin", () => {
)
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")),
api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: make("languageModel") },
options: {},
},
{},
)
expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"])

View File

@ -1,12 +1,22 @@
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, it, model, required } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const cerebrasOptions: Record<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", () => ({
createCerebras: (options: Record<string, unknown>) => {
@ -21,16 +31,15 @@ void mock.module("@ai-sdk/cerebras", () => ({
describe("CerebrasPlugin", () => {
it.effect("applies the legacy integration header", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/cerebras" }
item.request.headers.Existing = "1"
})
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.request.headers).toEqual({
Existing: "1",
"X-Cerebras-3rd-Party-Integration": "opencode",
})
@ -39,11 +48,10 @@ describe("CerebrasPlugin", () => {
it.effect("ignores non-Cerebras providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.request.headers).toEqual({})
}),
)
@ -51,11 +59,21 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
),
api: {
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/cerebras",
options: { name: "custom-cerebras", apiKey: "test" },
},
@ -70,11 +88,21 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
),
api: {
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/cerebras",
options: { name: "configured-cerebras", apiKey: "test" },
},
@ -88,11 +116,21 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
),
api: {
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/groq",
options: { name: "custom-cerebras", apiKey: "test" },
},

View File

@ -1,8 +1,41 @@
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
import { addPlugin, it, model, withEnv } from "./provider-helper"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: CloudflareAIGatewayPlugin.id, effect: CloudflareAIGatewayPlugin.effect(host) })
})
function withEnv<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 unifiedCalls: string[] = []
@ -78,11 +111,17 @@ describe("CloudflareAIGatewayPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("openai/gpt-5"),
),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" },
},
@ -98,12 +137,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("openai/gpt-5"),
),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider",
options: {
name: "cloudflare-ai-gateway",
@ -142,12 +187,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("openai/gpt-5"),
),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider",
options: {
name: "cloudflare-ai-gateway",
@ -171,12 +222,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("openai/gpt-5"),
),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider",
options: {
name: "cloudflare-ai-gateway",
@ -208,12 +265,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("openai/gpt-5"),
),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider",
options: {
name: "cloudflare-ai-gateway",
@ -239,12 +302,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("openai/gpt-5"),
),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" },
},
@ -261,12 +330,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("openai/gpt-5"),
),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" },
},
@ -284,12 +359,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("openai/gpt-5"),
),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" },
},
@ -313,12 +394,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("openai/gpt-5"),
),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" },
},
@ -336,12 +423,22 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
),
api: {
id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
type: "aisdk",
package: "test-provider",
},
}),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" },
},
@ -364,12 +461,18 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("openai/gpt-5"),
),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-ai-gateway" },
},

View File

@ -3,9 +3,59 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: CloudflareWorkersAIPlugin.id, effect: CloudflareWorkersAIPlugin.effect(host) })
})
function required<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") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
@ -37,12 +87,15 @@ describe("CloudflareWorkersAIPlugin", () => {
provider.api = { type: "aisdk", package: "test-provider" }
}),
)
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "@cf/model", { api: provider.api }),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
api: { id: ModelV2.ID.make("@cf/model"), ...provider.api },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
},
@ -61,14 +114,13 @@ describe("CloudflareWorkersAIPlugin", () => {
it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
}),
)
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
type: "aisdk",
package: "test-provider",
@ -82,12 +134,18 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "@cf/model", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
api: {
id: ModelV2.ID.make("@cf/model"),
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://proxy.example/v1",
},
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
@ -102,7 +160,6 @@ describe("CloudflareWorkersAIPlugin", () => {
it.effect("uses env account ID over configured account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
@ -110,7 +167,7 @@ describe("CloudflareWorkersAIPlugin", () => {
provider.request.body.accountId = "configured-acct"
}),
)
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
type: "aisdk",
package: "test-provider",
@ -124,12 +181,18 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "@cf/model", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
api: {
id: ModelV2.ID.make("@cf/model"),
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://proxy.example/v1",
},
}),
package: "@ai-sdk/openai-compatible",
options: {
@ -153,12 +216,14 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "@cf/model", {
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
api: {
id: ModelV2.ID.make("@cf/model"),
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
@ -183,11 +248,14 @@ describe("CloudflareWorkersAIPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("cloudflare-workers-ai", "alias", { api: { id: ModelV2.ID.make("@cf/api-model") } }),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
@ -202,12 +270,18 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "@cf/model", {
api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
api: {
id: ModelV2.ID.make("@cf/model"),
type: "aisdk",
package: "@ai-sdk/anthropic",
url: "https://proxy.example/v1",
},
}),
package: "@ai-sdk/anthropic",
options: { name: "cloudflare-workers-ai" },

View File

@ -2,10 +2,34 @@ import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
import { ProviderV2 } from "@opencode-ai/core/provider"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const cohereOptions: Record<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", () => ({
createCohere: (options: Record<string, any>) => {
@ -24,18 +48,32 @@ describe("CoherePlugin", () => {
it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CoherePlugin)
yield* addPlugin()
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("cohere", "command"), package: "@ai-sdk/openai-compatible", options: { name: "cohere" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cohere" },
},
{},
)
expect(ignored.sdk).toBeUndefined()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("cohere", "command"), package: "@ai-sdk/cohere", options: { name: "cohere" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/cohere",
options: { name: "cohere" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -45,11 +83,14 @@ describe("CoherePlugin", () => {
it.effect("uses the model provider ID as the bundled SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CoherePlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-cohere", "command-r-plus"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")),
api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/cohere",
options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" },
},
@ -70,10 +111,17 @@ describe("CoherePlugin", () => {
const plugin = yield* PluginV2.Service
const calls: string[] = []
const sdk = fakeSelectorSdk(calls)
yield* addPlugin(plugin, CoherePlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" },
}),
sdk,
options: {},
},
{},
)

View File

@ -1,20 +1,25 @@
import { describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { EventV2 } from "@opencode-ai/core/event"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { addPlugin, it, model } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const itAISDK = testEffect(
Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))),
)
const deepinfraOptions: Record<string, any>[] = []
const it = testEffect(PluginTestLayer)
const deepinfraOptions: Record<string, unknown>[] = []
const deepinfraLanguageModels: string[] = []
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: DeepInfraPlugin.id, effect: DeepInfraPlugin.effect(host) })
})
void mock.module("@ai-sdk/deepinfra", () => ({
createDeepInfra: (options: Record<string, any>) => {
createDeepInfra: (options: Record<string, unknown>) => {
const captured = { ...options }
deepinfraOptions.push(captured)
return {
@ -36,10 +41,17 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () {
resetDeepInfraMock()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, DeepInfraPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
package: "@ai-sdk/deepinfra",
options: { name: "deepinfra" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -50,11 +62,14 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () {
resetDeepInfraMock()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, DeepInfraPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-deepinfra", "model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
package: "@ai-sdk/deepinfra",
options: { name: "custom-deepinfra", apiKey: "test" },
},
@ -69,11 +84,14 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () {
resetDeepInfraMock()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, DeepInfraPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("deepinfra", "model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
package: "@ai-sdk/deepinfra",
options: { name: "deepinfra", apiKey: "test" },
},
@ -88,7 +106,7 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () {
resetDeepInfraMock()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, DeepInfraPlugin)
yield* addPlugin()
const packages = [
"unmatched-package",
"@ai-sdk/deepinfra-compatible",
@ -98,7 +116,14 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () {
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
package: item,
options: { name: "deepinfra" },
},
{},
)
expect(ignored.sdk).toBeUndefined()
@ -106,7 +131,14 @@ describe("DeepInfraPlugin", () => {
)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
package: "@ai-sdk/deepinfra",
options: { name: "deepinfra" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -114,17 +146,36 @@ describe("DeepInfraPlugin", () => {
}),
)
itAISDK.effect("uses the default languageModel selection for DeepInfra models", () =>
it.effect("uses the default languageModel selection for DeepInfra models", () =>
Effect.gen(function* () {
resetDeepInfraMock()
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin(plugin, DeepInfraPlugin)
const language = yield* aisdk.language(
model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", {
api: { type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
yield* addPlugin()
const sdkEvent = yield* plugin.trigger(
"aisdk.sdk",
{
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("deepinfra"),
ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"),
),
api: {
id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"),
type: "aisdk",
package: "@ai-sdk/deepinfra",
},
}),
package: "@ai-sdk/deepinfra",
options: { name: "deepinfra" },
},
{},
)
const result = yield* plugin.trigger(
"aisdk.language",
{ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options },
{},
)
const language = result.language ?? result.sdk.languageModel(result.model.api.id)
expect(language.provider).toBe("deepinfra.chat")
expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"])
}),

View File

@ -1,43 +1,40 @@
import { Npm } from "@opencode-ai/core/npm"
import { describe, expect } from "bun:test"
import { Cause, Effect, Layer, Option } from "effect"
import { Cause, Effect, Layer } from "effect"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { AISDK } from "@opencode-ai/core/aisdk"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { host } from "./host"
import { fixtureProvider, it, model, npmLayer } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const fixtureProviderPath = fileURLToPath(fixtureProvider)
const itWithAISDK = testEffect(
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
)
const it = testEffect(PluginTestLayer)
const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginTestLayer)))
function npmEntrypointLayer(entrypoint?: string) {
return Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
install: () => Effect.void,
which: () => Effect.succeed(undefined),
}),
)
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
install: () => Effect.void,
which: () => Effect.succeed(undefined),
})
}
function dynamicPlugin(layer = npmLayer) {
return {
const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({
id: DynamicProviderPlugin.id,
effect: Effect.gen(function* () {
yield* DynamicProviderPlugin.effect(host({ npm: yield* Npm.Service }))
}).pipe(Effect.provide(layer)),
}
}
effect: DynamicProviderPlugin.effect(npm ? { ...host, npm } : host),
})
})
function tempEntrypoint(source: string) {
return Effect.acquireRelease(
@ -55,11 +52,14 @@ describe("DynamicProviderPlugin", () => {
it.effect("creates an SDK from a provider factory export", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(dynamicPlugin())
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom", "test-model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
}),
package: fixtureProvider,
options: { name: "custom", marker: "dynamic" },
},
@ -74,11 +74,14 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const sdk = { marker: "existing" }
yield* plugin.add(dynamicPlugin())
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom", "test-model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
}),
package: fixtureProvider,
options: { name: "custom", marker: "dynamic" },
},
@ -91,11 +94,14 @@ describe("DynamicProviderPlugin", () => {
it.effect("injects the provider ID as the SDK factory name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(dynamicPlugin())
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-provider", "test-model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
}),
package: fixtureProvider,
options: { name: "custom-provider", marker: "dynamic" },
},
@ -108,11 +114,14 @@ describe("DynamicProviderPlugin", () => {
it.effect("loads npm packages through their resolved import entrypoint", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(fixtureProviderPath)))
yield* addPlugin(npmEntrypoint(fixtureProviderPath))
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("npm-provider", "test-model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" },
}),
package: "fixture-provider",
options: { name: "npm-provider", marker: "npm" },
},
@ -126,9 +135,14 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* plugin.add(dynamicPlugin(npmEntrypointLayer()))
yield* addPlugin(npmEntrypoint())
const exit = yield* aisdk
.language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } }))
.language(
new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
}),
)
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
@ -139,10 +153,13 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* plugin.add(dynamicPlugin())
yield* addPlugin()
const exit = yield* aisdk
.language(
model("bad-import", "alias", { api: { type: "aisdk", package: "file:///missing/provider-factory.js" } }),
new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" },
}),
)
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
@ -155,9 +172,14 @@ describe("DynamicProviderPlugin", () => {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(tmp.entrypoint)))
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
const exit = yield* aisdk
.language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } }))
.language(
new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
}),
)
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
@ -168,9 +190,10 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* plugin.add(dynamicPlugin())
yield* addPlugin()
const language = yield* aisdk.language(
model("custom", "alias", {
new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider },
}),
)

View File

@ -1,11 +1,22 @@
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
import { addPlugin, it, model } from "./provider-helper"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const gatewayCalls: Record<string, unknown>[] = []
const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"]
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: GatewayPlugin.id, effect: GatewayPlugin.effect(host) })
})
mock.module("@ai-sdk/gateway", () => ({
createGateway(options: Record<string, unknown>) {
@ -27,10 +38,17 @@ describe("GatewayPlugin", () => {
Effect.gen(function* () {
gatewayCalls.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GatewayPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/gateway",
options: { name: "gateway" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -42,12 +60,22 @@ describe("GatewayPlugin", () => {
Effect.gen(function* () {
gatewayCalls.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GatewayPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("vercel", "anthropic/claude-sonnet-4"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("anthropic/claude-sonnet-4"),
),
api: {
id: ModelV2.ID.make("anthropic/claude-sonnet-4"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/gateway",
options: { name: "vercel", apiKey: "test-key" },
},
@ -63,19 +91,33 @@ describe("GatewayPlugin", () => {
Effect.gen(function* () {
gatewayCalls.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GatewayPlugin)
yield* addPlugin()
for (const modelID of vercelGatewayModels) {
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("vercel", modelID), package: "@ai-sdk/vercel", options: { name: "vercel" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/vercel",
options: { name: "vercel" },
},
{},
)
expect(ignored.sdk).toBeUndefined()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("vercel", modelID), package: "@ai-sdk/gateway", options: { name: "vercel" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/gateway",
options: { name: "vercel" },
},
{},
)
expect(result.sdk).toBeDefined()

View File

@ -3,19 +3,51 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, fakeSelectorSdk, it, model, required } from "./provider-helper"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: GithubCopilotPlugin.id, effect: GithubCopilotPlugin.effect(host) })
})
function required<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", () => {
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* addPlugin()
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("github-copilot", "gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "github-copilot" },
},
@ -24,7 +56,10 @@ describe("GithubCopilotPlugin", () => {
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("github-copilot", "gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/github-copilot",
options: { name: "github-copilot" },
},
@ -39,11 +74,14 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("github-copilot", "claude-sonnet-4"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")),
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
@ -57,11 +95,14 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("github-copilot", "alias", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
@ -75,30 +116,68 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{ model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{ model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")),
api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{ model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")),
api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{ model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")),
api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{ model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("github-copilot"),
ModelV2.ID.make("gpt-5-mini-2025-08-07"),
),
api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual([
@ -115,11 +194,14 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("github-copilot", "default", { api: { id: ModelV2.ID.make("gpt-5") } }),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
@ -128,7 +210,10 @@ describe("GithubCopilotPlugin", () => {
yield* plugin.trigger(
"aisdk.language",
{
model: model("github-copilot", "small", { api: { id: ModelV2.ID.make("gpt-5-mini") } }),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")),
api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
@ -137,7 +222,10 @@ describe("GithubCopilotPlugin", () => {
yield* plugin.trigger(
"aisdk.language",
{
model: model("github-copilot", "sonnet", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")),
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
@ -149,13 +237,12 @@ describe("GithubCopilotPlugin", () => {
it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
.enabled,
@ -165,13 +252,12 @@ describe("GithubCopilotPlugin", () => {
it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
.enabled,
@ -183,10 +269,17 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual([])

View File

@ -1,12 +1,43 @@
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, it, model, required, withEnv } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const gitlabSDKOptions: Record<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", () => ({
VERSION: "test-version",
@ -32,10 +63,17 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GitLabPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.sdk",
{ model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
}),
package: "gitlab-ai-provider",
options: { name: "gitlab" },
},
{},
)
expect(gitlabSDKOptions).toHaveLength(1)
@ -65,10 +103,17 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GitLabPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.sdk",
{ model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
}),
package: "gitlab-ai-provider",
options: { name: "gitlab" },
},
{},
)
expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example")
@ -86,11 +131,14 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GitLabPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("gitlab", "claude"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
}),
package: "gitlab-ai-provider",
options: {
name: "gitlab",
@ -127,10 +175,17 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GitLabPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai",
options: { name: "gitlab" },
},
{},
)
expect(result.sdk).toBeUndefined()
@ -142,11 +197,13 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = []
yield* addPlugin(plugin, GitLabPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("gitlab", "duo-workflow-custom", {
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" },
request: {
headers: {},
body: { workflowRef: "ref", workflowDefinition: "definition" },
@ -178,11 +235,14 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = []
yield* addPlugin(plugin, GitLabPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("gitlab", "duo-workflow-exact"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")),
api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" },
}),
sdk: {
workflowChat: (id: string, options: unknown) => {
calls.push([id, options])
@ -205,11 +265,13 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = []
yield* addPlugin(plugin, GitLabPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("gitlab", "duo-workflow-custom", {
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" },
request: {
headers: {},
body: { featureFlags: { request_flag: true } },
@ -234,11 +296,13 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = []
yield* addPlugin(plugin, GitLabPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("gitlab", "claude", {
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
request: { headers: { h: "v" }, body: {} },
}),
sdk: {

View File

@ -1,10 +1,50 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: definition.id, effect: definition.effect(host) })
})
function withEnv<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", () => {
it.effect("resolves legacy project and location env on provider update", () =>
@ -19,17 +59,19 @@ describe("GoogleVertexAnthropicPlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
}),
)
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))
expect(provider.request.body.project).toBe("cloud-project")
expect(provider.request.body.location).toBe("cloud-location")
yield* addPlugin(GoogleVertexAnthropicPlugin)
expect(
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project,
).toBe("cloud-project")
expect(
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location,
).toBe("cloud-location")
}),
),
)
@ -37,9 +79,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("keeps configured project and location over env fallback", () =>
withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
@ -47,9 +87,13 @@ describe("GoogleVertexAnthropicPlugin", () => {
provider.request.body.location = "configured-location"
}),
)
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))
expect(provider.request.body.project).toBe("configured-project")
expect(provider.request.body.location).toBe("configured-location")
yield* addPlugin(GoogleVertexAnthropicPlugin)
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe(
"configured-project",
)
expect(
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location,
).toBe("configured-location")
}),
),
)
@ -67,11 +111,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex-anthropic", "claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("google-vertex-anthropic"),
ModelV2.ID.make("claude-sonnet-4-5"),
),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex-anthropic" },
},
@ -90,11 +140,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex-anthropic", "claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("google-vertex-anthropic"),
ModelV2.ID.make("claude-sonnet-4-5"),
),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex-anthropic" },
},
@ -110,11 +166,14 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", "claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "eu" },
},
@ -129,11 +188,14 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("keeps configured baseURL for google-vertex Anthropic models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", "claude-sonnet-4-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" },
},
@ -146,12 +208,15 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("selects google-vertex Anthropic language models through V2 plugins", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* addPlugin(GoogleVertexPlugin)
yield* addPlugin(GoogleVertexAnthropicPlugin)
const sdkResult = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", " claude-sonnet-4-5 "),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "us" },
},
@ -160,7 +225,10 @@ describe("GoogleVertexAnthropicPlugin", () => {
const languageResult = yield* plugin.trigger(
"aisdk.language",
{
model: model("google-vertex", " claude-sonnet-4-5 "),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
}),
sdk: sdkResult.sdk,
options: {},
},
@ -178,12 +246,18 @@ describe("GoogleVertexAnthropicPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* addPlugin(GoogleVertexAnthropicPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("google-vertex-anthropic", " claude-sonnet-4-5 "),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("google-vertex-anthropic"),
ModelV2.ID.make(" claude-sonnet-4-5 "),
),
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: selector(calls) },
options: {},
},
{},
@ -196,12 +270,15 @@ describe("GoogleVertexAnthropicPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("google-vertex", "claude-sonnet-4-5"),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: selector(calls) },
options: {},
},
{},

View File

@ -1,13 +1,63 @@
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const vertexOptions: Record<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", () => ({
createVertex: (options: Record<string, any>) => {
@ -37,9 +87,7 @@ void mock.module("google-auth-library", () => ({
describe("GoogleVertexPlugin", () => {
it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
provider.api = {
@ -49,6 +97,7 @@ describe("GoogleVertexPlugin", () => {
}
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode))
expect(provider.request.body).toEqual({})
@ -67,9 +116,7 @@ describe("GoogleVertexPlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = {
@ -79,6 +126,7 @@ describe("GoogleVertexPlugin", () => {
}
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
expect(provider.request.body.project).toBe("google-cloud-project")
expect(provider.request.body.location).toBe("google-vertex-location")
@ -107,7 +155,6 @@ describe("GoogleVertexPlugin", () => {
vertexOptions.length = 0
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = {
@ -117,12 +164,18 @@ describe("GoogleVertexPlugin", () => {
}
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", "gemini", {
api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
api: {
id: ModelV2.ID.make("gemini"),
type: "aisdk",
package: "@ai-sdk/google-vertex",
},
}),
package: "@ai-sdk/google-vertex",
options: { name: "google-vertex" },
@ -154,9 +207,7 @@ describe("GoogleVertexPlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = {
@ -168,6 +219,7 @@ describe("GoogleVertexPlugin", () => {
provider.request.body.location = "global"
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
expect(provider.request.body.project).toBe("config-project")
expect(provider.request.body.location).toBe("global")
@ -182,9 +234,7 @@ describe("GoogleVertexPlugin", () => {
it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = {
@ -196,6 +246,7 @@ describe("GoogleVertexPlugin", () => {
provider.request.body.location = "eu"
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
expect(provider.api).toEqual({
type: "aisdk",
@ -217,15 +268,14 @@ describe("GoogleVertexPlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" }
provider.request.body.project = "config-project"
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
expect(provider.request.body.project).toBe("config-project")
expect(provider.request.body.location).toBe("us-central1")
@ -243,12 +293,17 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () {
vertexOptions.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", "gemini", {
api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
api: {
id: ModelV2.ID.make("gemini"),
type: "aisdk",
package: "@ai-sdk/google-vertex",
},
}),
package: "@ai-sdk/google-vertex",
options: { name: "google-vertex" },
@ -268,21 +323,17 @@ describe("GoogleVertexPlugin", () => {
googleAuthOptions.length = 0
const fetchCalls: { input: Parameters<typeof fetch>[0]; init?: RequestInit }[] = []
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* plugin.add({
id: PluginV2.ID.make("capture-openai-compatible"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.promise(async () => {
if (evt.model.providerID !== "google-vertex") return
if (evt.package !== "@ai-sdk/openai-compatible") return
expect(typeof evt.options.fetch).toBe("function")
await evt.options.fetch("https://vertex.example", {
headers: { "x-test": "1" },
})
}),
yield* addPlugin()
yield* plugin.hook("aisdk.sdk", (evt) =>
Effect.promise(async () => {
if (evt.model.providerID !== "google-vertex") return
if (evt.package !== "@ai-sdk/openai-compatible") return
expect(typeof evt.options.fetch).toBe("function")
await evt.options.fetch("https://vertex.example", {
headers: { "x-test": "1" },
})
}),
})
)
const originalFetch = fetch
;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async (
input: Parameters<typeof fetch>[0],
@ -297,8 +348,13 @@ describe("GoogleVertexPlugin", () => {
plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", "gemini", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
api: {
id: ModelV2.ID.make("gemini"),
type: "aisdk",
package: "@ai-sdk/openai-compatible",
},
}),
package: "@ai-sdk/openai-compatible",
options: { name: "google-vertex" },
@ -322,11 +378,14 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* addPlugin()
yield* plugin.trigger(
"aisdk.language",
{
model: model("google-vertex", " gemini-2.5-pro "),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")),
api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},

View File

@ -1,26 +1,33 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { EventV2 } from "@opencode-ai/core/event"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { addPlugin, it, model } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const itWithAISDK = testEffect(
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
)
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: GooglePlugin.id, effect: GooglePlugin.effect(host) })
})
describe("GooglePlugin", () => {
it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GooglePlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-google", "gemini"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")),
api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" },
}),
package: "@ai-sdk/google",
options: { name: "custom-google", apiKey: "test" },
},
@ -34,34 +41,49 @@ describe("GooglePlugin", () => {
it.effect("ignores non-Google SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GooglePlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")),
api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" },
}),
package: "@ai-sdk/google-vertex",
options: { name: "google" },
},
{},
)
expect(result.sdk).toBeUndefined()
}),
)
itWithAISDK.effect("uses default languageModel loading with provider ID parity", () =>
it.effect("uses default languageModel loading with provider ID parity", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin(plugin, GooglePlugin)
const language = yield* aisdk.language(
model("custom-google", "alias", {
api: {
id: ModelV2.ID.make("gemini-api"),
type: "aisdk",
package: "@ai-sdk/google",
},
request: {
headers: {},
body: { apiKey: "test" },
},
}),
yield* addPlugin()
const sdkEvent = yield* plugin.trigger(
"aisdk.sdk",
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" },
}),
package: "@ai-sdk/google",
options: { name: "custom-google", apiKey: "test" },
},
{},
)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: sdkEvent.model,
sdk: sdkEvent.sdk,
options: sdkEvent.options,
},
{},
)
const language = result.language ?? result.sdk.languageModel(result.model.api.id)
expect(language.modelId).toBe("gemini-api")
expect(language.provider).toBe("custom-google")
}),

View File

@ -1,26 +1,37 @@
import { describe, expect } from "bun:test"
import { createGroq } from "@ai-sdk/groq"
import { Effect, Layer } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { EventV2 } from "@opencode-ai/core/event"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
import { addPlugin, it, model } from "./provider-helper"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const aisdkIt = testEffect(
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
)
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: GroqPlugin.id, effect: GroqPlugin.effect(host) })
})
describe("GroqPlugin", () => {
it.effect("creates a Groq SDK for @ai-sdk/groq", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GroqPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
}),
package: "@ai-sdk/groq",
options: { name: "groq" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -30,10 +41,17 @@ describe("GroqPlugin", () => {
it.effect("ignores non-Groq SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GroqPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "groq" },
},
{},
)
expect(result.sdk).toBeUndefined()
@ -43,10 +61,17 @@ describe("GroqPlugin", () => {
it.effect("only matches the bundled @ai-sdk/groq package exactly", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GroqPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
}),
package: "@ai-sdk/groq/compat",
options: { name: "groq" },
},
{},
)
expect(result.sdk).toBeUndefined()
@ -56,11 +81,14 @@ describe("GroqPlugin", () => {
it.effect("matches the old bundled Groq SDK provider naming", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, GroqPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-groq", "llama"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")),
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
}),
package: "@ai-sdk/groq",
options: { name: "custom-groq", apiKey: "test" },
},
@ -75,26 +103,32 @@ describe("GroqPlugin", () => {
}),
)
aisdkIt.effect("uses the default languageModel(api.id) behavior", () =>
it.effect("uses the default languageModel(api.id) behavior", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin(plugin, GroqPlugin)
const result = yield* aisdk.language(
model("groq", "alias", {
api: {
id: ModelV2.ID.make("llama-api"),
type: "aisdk",
package: "@ai-sdk/groq",
},
request: {
headers: {},
body: { apiKey: "test" },
},
}),
yield* addPlugin()
const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters<typeof createGroq>[0] & {
name: string
})
const result = yield* plugin.trigger(
"aisdk.language",
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")),
api: {
id: ModelV2.ID.make("llama-api"),
type: "aisdk",
package: "@ai-sdk/groq",
},
}),
sdk,
options: { name: "groq", apiKey: "test" },
},
{},
)
expect(result.modelId).toBe("llama-api")
expect(result.provider).toBe("groq.chat")
const language = result.language ?? sdk.languageModel(result.model.api.id)
expect(language.modelId).toBe("llama-api")
expect(language.provider).toBe("groq.chat")
}),
)
})

View File

@ -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))
}

View File

@ -2,96 +2,98 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: KiloPlugin.id, effect: KiloPlugin.effect(host) })
})
describe("KiloPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"kilo",
),
),
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))),
)
it.effect("applies legacy referer headers only to kilo", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, KiloPlugin)
yield* catalog.transform((catalog) => {
const kilo = provider("kilo", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
request: { headers: { Existing: "value" }, body: {} },
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.kilo.ai/api/gateway",
}
provider.request = { headers: { Existing: "value" }, body: {} }
})
catalog.provider.update(kilo.id, (draft) => {
draft.api = kilo.api
draft.request = kilo.request
})
catalog.provider.update(provider("openrouter").id, () => {})
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
}),
)
it.effect("uses the exact legacy Kilo header casing and set", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, KiloPlugin)
yield* catalog.transform((catalog) => {
const item = provider("kilo", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.kilo.ai/api/gateway",
}
})
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("kilo")))
expect(result.request.headers).toEqual({
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect(result.request.headers).not.toHaveProperty("http-referer")
expect(result.request.headers).not.toHaveProperty("x-title")
expect(result.request.headers).not.toHaveProperty("X-Source")
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty(
"http-referer",
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("x-title")
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("X-Source")
}),
)
it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, KiloPlugin)
yield* catalog.transform((catalog) => {
const kilo = provider("kilo", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.kilo.ai/api/gateway",
}
})
catalog.provider.update(kilo.id, (draft) => {
draft.api = kilo.api
})
const custom = provider("custom-kilo", {
api: { type: "aisdk", package: "kilo" },
})
catalog.provider.update(custom.id, (draft) => {
draft.api = custom.api
catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => {
provider.api = { type: "aisdk", package: "kilo" }
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({})
expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.request.headers).toEqual({})
}),
)
})

View File

@ -3,36 +3,28 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider, required } from "./provider-helper"
import { catalogHost, host, integrationHost } from "./host"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: LLMGatewayPlugin.id, effect: LLMGatewayPlugin.effect(host) })
})
describe("LLMGatewayPlugin", () => {
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
yield* plugin.add({
...LLMGatewayPlugin,
effect: LLMGatewayPlugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
),
})
})
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"llmgateway",
),
),
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))),
)
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* integrations.transform((editor) => {
@ -40,43 +32,48 @@ describe("LLMGatewayPlugin", () => {
editor.update(Integration.ID.make("openrouter"), () => {})
})
yield* catalog.transform((catalog) => {
const llmgateway = provider("llmgateway", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
request: { headers: { Existing: "value" }, body: {} },
})
catalog.provider.update(llmgateway.id, (draft) => {
draft.api = llmgateway.api
draft.request = llmgateway.request
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.llmgateway.io/v1",
}
provider.request = { headers: { Existing: "value" }, body: {} }
})
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
})
yield* add(plugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-Source": "opencode",
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
}),
)
it.effect("does not apply legacy headers to a disabled llmgateway provider", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* add(plugin)
const integrations = yield* Integration.Service
yield* integrations.transform((editor) => {
editor.update(Integration.ID.make("llmgateway"), () => {})
})
yield* catalog.transform((catalog) => {
const item = provider("llmgateway", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
provider.disabled = true
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.llmgateway.io/v1",
}
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true)
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({})
}),
)
})

View File

@ -1,18 +1,37 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: MistralPlugin.id, effect: MistralPlugin.effect(host) })
})
describe("MistralPlugin", () => {
it.effect("creates a Mistral SDK for @ai-sdk/mistral", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, MistralPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/mistral",
options: { name: "mistral" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -22,11 +41,14 @@ describe("MistralPlugin", () => {
it.effect("ignores non-Mistral SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, MistralPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("mistral", "mistral-large"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "mistral" },
},
@ -40,19 +62,22 @@ describe("MistralPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* addPlugin(plugin, MistralPlugin)
yield* plugin.add({
id: PluginV2.ID.make("mistral-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("mistral-large").provider)
}),
yield* addPlugin()
yield* plugin.hook("aisdk.sdk", (event) =>
Effect.sync(() => {
providers.push(event.sdk.languageModel("mistral-large").provider)
}),
})
)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/mistral",
options: { name: "mistral" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -64,20 +89,19 @@ describe("MistralPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* addPlugin(plugin, MistralPlugin)
yield* plugin.add({
id: PluginV2.ID.make("mistral-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("mistral-large").provider)
}),
yield* addPlugin()
yield* plugin.hook("aisdk.sdk", (event) =>
Effect.sync(() => {
providers.push(event.sdk.languageModel("mistral-large").provider)
}),
})
)
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-mistral", "mistral-large"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")),
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/mistral",
options: { name: "custom-mistral" },
},
@ -91,11 +115,23 @@ describe("MistralPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
const sdk = fakeSelectorSdk(calls)
yield* addPlugin(plugin, MistralPlugin)
const sdk = {
languageModel: (id: string) => {
calls.push(`languageModel:${id}`)
return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3
},
}
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
}),
sdk,
options: {},
},
{},
)
const language = result.language ?? sdk.languageModel(result.model.api.id)

View File

@ -2,64 +2,66 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: NvidiaPlugin.id, effect: NvidiaPlugin.effect(host) })
})
describe("NvidiaPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"nvidia",
),
),
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))),
)
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, NvidiaPlugin)
yield* catalog.transform((catalog) => {
const nvidia = provider("nvidia", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
request: { headers: { Existing: "value" }, body: {} },
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://integrate.api.nvidia.com/v1",
}
provider.request = { headers: { Existing: "value" }, body: {} }
})
catalog.provider.update(nvidia.id, (draft) => {
draft.api = nvidia.api
draft.request = nvidia.request
})
catalog.provider.update(provider("openrouter").id, () => {})
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
}),
)
it.effect("adds billing origin for custom NVIDIA endpoints", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, NvidiaPlugin)
yield* catalog.transform((catalog) => {
const item = provider("nvidia", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
request: { headers: {}, body: {} },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
draft.request = item.request
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://integrate.api.nvidia.com/v1",
}
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
@ -69,24 +71,23 @@ describe("NvidiaPlugin", () => {
it.effect("preserves an explicit NVIDIA billing origin header", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, NvidiaPlugin)
yield* catalog.transform((catalog) => {
const item = provider("nvidia", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
request: {
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://integrate.api.nvidia.com/v1",
}
provider.request = {
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
body: { baseURL: "https://integrate.api.nvidia.com/v1" },
},
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
draft.request = item.request
}
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",

View File

@ -1,23 +1,45 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible"
import { addPlugin, it, model } from "./provider-helper"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: OpenAICompatiblePlugin.id, effect: OpenAICompatiblePlugin.effect(host) })
})
describe("OpenAICompatiblePlugin", () => {
it.effect("preserves explicit includeUsage false and defaults it to true", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, OpenAICompatiblePlugin)
yield* addPlugin()
const defaulted = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "custom" },
},
{},
)
const disabled = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom", "model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "custom", includeUsage: false },
},
@ -31,11 +53,14 @@ describe("OpenAICompatiblePlugin", () => {
it.effect("defaults includeUsage for OpenAI-compatible package matches", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, OpenAICompatiblePlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom", "model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "file:///tmp/@ai-sdk/openai-compatible-provider.js",
options: { name: "custom" },
},
@ -49,20 +74,19 @@ describe("OpenAICompatiblePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const observed: string[] = []
yield* addPlugin(plugin, OpenAICompatiblePlugin)
yield* plugin.add({
id: PluginV2.ID.make("inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
observed.push(evt.sdk.languageModel("model").provider)
}),
yield* addPlugin()
yield* plugin.hook("aisdk.sdk", (event) =>
Effect.sync(() => {
observed.push(event.sdk.languageModel("model").provider)
}),
})
)
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-provider", "model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "custom-provider", baseURL: "https://example.com/v1" },
},
@ -85,11 +109,14 @@ describe("OpenAICompatiblePlugin", () => {
}),
}),
})
yield* addPlugin(plugin, OpenAICompatiblePlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai" },
},

View File

@ -1,28 +1,50 @@
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider, required } from "./provider-helper"
import { host, integrationHost } from "./host"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
function add(plugin: PluginV2.Interface, integrations: Integration.Interface) {
return plugin.add({
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
const integrations = yield* Integration.Service
yield* plugin.add({
id: OpenAIPlugin.id,
effect: OpenAIPlugin.effect(host({ integration: integrationHost(integrations) })).pipe(
Effect.provideService(Integration.Service, integrations),
),
effect: OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)),
})
})
function required<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", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* add(plugin, yield* Integration.Service)
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
{
id: Integration.MethodID.make("chatgpt-browser"),
@ -41,11 +63,14 @@ describe("OpenAIPlugin", () => {
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* add(plugin, yield* Integration.Service)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-openai", "gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai",
options: { name: "custom-openai", apiKey: "test" },
},
@ -58,10 +83,17 @@ describe("OpenAIPlugin", () => {
it.effect("ignores non-OpenAI SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* add(plugin, yield* Integration.Service)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "openai" },
},
{},
)
expect(result.sdk).toBeUndefined()
@ -72,11 +104,12 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* add(plugin, yield* Integration.Service)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("openai", "alias", {
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
@ -93,10 +126,17 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* add(plugin, yield* Integration.Service)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual([])
@ -106,17 +146,19 @@ describe("OpenAIPlugin", () => {
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* add(plugin, yield* Integration.Service)
yield* catalog.transform((catalog) => {
const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } })
const item = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
api: { type: "aisdk", package: "@ai-sdk/openai" },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true)
expect(
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
@ -126,14 +168,18 @@ describe("OpenAIPlugin", () => {
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* add(plugin, yield* Integration.Service)
yield* catalog.transform((catalog) => {
const item = provider("custom-openai")
catalog.provider.update(item.id, () => {})
const item = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")),
api: { type: "aisdk", package: "test-provider" },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest")))
.enabled,

View File

@ -1,45 +1,72 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { it, model, provider, required, withEnv } from "./provider-helper"
import { catalogHost, host, integrationHost } from "./host"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: OpencodePlugin.id, effect: OpencodePlugin.effect(host) })
})
function required<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 locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const pluginWithIntegrations = (catalog: Catalog.Interface, integrations: Integration.Interface) => ({
...OpencodePlugin,
effect: OpencodePlugin.effect(host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) })),
})
describe("OpencodePlugin", () => {
it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
const provider = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
api: { type: "aisdk", package: "test-provider" },
})
const model = new ModelV2.Info({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
}),
@ -49,17 +76,23 @@ describe("OpencodePlugin", () => {
it.effect("keeps free models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const free = model("opencode", "free", { cost: cost(0) })
catalog.model.update(item.id, free.id, (draft) => {
draft.cost = [...free.cost]
const provider = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
api: { type: "aisdk", package: "test-provider" },
})
const model = new ModelV2.Info({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" },
cost: cost(0),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
}),
@ -69,17 +102,23 @@ describe("OpencodePlugin", () => {
it.effect("treats output-only cost as free without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
catalog.model.update(item.id, outputOnly.id, (draft) => {
draft.cost = [...outputOnly.cost]
const provider = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
api: { type: "aisdk", package: "test-provider" },
})
const model = new ModelV2.Info({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" },
cost: cost(0, 1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
true,
@ -91,17 +130,23 @@ describe("OpencodePlugin", () => {
it.effect("uses OPENCODE_API_KEY as credentials", () =>
withEnv({ OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
const provider = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
api: { type: "aisdk", package: "test-provider" },
})
const model = new ModelV2.Info({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}),
@ -111,10 +156,8 @@ describe("OpencodePlugin", () => {
it.effect("uses configured provider env vars as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* plugin.add(pluginWithIntegrations(catalog, integrations))
yield* integrations.transform((editor) => {
editor.method.update({
integrationID: Integration.ID.make("opencode"),
@ -122,13 +165,21 @@ describe("OpencodePlugin", () => {
})
})
yield* catalog.transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
const provider = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
api: { type: "aisdk", package: "test-provider" },
})
const model = new ModelV2.Info({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}),
@ -138,24 +189,29 @@ describe("OpencodePlugin", () => {
it.effect("uses configured apiKey as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => {
const item = provider("opencode", {
const provider = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
api: { type: "aisdk", package: "test-provider" },
request: {
headers: {},
body: { apiKey: "configured" },
},
})
catalog.provider.update(item.id, (draft) => {
draft.request = item.request
const model = new ModelV2.Info({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
cost: cost(1),
})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
catalog.provider.update(provider.id, (draft) => {
draft.request = provider.request
})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}),
@ -165,17 +221,23 @@ describe("OpencodePlugin", () => {
it.effect("ignores non-opencode providers and models", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => {
const item = provider("openai")
catalog.provider.update(item.id, () => {})
const paid = model("openai", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
const provider = new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
api: { type: "aisdk", package: "test-provider" },
})
const model = new ModelV2.Info({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
}),
@ -206,8 +268,6 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID)
expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe(
Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))),
),
}),
)
})

View File

@ -3,56 +3,59 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, expectPluginRegistered, it, model, provider, required } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: OpenRouterPlugin.id, effect: OpenRouterPlugin.effect(host) })
})
describe("OpenRouterPlugin", () => {
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"openrouter",
),
),
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))),
)
it.effect("applies legacy referer headers only to openrouter", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, OpenRouterPlugin)
yield* catalog.transform((catalog) => {
const openrouter = provider("openrouter", {
api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
request: { headers: { Existing: "value" }, body: {} },
})
catalog.provider.update(openrouter.id, (item) => {
item.api = openrouter.api
item.request = openrouter.request
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
provider.request = { headers: { Existing: "value" }, body: {} }
})
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({})
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({})
}),
)
it.effect("creates an SDK only for the OpenRouter package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, OpenRouterPlugin)
yield* addPlugin()
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("openrouter", "openai/gpt-5"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "openrouter" },
},
@ -62,7 +65,14 @@ describe("OpenRouterPlugin", () => {
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@openrouter/ai-sdk-provider",
options: { name: "custom" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -71,52 +81,37 @@ describe("OpenRouterPlugin", () => {
it.effect("filters OpenRouter's gpt-5 chat alias", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, OpenRouterPlugin)
yield* catalog.transform((catalog) => {
const openrouter = provider("openrouter", {
api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
})
catalog.provider.update(openrouter.id, (item) => {
item.api = openrouter.api
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
})
catalog.provider.update(ProviderV2.ID.openai, () => {})
for (const item of [
model("openrouter", "openai/gpt-5-chat"),
model("openrouter", "openai/gpt-5"),
model("openai", "openai/gpt-5-chat"),
]) {
catalog.model.update(item.providerID, item.id, () => {})
}
catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {})
catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5"), () => {})
catalog.model.update(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"), () => {})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat")))
.enabled,
).toBe(false)
expect(
required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled,
).toBe(true)
expect(
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled,
).toBe(true)
expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(
false,
)
expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")))?.enabled).toBe(true)
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(true)
}),
)
it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, OpenRouterPlugin)
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(
required(
yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")),
).enabled,
(yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")))
?.enabled,
).toBe(true)
}),
)

View File

@ -1,18 +1,50 @@
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: PerplexityPlugin.id, effect: PerplexityPlugin.effect(host) })
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("PerplexityPlugin", () => {
it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, PerplexityPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/perplexity",
options: { name: "perplexity" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -22,11 +54,14 @@ describe("PerplexityPlugin", () => {
it.effect("ignores packages that are not the bundled Perplexity package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, PerplexityPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("perplexity", "sonar"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/perplexity-compatible",
options: { name: "perplexity" },
},
@ -39,50 +74,40 @@ describe("PerplexityPlugin", () => {
it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* addPlugin(plugin, PerplexityPlugin)
yield* plugin.add({
id: PluginV2.ID.make("perplexity-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("sonar").provider)
}),
}),
})
yield* plugin.trigger(
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/perplexity",
options: { name: "perplexity" },
},
{},
)
expect(providers).toEqual(["perplexity"])
expect(result.sdk.languageModel("sonar").provider).toBe("perplexity")
}),
)
it.effect("creates bundled Perplexity SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* addPlugin(plugin, PerplexityPlugin)
yield* plugin.add({
id: PluginV2.ID.make("custom-perplexity-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("sonar").provider)
}),
}),
})
yield* plugin.trigger(
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-perplexity", "sonar"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")),
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/perplexity",
options: { name: "custom-perplexity" },
},
{},
)
expect(providers).toEqual(["perplexity"])
expect(result.sdk.languageModel("sonar").provider).toBe("perplexity")
}),
)
@ -90,11 +115,14 @@ describe("PerplexityPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, PerplexityPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("perplexity", "alias", { api: { id: ModelV2.ID.make("sonar") } }),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},

View File

@ -1,16 +1,54 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Npm } from "@opencode-ai/core/npm"
import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core"
import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper"
import { host } from "./host"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const pluginWithNpm = {
id: SapAICorePlugin.id,
effect: Effect.gen(function* () {
yield* SapAICorePlugin.effect(host({ npm: yield* Npm.Service }))
}).pipe(Effect.provide(npmLayer)),
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
install: () => Effect.void,
which: () => Effect.succeed(undefined),
})
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: SapAICorePlugin.id, effect: SapAICorePlugin.effect({ ...host, npm }) })
})
function withEnv<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", () => {
@ -20,11 +58,11 @@ describe("SapAICorePlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm)
yield* addPlugin()
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("sap-ai-core", "sap-model"),
model: model("sap-ai-core"),
package: fixtureProvider,
options: { name: "sap-ai-core", serviceKey: "service-key" },
},
@ -46,11 +84,11 @@ describe("SapAICorePlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm)
yield* addPlugin()
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("sap-ai-core", "sap-model"),
model: model("sap-ai-core"),
package: fixtureProvider,
options: { name: "sap-ai-core", serviceKey: "option-service-key" },
},
@ -68,10 +106,10 @@ describe("SapAICorePlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm)
yield* addPlugin()
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } },
{ model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core" } },
{},
)
expect(process.env.AICORE_SERVICE_KEY).toBeUndefined()
@ -83,7 +121,7 @@ describe("SapAICorePlugin", () => {
it.effect("uses the callable SDK for language selection", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm)
yield* addPlugin()
const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), {
languageModel() {
throw new Error("SAP AI Core should call the SDK directly")
@ -91,7 +129,7 @@ describe("SapAICorePlugin", () => {
})
const language = yield* plugin.trigger(
"aisdk.language",
{ model: model("sap-ai-core", "sap-model"), sdk, options: {} },
{ model: model("sap-ai-core"), sdk, options: {} },
{},
)
expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" })
@ -104,11 +142,11 @@ describe("SapAICorePlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm)
yield* addPlugin()
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("openai", "sap-model"),
model: model("openai"),
package: fixtureProvider,
options: { name: "openai", serviceKey: "service-key" },
},
@ -117,7 +155,7 @@ describe("SapAICorePlugin", () => {
const language = yield* plugin.trigger(
"aisdk.language",
{
model: model("openai", "sap-model"),
model: model("openai"),
sdk: () => {
throw new Error("SAP AI Core should ignore other providers")
},

View File

@ -1,18 +1,48 @@
import { describe, expect, it as bun_it } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { addPlugin, expectPluginRegistered, it, model, withEnv } from "./provider-helper"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: SnowflakeCortexPlugin.id, effect: SnowflakeCortexPlugin.effect(host) })
})
function withEnv<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", () => {
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => {
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"snowflake-cortex",
)
const ids = ProviderPlugins.map((p) => p.id as string)
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex"))
const ids = ProviderPlugins.map((p) => p.id)
expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible"))
}),
)
@ -20,10 +50,17 @@ describe("SnowflakeCortexPlugin", () => {
it.effect("ignores non-snowflake-cortex providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, SnowflakeCortexPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")),
api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai",
options: { name: "openai" },
},
{},
)
expect(result.sdk).toBeUndefined()
@ -34,11 +71,17 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, SnowflakeCortexPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("snowflake-cortex", "claude-sonnet-4-6"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("snowflake-cortex"),
ModelV2.ID.make("claude-sonnet-4-6"),
),
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
},
@ -53,11 +96,17 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, SnowflakeCortexPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("snowflake-cortex", "claude-sonnet-4-6"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("snowflake-cortex"),
ModelV2.ID.make("claude-sonnet-4-6"),
),
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: {
name: "snowflake-cortex",
@ -76,11 +125,17 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, SnowflakeCortexPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("snowflake-cortex", "claude-sonnet-4-6"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("snowflake-cortex"),
ModelV2.ID.make("claude-sonnet-4-6"),
),
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
},
@ -95,11 +150,17 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, SnowflakeCortexPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("snowflake-cortex", "claude-sonnet-4-6"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("snowflake-cortex"),
ModelV2.ID.make("claude-sonnet-4-6"),
),
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: {
name: "snowflake-cortex",
@ -118,27 +179,23 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const captured: Record<string, unknown>[] = []
yield* addPlugin(plugin, SnowflakeCortexPlugin)
yield* plugin.add({
id: PluginV2.ID.make("inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
captured.push({ ...evt.options })
}),
}),
})
yield* plugin.trigger(
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("snowflake-cortex", "claude-sonnet-4-6"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("snowflake-cortex"),
ModelV2.ID.make("claude-sonnet-4-6"),
),
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
},
{},
)
expect(captured[0]?.includeUsage).toBe(true)
expect(result.options.includeUsage).toBe(true)
}),
),
)

View File

@ -1,17 +1,50 @@
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai"
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: TogetherAIPlugin.id, effect: TogetherAIPlugin.effect(host) })
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("TogetherAIPlugin", () => {
it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, TogetherAIPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/togetherai",
options: { name: "togetherai" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -21,12 +54,15 @@ describe("TogetherAIPlugin", () => {
it.effect("matches the old bundled provider package exactly", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, TogetherAIPlugin)
yield* addPlugin()
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("togetherai", "model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "file:///tmp/@ai-sdk/togetherai-provider.js",
options: { name: "togetherai" },
},
@ -36,7 +72,14 @@ describe("TogetherAIPlugin", () => {
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/togetherai",
options: { name: "togetherai" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -46,29 +89,22 @@ describe("TogetherAIPlugin", () => {
it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const observed: string[] = []
yield* addPlugin(plugin, TogetherAIPlugin)
yield* plugin.add({
id: PluginV2.ID.make("inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
observed.push(evt.sdk.languageModel("model").provider)
}),
}),
})
yield* addPlugin()
yield* plugin.trigger(
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-togetherai", "model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/togetherai",
options: { name: "custom-togetherai" },
},
{},
)
expect(observed).toEqual(["togetherai.chat"])
expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat")
}),
)
@ -76,12 +112,22 @@ describe("TogetherAIPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, TogetherAIPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
model: new ModelV2.Info({
...ModelV2.Info.empty(
ProviderV2.ID.make("togetherai"),
ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
),
api: {
id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
type: "aisdk",
package: "test-provider",
},
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},

View File

@ -1,17 +1,50 @@
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: VenicePlugin.id, effect: VenicePlugin.effect(host) })
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("VenicePlugin", () => {
it.effect("creates a Venice SDK for venice-ai-sdk-provider", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, VenicePlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "venice-ai-sdk-provider",
options: { name: "venice" },
},
{},
)
expect(result.sdk).toBeDefined()
@ -21,39 +54,35 @@ describe("VenicePlugin", () => {
it.effect("uses the model provider ID as the bundled Venice SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const observed: string[] = []
yield* addPlugin(plugin, VenicePlugin)
yield* plugin.add({
id: PluginV2.ID.make("inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
observed.push(evt.sdk.languageModel("model").provider)
}),
}),
})
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-venice", "model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "venice-ai-sdk-provider",
options: { name: "custom-venice", apiKey: "test" },
},
{},
)
expect(result.sdk).toBeDefined()
expect(observed).toEqual(["custom-venice.chat"])
expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat")
}),
)
it.effect("only handles the bundled venice-ai-sdk-provider package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, VenicePlugin)
yield* addPlugin()
const similar = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("venice", "model"),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "file:///tmp/venice-ai-sdk-provider.js",
options: { name: "venice" },
},
@ -61,7 +90,14 @@ describe("VenicePlugin", () => {
)
const other = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("venice", "model"), package: "@ai-sdk/openai-compatible", options: { name: "venice" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "venice" },
},
{},
)
expect(similar.sdk).toBeUndefined()
@ -73,10 +109,17 @@ describe("VenicePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, VenicePlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual([])

View File

@ -1,28 +1,34 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, it, model, provider, required } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: VercelPlugin.id, effect: VercelPlugin.effect(host) })
})
describe("VercelPlugin", () => {
it.effect("applies legacy lower-case referer headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, VercelPlugin)
yield* catalog.transform((catalog) => {
const item = provider("vercel", {
api: { type: "aisdk", package: "@ai-sdk/vercel" },
request: { headers: { Existing: "1" }, body: {} },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
draft.request = item.request
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/vercel" }
provider.request.headers.Existing = "1"
})
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).toEqual({
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).toEqual({
Existing: "1",
"http-referer": "https://opencode.ai/",
"x-title": "opencode",
@ -32,19 +38,17 @@ describe("VercelPlugin", () => {
it.effect("does not add legacy upper-case referer headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, VercelPlugin)
yield* catalog.transform((catalog) => {
const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } })
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
})
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty(
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/vercel" }
}),
)
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty(
"HTTP-Referer",
)
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty(
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty(
"X-Title",
)
}),
@ -53,10 +57,17 @@ describe("VercelPlugin", () => {
it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, VercelPlugin)
yield* addPlugin()
const event = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")),
api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" },
}),
package: "@ai-sdk/vercel",
options: { name: "custom-vercel" },
},
{},
)
expect(event.sdk).toBeDefined()
@ -66,11 +77,10 @@ describe("VercelPlugin", () => {
it.effect("ignores non-Vercel providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, VercelPlugin)
yield* catalog.transform((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({})
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {}))
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({})
}),
)
})

View File

@ -1,37 +1,66 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { addPlugin, fakeSelectorSdk } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))
const it = testEffect(PluginTestLayer)
const model = new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
api: {
id: ModelV2.ID.make("grok-4"),
type: "aisdk",
package: "@ai-sdk/xai",
},
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: XAIPlugin.id, effect: XAIPlugin.effect(host) })
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("XAIPlugin", () => {
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, XAIPlugin)
yield* addPlugin()
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{ model, package: "@ai-sdk/openai-compatible", options: {} },
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
}),
package: "@ai-sdk/openai-compatible",
options: {},
},
{},
)
const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {})
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
}),
package: "@ai-sdk/xai",
options: {},
},
{},
)
expect(ignored.sdk).toBeUndefined()
expect(typeof result.sdk?.responses).toBe("function")
@ -41,32 +70,22 @@ describe("XAIPlugin", () => {
it.effect("creates xAI SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* addPlugin()
yield* addPlugin(plugin, XAIPlugin)
yield* plugin.add({
id: PluginV2.ID.make("xai-sdk-name-observer"),
effect: Effect.gen(function* () {
return {
"aisdk.sdk": Effect.fn(function* (evt) {
if (!evt.sdk) return
providers.push(evt.sdk.responses("grok-4").provider)
}),
}
}),
})
yield* plugin.trigger(
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")),
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
}),
package: "@ai-sdk/xai",
options: {},
},
{},
)
expect(providers).toEqual(["xai.responses"])
expect(result.sdk.responses("grok-4").provider).toBe("xai.responses")
}),
)
@ -75,11 +94,14 @@ describe("XAIPlugin", () => {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, XAIPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{
model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias") }),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
@ -96,11 +118,14 @@ describe("XAIPlugin", () => {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, XAIPlugin)
yield* addPlugin()
const result = yield* plugin.trigger(
"aisdk.language",
{
model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }),
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")),
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},

View File

@ -2,34 +2,44 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: ZenmuxPlugin.id, effect: ZenmuxPlugin.effect(host) })
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
describe("ZenmuxPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"zenmux",
),
),
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))),
)
it.effect("applies the exact legacy Zenmux headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => {
const item = provider("zenmux", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://zenmux.ai/api/v1",
}
})
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux")))
expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
@ -38,19 +48,18 @@ describe("ZenmuxPlugin", () => {
it.effect("merges legacy Zenmux headers with existing headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => {
const item = provider("zenmux", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
request: { headers: { Existing: "value" }, body: {} },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
draft.request = item.request
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://zenmux.ai/api/v1",
}
provider.request.headers.Existing = "value"
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
Existing: "value",
@ -62,22 +71,18 @@ describe("ZenmuxPlugin", () => {
it.effect("lets configured Zenmux legacy headers override defaults", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => {
const item = provider("zenmux", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
request: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {},
},
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
draft.request = item.request
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://zenmux.ai/api/v1",
}
provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
"HTTP-Referer": "https://example.com/",
@ -88,20 +93,13 @@ describe("ZenmuxPlugin", () => {
it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => {
const item = provider("openrouter", {
request: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {},
},
})
catalog.provider.update(item.id, (draft) => {
draft.request = item.request
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({
"HTTP-Referer": "https://example.com/",

View File

@ -0,0 +1 @@
process.env.OPENCODE_DB = ":memory:"

View File

@ -16,17 +16,16 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const databaseLayer = Database.layerFromPath(":memory:")
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
const copyLayer = ProjectCopy.layer.pipe(
Layer.provide(databaseLayer),
Layer.provide(directoriesLayer),
Layer.provide(eventLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectDirectories.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
)
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer))
const it = testEffect(
Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer),
)
function abs(input: string) {
return AbsolutePath.make(input)

View File

@ -8,10 +8,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
const it = testEffect(Layer.mergeAll(database, events, directories))
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer))
const projectID = Project.ID.make("project-directories")
const directory = AbsolutePath.make("/tmp/project-directories")

View File

@ -13,19 +13,8 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const databaseLayer = Database.layerFromPath(":memory:")
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
const it = testEffect(
Layer.mergeAll(
ProjectV2.layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(directoriesLayer),
Layer.provide(databaseLayer),
),
databaseLayer,
directoriesLayer,
),
Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer),
)
function remoteID(remote: string) {

View File

@ -6,10 +6,8 @@ import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const questions = QuestionV2.layer.pipe(Layer.provide(events))
const it = testEffect(Layer.mergeAll(database, events, questions))
const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, questions))
const sessionID = SessionV2.ID.make("ses_question_test")
const question: QuestionV2.Info = {

View File

@ -25,8 +25,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
@ -35,17 +33,23 @@ const projects = Layer.succeed(
commit: () => Effect.void,
}),
)
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(projects),
Layer.provide(SessionExecution.noopLayer),
)
const it = testEffect(
Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions),
Layer.mergeAll(
Database.defaultLayer,
EventV2.defaultLayer,
projects,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
SessionExecution.noopLayer,
sessions,
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const id = SessionV2.ID.create()

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 { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const it = testEffect(Layer.mergeAll(database, events, projector))
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer))
const sessionID = SessionV2.ID.make("ses_projector_test")
const created = DateTime.makeUnsafe(0)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
@ -113,10 +110,10 @@ describe("SessionProjector", () => {
}).pipe(
Effect.provide(
SessionV2.layer.pipe(
Layer.provide(events),
Layer.provide(database),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(SessionStore.layer.pipe(Layer.provide(database))),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
),
),

View File

@ -18,10 +18,6 @@ import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-
import { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = []
const interruptSeqs: Array<number | undefined> = []
@ -47,13 +43,22 @@ const execution = Layer.succeed(
}),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(execution),
)
const it = testEffect(Layer.mergeAll(database, events, projector, store, execution, sessions))
const it = testEffect(
Layer.mergeAll(
Database.defaultLayer,
EventV2.defaultLayer,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
execution,
sessions,
),
)
const sessionID = SessionV2.ID.make("ses_prompt_test")
const messageID = SessionMessage.ID.create()

View File

@ -268,7 +268,7 @@ describe("SessionRunnerModel", () => {
it.effect("prefers stored credentials over configured auth", () =>
Effect.gen(function* () {
const credential = new Credential.Stored({
const credential = new Credential.Info({
id: Credential.ID.create(),
integrationID: Integration.ID.make("test-provider"),
label: "Work",

View File

@ -32,10 +32,6 @@ import { Effect, Layer } from "effect"
import path from "node:path"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const cassette =
process.env.RECORD === "true"
? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", {
@ -74,9 +70,9 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(database),
Layer.provide(store),
Layer.provide(events),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(client),
Layer.provide(registry),
Layer.provide(models),
@ -101,18 +97,18 @@ const execution = Layer.effect(
),
).pipe(Layer.provide(coordinator))
const sessions = SessionV2.layer.pipe(
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(execution),
)
const it = testEffect(
Layer.mergeAll(
database,
events,
projector,
store,
Database.defaultLayer,
EventV2.defaultLayer,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
executor,
client,
permission,

View File

@ -56,11 +56,7 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream }
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const questions = QuestionV2.layer.pipe(Layer.provide(events))
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
const requests: LLMRequest[] = []
let response: LLMEvent[] = []
let responses: LLMEvent[][] | undefined
@ -235,9 +231,9 @@ const config = Layer.succeed(
}),
)
const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(database),
Layer.provide(store),
Layer.provide(events),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(client),
Layer.provide(registry),
Layer.provide(models),
@ -262,19 +258,19 @@ const execution = Layer.effect(
),
).pipe(Layer.provide(coordinator))
const sessions = SessionV2.layer.pipe(
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(execution),
)
const it = testEffect(
Layer.mergeAll(
database,
events,
Database.defaultLayer,
EventV2.defaultLayer,
questions,
projector,
store,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
client,
permission,
applications,

View File

@ -11,10 +11,7 @@ import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql"
import { SessionTodo } from "@opencode-ai/core/session/todo"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events))
const it = testEffect(Layer.mergeAll(database, events, todos))
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer))
const sessionID = SessionV2.ID.make("ses_todo_test")
const setup = Effect.gen(function* () {

View File

@ -16,10 +16,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const it = testEffect(Layer.mergeAll(database, events, projector))
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer))
const timestamp = DateTime.makeUnsafe(1)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }

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(source).toContain(
"Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.",
"absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.",
)
for (const todo of [
"Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.",

View File

@ -6,7 +6,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { testEffect } from "./lib/effect"
const it = testEffect(FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)))
const it = testEffect(Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer))
const fixture = Effect.gen(function* () {
const fs = yield* FSUtil.Service
const files = yield* FileSystem.FileSystem

View File

@ -1,4 +1,5 @@
import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer, PlatformError } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
@ -19,7 +20,7 @@ import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/to
const assertions: PermissionV2.AssertInput[] = []
const missingPath = "__missing_read_target__.txt"
const missingAbsolutePath = `${process.cwd()}/${missingPath}`
const missingAbsolutePath = path.join(process.cwd(), missingPath)
const readCalls: {
input: AbsolutePath
page: ReadToolFileSystem.PageInput
@ -164,7 +165,12 @@ describe("ReadTool", () => {
},
})
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/README.md`), page: {} }])
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "README.md")),
page: { offset: undefined, limit: undefined },
},
])
}),
)
@ -193,7 +199,12 @@ describe("ReadTool", () => {
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
],
})
expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/pixel.png`), page: {} }])
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")),
page: { offset: undefined, limit: undefined },
},
])
const settled = yield* settleTool(registry, {
sessionID,
@ -449,7 +460,7 @@ describe("ReadTool", () => {
}),
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
expect(readCalls).toEqual([
{ input: AbsolutePath.make(`${process.cwd()}/archive.dat`), page: { offset: 2, limit: 1 } },
{ input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } },
])
}),
)
@ -589,7 +600,7 @@ describe("ReadTool", () => {
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
})
expect(readCalls).toEqual([
{ input: AbsolutePath.make(`${process.cwd()}/large.txt`), page: { offset: 2, limit: 1 } },
{ input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } },
])
}),
)

View File

@ -32,12 +32,15 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos))
const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool))
const tool = TodoWriteTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(SessionTodo.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer, permission, registry, tool),
)
const setup = Effect.gen(function* () {
assertions.length = 0

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(source).toContain(
"Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.",
"absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.",
)
for (const todo of [
"Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.",

View File

@ -13,6 +13,10 @@
"outputs": [],
"passThroughEnv": ["*"]
},
"@opencode-ai/core#test": {
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/app#test": {
"dependsOn": ["^build"],
"outputs": []