Merge branch 'dev' into simplify/v2-runner-transitions

This commit is contained in:
Kit Langton 2026-06-22 15:34:59 +02:00 committed by GitHub
commit 14c03f70eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 11135 additions and 2034 deletions

View file

@ -65,7 +65,7 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}

View file

@ -27,9 +27,11 @@ export default Runtime.handler(
function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
// Preserve the familiar default when available, but let the OS choose a free
// port when another local server already owns 4096.
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
const next = (port: number): ReturnType<typeof bind> =>
bind(hostname, port, password).pipe(
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
)
return next(4096)
}
function bind(hostname: string, port: number, password: string) {

View file

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

View file

@ -45,15 +45,17 @@ async function generate() {
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
await Bun.write(
target,
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
await formatTypescript(
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
),
)
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
}
await fs.mkdir(full)
await drizzle(temporary, full, "schema")
await Bun.write(schema, renderSchema(await generatedSql(full)))
await Bun.write(registry, renderRegistry(await typescriptMigrations()))
await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full))))
await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations())))
} finally {
await fs.rm(temporary, { recursive: true, force: true })
}
@ -76,12 +78,12 @@ async function check() {
await fs.mkdir(full)
await drizzle(temporary, full, "schema")
if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) {
if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) {
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
}
const migrations = await typescriptMigrations()
if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) {
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
}
} finally {
@ -170,6 +172,18 @@ function escapeTemplate(line: string) {
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
}
async function formatTypescript(input: string) {
const prettier = await import("prettier")
const typescript = await import("prettier/plugins/typescript")
const estree = await import("prettier/plugins/estree")
return prettier.format(input, {
parser: "typescript",
plugins: [typescript.default, estree.default],
semi: false,
printWidth: 120,
})
}
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"

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

@ -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,27 @@ 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) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
const detected = entry.methods
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const credentials = saved
.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
.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 +385,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 +436,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

@ -103,8 +103,16 @@ export const layer = Layer.effect(
}
}[keyof Hooks][] = []
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const locks = KeyedMutex.makeUnsafe<ID>()
const scope = yield* Scope.make()
// One registry-owned scope lets shutdown remove every plugin transform in one batch.
yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () {
hooks = []
yield* State.batch(Scope.close(scope, exit))
}),
)
const svc = Service.of({
add: Effect.fn("Plugin.add")(function* (input) {
@ -112,7 +120,7 @@ export const layer = Layer.effect(
yield* locks.withLock(id)(
Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
const childScope = yield* Scope.fork(scope)
const result = yield* input.effect.pipe(
Scope.provide(childScope),
@ -181,7 +189,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id)
hooks = hooks.filter((item) => item.id !== id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
}),
)
}),

View file

@ -126,10 +126,7 @@ export interface Interface {
sessionID: SessionSchema.ID
after?: number
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
readonly switchAgent: (input: {
sessionID: SessionSchema.ID
agent: string
}) => Effect.Effect<void, OperationUnavailableError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: {
sessionID: SessionSchema.ID
model: ModelV2.Ref
@ -376,8 +373,14 @@ export const layer = Layer.effect(
skill: Effect.fn("V2Session.skill")(function* () {
return yield* new OperationUnavailableError({ operation: "skill" })
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
return yield* new OperationUnavailableError({ operation: "switchAgent" })
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
agent: input.agent,
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
yield* result.get(input.sessionID)

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,49 +1,35 @@
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* () {
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const created = yield* credentials.create({
integrationID,
label: "Work",
value: new Credential.Key({ type: "key", key: "secret" }),
})
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({
integrationID,
label: "Work",
value: new Credential.Key({ type: "key", key: "secret" }),
})
expect(yield* credentials.list(integrationID)).toEqual([created])
yield* credentials.update(created.id, { label: "Personal" })
expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal")
expect(yield* credentials.list(integrationID)).toEqual([created])
yield* credentials.update(created.id, { label: "Personal" })
expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal")
const replacement = yield* credentials.create({
integrationID,
label: "Replacement",
value: new Credential.Key({ type: "key", key: "replacement" }),
})
expect(yield* credentials.list(integrationID)).toEqual([replacement])
const replacement = yield* credentials.create({
integrationID,
label: "Replacement",
value: new Credential.Key({ type: "key", key: "replacement" }),
})
expect(yield* credentials.list(integrationID)).toEqual([replacement])
yield* credentials.remove(replacement.id)
expect(yield* credentials.list(integrationID)).toEqual([])
}).pipe(Effect.provide(layer(tmp.path))),
),
),
yield* credentials.remove(replacement.id)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
})

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,12 @@ 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 +26,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 +47,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 +95,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 +121,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 +171,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 +218,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 +250,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 +286,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 +302,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 +312,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

@ -46,6 +46,40 @@ describe("PluginV2", () => {
}),
)
it.effect("batches plugin state rebuilds when the registry layer finalizes", () =>
Effect.gen(function* () {
let finalized = 0
const values = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (value: string) => draft.values.push(value) }),
finalize: () => Effect.sync(() => finalized++),
})
const layerScope = yield* Scope.fork(yield* Scope.Scope)
const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service)
yield* State.batch(
Effect.forEach(
["first", "second"],
(id) =>
plugin.add({
id: PluginV2.ID.make(id),
effect: values
.transform((editor) => {
editor.add(id)
})
.pipe(Effect.asVoid),
}),
{ discard: true },
),
)
finalized = 0
yield* Scope.close(layerScope, Exit.void)
expect(values.get().values).toEqual([])
expect(finalized).toBe(1)
}),
)
it.effect("serializes same-ID additions and leaves one removable attachment", () =>
Effect.gen(function* () {
const values = state()

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,18 @@ 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 +201,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 +223,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 +245,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 +268,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 +300,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 +331,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 +365,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 +385,13 @@ 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 +405,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 +439,18 @@ 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 +477,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 +493,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 +505,14 @@ 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 +521,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 +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-southeast-2" },
},
@ -432,11 +557,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 +645,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 +668,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,40 @@ 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,14 @@ 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 +136,29 @@ 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 +171,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 +190,10 @@ 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 +202,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,14 @@ 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 +134,15 @@ 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 +181,15 @@ 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 +213,15 @@ 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 +253,15 @@ 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 +287,15 @@ 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 +312,15 @@ 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 +338,15 @@ 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 +370,15 @@ 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 +396,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 +434,15 @@ 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,19 @@ 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 +88,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,65 @@ 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 +191,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 +207,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 +219,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 +234,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 +249,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 +266,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,17 +121,13 @@ 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")
},
})
const language = yield* plugin.trigger(
"aisdk.language",
{ model: model("sap-ai-core", "sap-model"), sdk, options: {} },
{},
)
const language = yield* plugin.trigger("aisdk.language", { model: model("sap-ai-core"), sdk, options: {} }, {})
expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" })
}),
)
@ -104,11 +138,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 +151,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,14 @@ 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 +93,14 @@ 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 +119,14 @@ 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 +141,14 @@ 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 +167,20 @@ 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,31 +38,34 @@ 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(
"X-Title",
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty("X-Title")
}),
)
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 +75,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,20 +13,7 @@ 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,
),
)
const it = testEffect(Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer))
function remoteID(remote: string) {
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))

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()
@ -336,7 +340,34 @@ describe("SessionV2.create", () => {
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent")
}),
)
it.effect("switches the selected agent through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
yield* session.switchAgent({ sessionID: created.id, agent: "plan" })
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect(
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }])
}),
)
it.effect("rejects an agent switch for a missing Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const missing = SessionV2.ID.make("ses_missing_agent_switch")
expect(
yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe(
Effect.flip,
Effect.map((error) => error._tag),
),
).toBe("Session.NotFoundError")
}),
)

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

@ -1,4 +1,5 @@
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { FSUtil } from "@opencode-ai/core/fs-util"
// CLI entry point for `opencode run`.
//
// Handles three modes:
@ -15,6 +16,7 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import type { Argv } from "yargs"
import path from "path"
import { pathToFileURL } from "url"
import { open } from "node:fs/promises"
import { Effect } from "effect"
import { UI } from "../ui"
import { effectCmd } from "../effect-cmd"
@ -54,6 +56,8 @@ type FilePart = {
mime: string
}
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
type Inline = {
icon: string
title: string
@ -337,11 +341,48 @@ export const RunCommand = effectCmd({
process.exit(1)
}
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
const stat = Filesystem.stat(resolvedPath)
const isDirectory = stat?.isDirectory() ?? false
if (args.attach && isDirectory) {
UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`)
process.exit(1)
}
const content = await (async () => {
if (!args.attach) return
const handle = await open(resolvedPath, "r")
try {
const opened = await handle.stat()
if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) {
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${filePath}`)
process.exit(1)
}
if (opened.size === 0) return Buffer.alloc(0)
const buffer = Buffer.alloc(Number(opened.size))
let offset = 0
while (offset < buffer.length) {
const read = await handle.read(buffer, offset, buffer.length - offset, offset)
if (read.bytesRead === 0) break
offset += read.bytesRead
}
return buffer.subarray(0, offset)
} finally {
await handle.close()
}
})()
const detected = FSUtil.mimeType(resolvedPath)
const text = content?.toString("utf8")
const mime = !args.attach
? isDirectory
? "application/x-directory"
: "text/plain"
: content && text !== undefined && Buffer.from(text, "utf8").equals(content)
? "text/plain"
: detected
files.push({
type: "file",
url: pathToFileURL(resolvedPath).href,
url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href,
filename: path.basename(resolvedPath),
mime,
})

View file

@ -152,7 +152,7 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
normalizeLegacyErrorResponses(operation)
}
normalizeLegacyOperation(operation, path, method)
if ((path === "/event" || path === "/global/event") && method === "get") {
if ((path === "/event" || path === "/global/event" || path === "/api/event") && method === "get") {
// HttpApi has no first-class SSE response schema, and these handlers are
// raw/streaming routes. Document the actual wire protocol explicitly.
operation.responses!["200"] = {
@ -162,7 +162,9 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
schema:
path === "/event"
? { $ref: "#/components/schemas/Event" }
: { $ref: "#/components/schemas/GlobalEvent" },
: path === "/global/event"
? { $ref: "#/components/schemas/GlobalEvent" }
: { $ref: "#/components/schemas/V2Event" },
},
},
}

View file

@ -5,6 +5,7 @@
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { reply } from "../../lib/llm-server"
import { cliIt } from "../../lib/cli-process"
describe("opencode run (non-interactive subprocess)", () => {
@ -17,7 +18,46 @@ describe("opencode run (non-interactive subprocess)", () => {
yield* llm.text("hello from the test llm")
const result = yield* opencode.run("say hi")
opencode.expectExit(result, 0)
expect(result.stdout).toContain("hello from the test llm")
expect(result.stdout).toBe("hello from the test llm\n")
}),
60_000,
)
cliIt.concurrent(
"prints each completed text part in order around a tool continuation",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
reply().text(" before tool ").tool("bash", {
command: "printf tool-output",
description: "Print deterministic output",
}),
)
yield* llm.text(" after tool ")
const result = yield* opencode.run("use a tool", {
extraArgs: ["--dangerously-skip-permissions"],
})
opencode.expectExit(result, 0)
expect(result.stdout).toBe("before tool\nafter tool\n")
}),
60_000,
)
cliIt.concurrent(
"prints reasoning before text only with --thinking",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.reason(" considering ", { text: " answer " })
const thinking = yield* opencode.run("think", { extraArgs: ["--thinking"] })
opencode.expectExit(thinking, 0)
expect(thinking.stdout).toBe("Thinking: considering\nanswer\n")
yield* llm.reason("hidden", { text: "visible" })
const plain = yield* opencode.run("think again")
opencode.expectExit(plain, 0)
expect(plain.stdout).toBe("visible\n")
}),
60_000,
)
@ -41,19 +81,24 @@ describe("opencode run (non-interactive subprocess)", () => {
30_000,
)
// Locks in the current behavior: when the LLM stream errors mid-response
// (the prompt was accepted, then the upstream provider failed), opencode
// emits a session.error event and the process exits 0 today.
//
// This is debatable — a future cleanup might flip it to exit 1. If you're
// changing this expectation, do it deliberately and say so in the PR.
// The test provider's SSE error item is interpreted by the SDK as an unknown
// finish, not a fatal provider/session error. Lock that distinction in so it
// is not accidentally used as the failure compatibility oracle.
cliIt.concurrent(
"mid-stream LLM error still exits 0 today (contract lock-in)",
"unknown stream finish preserves partial output and exits 0",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
reply().text("partial response").tool("bash", {
command: "printf tool",
description: "Print deterministic output",
}),
)
yield* llm.fail("upstream provider exploded mid-stream")
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe("partial response\n")
expect(result.stderr).not.toContain("upstream provider exploded mid-stream")
}),
60_000,
)
@ -75,10 +120,212 @@ describe("opencode run (non-interactive subprocess)", () => {
expect(typeof evt.type).toBe("string")
expect(typeof evt.sessionID).toBe("string")
}
// At least one `text` event should appear with the LLM's response.
const text = events.find((e) => e.type === "text")
expect(text).toBeDefined()
expect(events.map((event) => event.type)).toEqual(["step_start", "text", "step_finish"])
expect(events.map(({ timestamp: _, sessionID: __, ...event }) => event)).toEqual([
{ type: "step_start", part: expect.objectContaining({ type: "step-start" }) },
{
type: "text",
part: expect.objectContaining({ type: "text", text: "structured output" }),
},
{ type: "step_finish", part: expect.objectContaining({ type: "step-finish" }) },
])
expect(result.stdout.endsWith("\n")).toBe(true)
expect(
result.stdout
.split("\n")
.slice(0, -1)
.every((line) => line.length > 0),
).toBe(true)
}),
60_000,
)
cliIt.concurrent(
"--format json emits a pure error record for a rejected prompt request",
({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.run("use an unknown model", {
model: "test/nonexistent-model",
format: "json",
})
expect(result.exitCode).not.toBe(0)
const events = opencode.parseJsonEvents(result.stdout)
expect(events.map((event) => event.type)).toEqual(["error"])
expect(events[0]).toEqual({
type: "error",
timestamp: expect.any(Number),
sessionID: expect.any(String),
error: expect.any(Object),
})
expect(result.stdout.split("\n").filter(Boolean)).toHaveLength(1)
}),
30_000,
)
cliIt.concurrent(
"--format json preserves reasoning, tool, and continuation ordering",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
reply().reason("reasoning").text("before").tool("bash", {
command: "printf tool",
description: "Print deterministic output",
}),
)
yield* llm.text("after")
const result = yield* opencode.run("exercise json records", {
format: "json",
extraArgs: ["--thinking", "--dangerously-skip-permissions"],
})
expect(result.exitCode).toBe(0)
const events = opencode.parseJsonEvents(result.stdout)
expect(events.map((event) => event.type)).toEqual([
"step_start",
"reasoning",
"text",
"tool_use",
"step_finish",
"step_start",
"text",
"step_finish",
])
expect(events.find((event) => event.type === "reasoning")?.part).toEqual(
expect.objectContaining({ type: "reasoning", text: "reasoning" }),
)
expect(events.find((event) => event.type === "tool_use")?.part).toEqual(
expect.objectContaining({
type: "tool",
tool: "bash",
state: expect.objectContaining({ status: "completed" }),
}),
)
expect(
result.stdout
.split("\n")
.slice(0, -1)
.every((line) => line.startsWith("{")),
).toBe(true)
}),
60_000,
)
cliIt.concurrent(
"--format json records partial output for an unknown stream finish",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
reply().text("partial json").tool("bash", {
command: "printf tool",
description: "Print deterministic output",
}),
)
yield* llm.fail("provider failed")
const result = yield* opencode.run("fail after output", { format: "json" })
const events = opencode.parseJsonEvents(result.stdout)
expect(result.exitCode).toBe(0)
expect(events.map((event) => event.type)).toEqual([
"step_start",
"text",
"tool_use",
"step_finish",
"step_start",
"step_finish",
])
expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" }))
expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" }))
}),
60_000,
)
cliIt.concurrent(
"rejects requested permissions by default and allows them with the dangerous flag",
({ home, llm, opencode }) =>
Effect.gen(function* () {
yield* llm.tool("bash", { command: "rm -f denied-file", description: "Remove a test file" })
yield* llm.text("continued after rejection")
const denied = yield* opencode.run("request permission", { permission: { bash: "ask" } })
opencode.expectExit(denied, 0)
expect(denied.stderr).toContain("permission requested: bash")
expect(denied.stdout).toBe("")
yield* llm.reset
yield* llm.tool("bash", { command: "rm -f allowed-file", description: "Remove a test file" })
yield* llm.text("continued after approval")
const allowed = yield* opencode.run("request permission", {
permission: { bash: "ask" },
extraArgs: ["--dangerously-skip-permissions"],
})
opencode.expectExit(allowed, 0)
expect(allowed.stderr).not.toContain("permission requested: bash")
expect(allowed.stdout).toContain("continued after approval")
yield* llm.reset
yield* llm.tool("bash", { command: "touch explicitly-denied", description: "Create a denied marker" })
yield* llm.text("continued after explicit denial")
const explicitlyDenied = yield* opencode.run("request denied permission", {
permission: { bash: "deny" },
extraArgs: ["--dangerously-skip-permissions"],
})
opencode.expectExit(explicitlyDenied, 0)
expect(explicitlyDenied.stdout).toContain("continued after explicit denial")
expect(yield* Effect.promise(() => Bun.file(`${home}/explicitly-denied`).exists())).toBe(false)
}),
60_000,
)
cliIt.live(
"attach mode sends client-local file contents without a shared path",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const source = `${home}/client-only.txt`
const sentinel = "client-only attachment sentinel"
yield* Effect.promise(() => Bun.write(source, sentinel))
yield* llm.text("attachment received")
const server = yield* opencode.serve()
const result = yield* opencode.run("read the attachment", {
extraArgs: ["--attach", server.url, `--file=${source}`, "--"],
})
opencode.expectExit(result, 0)
const input = JSON.stringify(yield* llm.inputs)
expect(input).toContain(sentinel)
expect(input).not.toContain(`file://${source}`)
}),
60_000,
)
cliIt.concurrent(
"attach mode rejects local directories before prompt admission",
({ home, opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.run("read the directory", {
extraArgs: ["--attach", "http://127.0.0.1:1", `--file=${home}`, "--"],
})
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toContain("Cannot attach local directory without a shared filesystem")
}),
30_000,
)
cliIt.live(
"SIGINT interrupts an active non-interactive run without leaking the process",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.hang
const run = yield* opencode.startRun("wait forever")
yield* llm.wait(1)
run.interrupt()
const result = yield* run.result
expect(result.exitCode).not.toBe(0)
expect(result.durationMs).toBeLessThan(30_000)
}),
30_000,
)
})

View file

@ -82,6 +82,11 @@ export type RunResult = {
readonly durationMs: number
}
export type RunHandle = {
readonly interrupt: () => void
readonly result: Effect.Effect<RunResult>
}
export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
// Typed equivalent of constructing argv for `opencode run`. New flags should
@ -92,6 +97,7 @@ export type RunOpts = SpawnOpts & {
readonly format?: "default" | "json"
readonly command?: string
readonly printLogs?: boolean
readonly permission?: Record<string, "ask" | "allow" | "deny">
readonly extraArgs?: string[]
}
@ -147,6 +153,7 @@ export type AcpHandle = {
export type OpencodeCli = {
// High-level: run a single prompt against the test model. Short-lived.
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
readonly startRun: (message: string, opts?: RunOpts) => Effect.Effect<RunHandle, never, Scope.Scope>
// Spawn `opencode serve` and wait until it's listening. Long-lived: the
// returned handle is killed when the caller's Scope closes. Fails if the
// listening line doesn't appear within `readyTimeoutMs`.
@ -236,7 +243,7 @@ export function withCliFixture<A, E>(
}
})
const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
const runArgs = (message: string, opts?: RunOpts) => {
const argv: string[] = ["run"]
if (opts?.printLogs) argv.push("--print-logs")
argv.push("--model", opts?.model ?? testModelID)
@ -245,9 +252,60 @@ export function withCliFixture<A, E>(
if (opts?.command) argv.push("--command", opts.command)
if (opts?.extraArgs) argv.push(...opts.extraArgs)
argv.push(message)
return spawn(argv, opts)
return argv
}
const runOpts = (opts?: RunOpts): SpawnOpts | undefined => {
if (!opts?.permission) return opts
return {
...opts,
env: {
...opts.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify({
...testProviderConfig(llm.url),
permission: opts.permission,
}),
},
}
}
const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
return spawn(runArgs(message, opts), runOpts(opts))
}
const startRun = Effect.fn("opencode.startRun")(function* (message: string, opts?: RunOpts) {
const start = Date.now()
const options = runOpts(opts)
const proc = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], {
cwd: home,
env: { ...process.env, ...env, ...options?.env },
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
}),
),
(child) =>
Effect.promise(() => {
child.kill()
return child.exited
}).pipe(Effect.ignore),
)
const stdout = new Response(proc.stdout).text()
const stderr = new Response(proc.stderr).text()
return {
interrupt: () => proc.kill("SIGINT"),
result: Effect.promise(async () => ({
exitCode: await proc.exited,
stdout: await stdout,
stderr: await stderr,
durationMs: Date.now() - start,
})),
} satisfies RunHandle
})
const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) {
const argv = ["serve"]
// Default port 0 — let the OS pick a free port, parse the actual one
@ -401,7 +459,7 @@ export function withCliFixture<A, E>(
} satisfies AcpHandle
})
const opencode: OpencodeCli = { run, serve, acp, spawn, expectExit, parseJsonEvents }
const opencode: OpencodeCli = { run, startRun, serve, acp, spawn, expectExit, parseJsonEvents }
return yield* fn({ llm, home, opencode })
// FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`

View file

@ -734,11 +734,11 @@ const scenarios: Scenario[] = [
.stream()
.status(
200,
(ctx, result) =>
(_ctx, result) =>
Effect.sync(() => {
check(result.contentType.includes("text/event-stream"), "v2 event should be an SSE stream")
check(result.text.includes("server.connected"), "v2 event should emit initial connection event")
check(!!ctx.directory && result.text.includes(ctx.directory), "v2 event should include the resolved location")
check(!result.text.includes('"location"'), "v2 connection event should not be scoped to a location")
}),
"status",
),
@ -955,6 +955,24 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.json(200, data(object)),
http.protected
.post("/api/session/{sessionID}/agent", "v2.session.switchAgent")
.seeded((ctx) => ctx.session({ title: "Switch agent" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/agent", { sessionID: ctx.state.id }),
headers: { ...ctx.headers(), "content-type": "application/json" },
body: { agent: "plan" },
}))
.status(204, undefined, "none"),
http.protected
.post("/api/session/{sessionID}/model", "v2.session.switchModel")
.seeded((ctx) => ctx.session({ title: "Switch model" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/model", { sessionID: ctx.state.id }),
headers: { ...ctx.headers(), "content-type": "application/json" },
body: { model: { providerID: "opencode", id: "big-pickle" } },
}))
.status(204, undefined, "none"),
http.protected
.get("/api/session/{sessionID}/context", "v2.session.context")
.at((ctx) => ({

View file

@ -21,10 +21,12 @@ function request(route: string, directory: string, init: RequestInit = {}) {
const Event = Schema.Struct({
id: Schema.String,
type: Schema.String,
location: Schema.Struct({
directory: Schema.String,
project: Schema.Struct({ id: Schema.String, directory: Schema.String }),
}),
location: Schema.optional(
Schema.Struct({
directory: Schema.String,
project: Schema.Struct({ id: Schema.String, directory: Schema.String }),
}),
),
data: Schema.Unknown,
})
@ -64,17 +66,20 @@ describe("v2 location HttpApi", () => {
}
})
test("streams native EventV2 payloads with resolved locations", async () => {
await using tmp = await tmpdir({ git: true })
const response = await request("/api/event", tmp.path)
test("streams native EventV2 payloads across locations", async () => {
await using subscriber = await tmpdir({ git: true })
await using publisher = await tmpdir({ git: true })
const response = await request("/api/event", subscriber.path)
const reader = response.body!.getReader()
expect((await readEvent(reader)).type).toBe("server.connected")
const connected = await readEvent(reader)
expect(connected.type).toBe("server.connected")
expect(connected.location).toBeUndefined()
const created = await request("/session", tmp.path, { method: "POST" })
const created = await request("/session", publisher.path, { method: "POST" })
expect(created.status).toBe(200)
expect(await readEventType(reader, "session.created")).toMatchObject({
type: "session.created",
location: { directory: tmp.path, project: { directory: tmp.path } },
location: { directory: publisher.path, project: { directory: publisher.path } },
data: { sessionID: expect.any(String) },
})
await reader.cancel()

View file

@ -353,6 +353,10 @@ import type {
V2SessionQuestionRejectResponses,
V2SessionQuestionReplyErrors,
V2SessionQuestionReplyResponses,
V2SessionSwitchAgentErrors,
V2SessionSwitchAgentResponses,
V2SessionSwitchModelErrors,
V2SessionSwitchModelResponses,
V2SessionWaitErrors,
V2SessionWaitResponses,
V2SkillListErrors,
@ -5337,6 +5341,88 @@ export class Session3 extends HeyApiClient {
})
}
/**
* Switch session agent
*
* Switch the agent used by subsequent session activity.
*/
public switchAgent<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
agent?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "body", key: "agent" },
],
},
],
)
return (options?.client ?? this.client).post<
V2SessionSwitchAgentResponses,
V2SessionSwitchAgentErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/agent",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Switch session model
*
* Switch the model used by subsequent session activity.
*/
public switchModel<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
model?: {
id: string
providerID: string
variant?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "body", key: "model" },
],
},
],
)
return (options?.client ?? this.client).post<
V2SessionSwitchModelResponses,
V2SessionSwitchModelErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/model",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Send message
*
@ -6157,22 +6243,12 @@ export class Event2 extends HeyApiClient {
/**
* Subscribe to events
*
* Subscribe to native event payloads for a location.
* Subscribe to native event payloads for the server.
*/
public subscribe<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
public subscribe<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).sse.get<V2EventSubscribeResponses, V2EventSubscribeErrors, ThrowOnError>({
url: "/api/event",
...options,
...params,
})
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,34 +1,42 @@
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const Event = Schema.Struct({
const fields = {
id: EventV2.ID,
type: Schema.String,
location: Location.Info.pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
version: Schema.Number.pipe(Schema.optional),
data: Schema.Unknown,
})
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
location: Schema.optional(Location.Ref),
}
const Event = Schema.Union([
...EventV2.definitions().map((definition) =>
Schema.Struct({
...fields,
type: Schema.Literal(definition.type),
data: definition.data as Schema.Struct<{}>,
}).annotate({ identifier: `V2Event.${definition.type}` }),
),
Schema.Struct({
...fields,
type: Schema.Literal("server.connected"),
data: Schema.Struct({}),
}).annotate({ identifier: "V2Event.server.connected" }),
]).annotate({ identifier: "V2Event" })
export const EventGroup = HttpApiGroup.make("server.event")
.add(
HttpApiEndpoint.get("event.subscribe", "/api/event", {
query: LocationQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.event.subscribe",
summary: "Subscribe to events",
description: "Subscribe to native event payloads for a location.",
}),
),
success: Event,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.event.subscribe",
summary: "Subscribe to events",
description: "Subscribe to native event payloads for the server.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." }))
.middleware(LocationMiddleware)
export type Event = typeof Event.Type

View file

@ -140,6 +140,38 @@ export const SessionGroup = HttpApiGroup.make("server.session")
}),
),
)
.add(
HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", {
params: { sessionID: SessionV2.ID },
payload: Schema.Struct({ agent: AgentV2.ID }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.switchAgent",
summary: "Switch session agent",
description: "Switch the agent used by subsequent session activity.",
}),
),
)
.add(
HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", {
params: { sessionID: SessionV2.ID },
payload: Schema.Struct({ model: ModelV2.Ref }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.switchModel",
summary: "Switch session model",
description: "Switch the model used by subsequent session activity.",
}),
),
)
.add(
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: SessionV2.ID },

View file

@ -1,5 +1,4 @@
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Effect, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@ -20,30 +19,14 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers)
const events = yield* EventV2.Service
return handlers.handleRaw("event.subscribe", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const connected = {
id: EventV2.ID.create(),
type: "server.connected",
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: {},
}
return HttpServerResponse.stream(
Stream.make(connected).pipe(
Stream.concat(
events
.all()
.pipe(
Stream.filter(
(event) =>
event.location?.directory === location.directory &&
event.location.workspaceID === location.workspaceID,
),
),
),
Stream.concat(events.all()),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,

View file

@ -92,6 +92,38 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.switchAgent",
Effect.fn(function* (ctx) {
yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.switchModel",
Effect.fn(function* (ctx) {
yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.prompt",
Effect.fn(function* (ctx) {

View file

@ -330,7 +330,7 @@ function CatalogDatum(props: { label: string; value: string }) {
function ModelOverview(props: { data: StatsModelData | null }) {
return (
<section data-section="model-panel">
<SectionTitle title="Overview" description="Recent OpenCode Go tokens, sessions, and market position." />
<SectionTitle title="Overview" description="Recent OpenCode Go tokens, unique users, and market position." />
<Show
when={props.data}
fallback={
@ -340,6 +340,7 @@ function ModelOverview(props: { data: StatsModelData | null }) {
{(data) => (
<div data-component="model-metric-grid">
<MetricCard label="Tokens" value={formatTokens(data().totals.tokens)} detail="last two months" />
<MetricCard label="Unique Users" value={formatUsers(data().totals.uniqueUsers)} detail="last two months" />
<MetricCard label="Sessions" value={formatInteger(data().totals.sessions)} detail="completed sessions" />
<MetricCard
label="Token Share"

View file

@ -1450,9 +1450,7 @@ function formatCountryName(country: string) {
}
function formatGeoTokens(value: number) {
if (value >= 1) return formatTrillions(value)
if (value >= 0.001) return `${Number((value * 1000).toFixed(value >= 0.01 ? 0 : 1))}B`
return `${Math.round(value * 1_000_000)}M`
return formatTrillions(value)
}
function formatGeoShare(value: number) {
@ -1508,6 +1506,9 @@ function formatMarketMobileDate(label: string) {
}
function formatTrillions(value: number) {
if (value === 0) return "0"
if (value < 0.001) return `${Number((value * 1_000_000).toFixed(value >= 0.00001 ? 0 : 1))}M`
if (value < 1) return `${Number((value * 1_000).toFixed(value >= 0.01 ? 0 : 1))}B`
return `${value.toFixed(value >= 10 ? 0 : 1)}T`
}

View file

@ -54,6 +54,7 @@ export type StatsModelData = {
tokenChange: number
totals: {
sessions: number
uniqueUsers: number
tokens: number
cost: number
tokensPerSession: number
@ -381,6 +382,7 @@ function buildStatsModelData(
tokenChange: percentChange(current.totalTokens, previous.totalTokens),
totals: {
sessions: current.sessions,
uniqueUsers: current.uniqueUsers,
tokens: current.totalTokens,
cost: round(microcentsToDollars(current.totalCostMicrocents), 2),
tokensPerSession: current.sessions > 0 ? Math.round(current.totalTokens / current.sessions) : 0,
@ -536,11 +538,11 @@ function buildMarketShare(rows: ProviderMetricRow[], product: UsageProduct, rang
return [
{
date: bucket.label,
total: round(totalTokens / 1_000_000_000_000, 2),
total: round(totalTokens / 1_000_000_000_000, 6),
authors: withOther.map((item) => ({
author: item.provider === "Other" ? "Other" : formatProvider(item.provider),
share: round((item.tokens / totalTokens) * 100, 1),
tokens: round(item.tokens / 1_000_000_000_000, 2),
tokens: round(item.tokens / 1_000_000_000_000, 6),
})),
},
]

View file

@ -1,7 +1,10 @@
import { TextAttributes } from "@opentui/core"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { createResource, createMemo } from "solid-js"
import { createResource, createMemo, createSignal } from "solid-js"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme"
import { errorMessage } from "../util/error"
export type DialogSkillProps = {
onSelect: (skill: string) => void
@ -10,14 +13,27 @@ export type DialogSkillProps = {
export function DialogSkill(props: DialogSkillProps) {
const dialog = useDialog()
const sdk = useSDK()
const { theme } = useTheme()
dialog.setSize("large")
const [skills] = createResource(async () => {
const result = await sdk.client.app.skills()
return result.data ?? []
})
const [loadError, setLoadError] = createSignal<unknown>()
const [skills] = createResource(() =>
sdk.client.app
.skills({}, { throwOnError: true })
.then((result) => result.data ?? [])
// Catch so the rejected resource never reaches the memo below: reading
// skills() in an errored state re-throws and tears down the dialog.
.catch((error) => {
setLoadError(error)
return undefined
}),
)
const showError = createMemo(() => Boolean(loadError()))
const options = createMemo<DialogSelectOption<string>[]>(() => {
if (showError()) return []
const list = skills() ?? []
const maxWidth = Math.max(0, ...list.map((s) => s.name.length))
return list.map((skill) => ({
@ -32,5 +48,23 @@ export function DialogSkill(props: DialogSkillProps) {
}))
})
return <DialogSelect title="Skills" placeholder="Search skills..." options={options()} />
return (
<DialogSelect
title="Skills"
placeholder="Search skills..."
options={options()}
renderFilter={!showError()}
locked={showError()}
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
</box>
) : undefined
}
/>
)
}

View file

@ -1,9 +1,7 @@
import { useEvent } from "./event"
import type {
AgentV2Info,
CommandV2Info,
IntegrationInfo,
Event,
LocationRef,
ModelV2Info,
PermissionSavedInfo,
@ -18,11 +16,13 @@ import type {
SessionMessageAssistantTool,
SessionV2Info,
SkillV2Info,
V2Event,
} from "@opencode-ai/sdk/v2"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { createSignal, onMount } from "solid-js"
import { useEvent } from "./event"
import { createSignal, onCleanup, onMount } from "solid-js"
type LocationData = {
agent?: AgentV2Info[]
@ -71,8 +71,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
location: {},
})
const event = useEvent()
const sdk = useSDK()
const events = useEvent()
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
directory: sdk.directory ?? process.cwd(),
})
@ -121,43 +121,43 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
}
event.subscribe((event, metadata) => {
function handleEvent(event: V2Event) {
switch (event.type) {
case "catalog.updated":
void Promise.all([
result.location.model.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
result.location.provider.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
result.location.model.refresh(event.location),
result.location.provider.refresh(event.location),
])
break
case "session.next.agent.switched":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "agent-switched",
agent: event.properties.agent,
time: { created: event.properties.timestamp },
agent: event.data.agent,
time: { created: event.data.timestamp },
})
})
break
case "session.next.model.switched":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "model-switched",
model: event.properties.model,
time: { created: event.properties.timestamp },
model: event.data.model,
time: { created: event.data.timestamp },
})
})
break
case "session.next.prompted": {
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
time: { created: event.properties.timestamp },
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
time: { created: event.data.timestamp },
})
})
break
@ -165,248 +165,223 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.prompt.admitted":
break
case "session.next.prompt.promoted":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
time: { created: event.properties.timeCreated },
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
time: { created: event.data.timeCreated },
})
})
break
case "session.next.context.updated":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "system",
text: event.properties.text,
time: { created: event.properties.timestamp },
text: event.data.text,
time: { created: event.data.timestamp },
})
})
break
case "session.next.synthetic":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "synthetic",
sessionID: event.properties.sessionID,
text: event.properties.text,
time: { created: event.properties.timestamp },
sessionID: event.data.sessionID,
text: event.data.text,
time: { created: event.data.timestamp },
})
})
break
case "session.next.shell.started":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "shell",
callID: event.properties.callID,
command: event.properties.command,
callID: event.data.callID,
command: event.data.command,
output: "",
time: { created: event.properties.timestamp },
time: { created: event.data.timestamp },
})
})
break
case "session.next.shell.ended":
message.update(event.properties.sessionID, (draft) => {
const match = message.activeShell(draft, event.properties.callID)
message.update(event.data.sessionID, (draft) => {
const match = message.activeShell(draft, event.data.callID)
if (!match) return
match.output = event.properties.output
match.time.completed = event.properties.timestamp
match.output = event.data.output
match.time.completed = event.data.timestamp
})
break
case "session.next.step.started":
message.update(event.properties.sessionID, (draft) => {
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
message.update(event.data.sessionID, (draft) => {
if (draft.some((message) => message.id === event.data.assistantMessageID)) return
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
if (currentAssistant) currentAssistant.time.completed = event.data.timestamp
message.prepend(draft, {
id: event.properties.assistantMessageID,
id: event.data.assistantMessageID,
type: "assistant",
agent: event.properties.agent,
model: event.properties.model,
agent: event.data.agent,
model: event.data.model,
content: [],
snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
time: { created: event.properties.timestamp },
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.data.timestamp },
})
})
break
case "session.next.step.ended":
message.update(event.properties.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
message.update(event.data.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp
currentAssistant.finish = event.properties.finish
currentAssistant.cost = event.properties.cost
currentAssistant.tokens = event.properties.tokens
if (event.properties.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
currentAssistant.time.completed = event.data.timestamp
currentAssistant.finish = event.data.finish
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
if (event.data.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
})
break
case "session.next.step.failed":
message.update(event.properties.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
message.update(event.data.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp
currentAssistant.time.completed = event.data.timestamp
currentAssistant.finish = "error"
currentAssistant.error = event.properties.error
currentAssistant.error = event.data.error
})
break
case "session.next.text.started":
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
message.update(event.data.sessionID, (draft) => {
message.assistant(draft, event.data.assistantMessageID)?.content.push({
type: "text",
id: event.properties.textID,
id: event.data.textID,
text: "",
})
})
break
case "session.next.text.delta":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestText(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.textID,
)
if (match) match.text += event.properties.delta
message.update(event.data.sessionID, (draft) => {
const match = message.latestText(message.assistant(draft, event.data.assistantMessageID), event.data.textID)
if (match) match.text += event.data.delta
})
break
case "session.next.text.ended":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestText(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.textID,
)
if (match) match.text = event.properties.text
message.update(event.data.sessionID, (draft) => {
const match = message.latestText(message.assistant(draft, event.data.assistantMessageID), event.data.textID)
if (match) match.text = event.data.text
})
break
case "session.next.tool.input.started":
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
message.update(event.data.sessionID, (draft) => {
message.assistant(draft, event.data.assistantMessageID)?.content.push({
type: "tool",
id: event.properties.callID,
name: event.properties.name,
time: { created: event.properties.timestamp },
id: event.data.callID,
name: event.data.name,
time: { created: event.data.timestamp },
state: { status: "pending", input: "" },
})
})
break
case "session.next.tool.input.delta":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status === "pending") match.state.input += event.properties.delta
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
if (match?.state.status === "pending") match.state.input += event.data.delta
})
break
case "session.next.tool.input.ended":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status === "pending") match.state.input = event.properties.text
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
if (match?.state.status === "pending") match.state.input = event.data.text
})
break
case "session.next.tool.called":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
if (!match) return
match.time.ran = event.properties.timestamp
match.provider = event.properties.provider
match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
match.time.ran = event.data.timestamp
match.provider = event.data.provider
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
})
break
case "session.next.tool.progress":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
if (match?.state.status !== "running") return
match.state.structured = event.properties.structured
match.state.content = [...event.properties.content]
match.state.structured = event.data.structured
match.state.content = [...event.data.content]
})
break
case "session.next.tool.success":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
if (match?.state.status !== "running") return
match.state = {
status: "completed",
input: match.state.input,
structured: event.properties.structured,
content: [...event.properties.content],
result: event.properties.result,
structured: event.data.structured,
content: [...event.data.content],
result: event.data.result,
}
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.properties.timestamp
match.time.completed = event.data.timestamp
})
break
case "session.next.tool.failed":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
match.state = {
status: "error",
error: event.properties.error,
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: match.state.status === "running" ? match.state.structured : {},
content: match.state.status === "running" ? match.state.content : [],
result: event.properties.result,
result: event.data.result,
}
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.properties.timestamp
match.time.completed = event.data.timestamp
})
break
case "session.next.reasoning.started":
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
message.update(event.data.sessionID, (draft) => {
message.assistant(draft, event.data.assistantMessageID)?.content.push({
type: "reasoning",
id: event.properties.reasoningID,
id: event.data.reasoningID,
text: "",
providerMetadata: event.properties.providerMetadata,
providerMetadata: event.data.providerMetadata,
})
})
break
case "session.next.reasoning.delta":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestReasoning(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
message.assistant(draft, event.data.assistantMessageID),
event.data.reasoningID,
)
if (match) match.text += event.properties.delta
if (match) match.text += event.data.delta
})
break
case "session.next.reasoning.ended":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestReasoning(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
message.assistant(draft, event.data.assistantMessageID),
event.data.reasoningID,
)
if (match) {
match.text = event.properties.text
if (event.properties.providerMetadata !== undefined)
match.providerMetadata = event.properties.providerMetadata
match.text = event.data.text
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
}
})
break
@ -415,14 +390,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.compaction.delta":
break
case "session.next.compaction.ended":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "compaction",
reason: event.properties.reason,
summary: event.properties.text,
recent: event.properties.recent,
time: { created: event.properties.timestamp },
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.data.timestamp },
})
})
break
@ -431,12 +406,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "integration.updated":
void Promise.all([
result.location.integration.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
result.location.model.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
result.location.provider.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
result.location.integration.refresh(event.location),
result.location.model.refresh(event.location),
result.location.provider.refresh(event.location),
])
break
}
}
onMount(() => {
const unsub = events.subscribe((event, metadata) => {
handleEvent({
...event,
data: event.properties,
location: { directory: metadata.directory, workspaceID: metadata.workspace },
} as V2Event)
})
onCleanup(unsub)
})
const result = {

View file

@ -26,6 +26,7 @@ function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event)
}
test("refreshes resources into reactive getters", async () => {
const events = createEventSource()
const location = {
directory,
project: { id: "proj_test", directory },
@ -49,8 +50,7 @@ test("refreshes resources into reactive getters", async () => {
data: [{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }],
})
return undefined
})
const events = createEventSource()
}, events)
let data!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
@ -119,7 +119,7 @@ test("refreshes integrations after integration updates", async () => {
},
],
})
})
}, events)
let data!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
@ -171,7 +171,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
requests.provider++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
}
})
}, events)
const app = await testRender(() => (
<TestTuiContexts>
@ -205,7 +205,7 @@ test("refreshes references after updates", async () => {
location: { directory, project: { id: "proj_test", directory } },
data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
})
})
}, events)
let data!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
@ -243,7 +243,7 @@ test("refreshes references after updates", async () => {
test("settles pending tools when a live failure arrives", async () => {
const events = createEventSource()
const calls = createFetch()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
@ -372,7 +372,7 @@ test("settles pending tools when a live failure arrives", async () => {
test("renders admitted prompts only after promotion", async () => {
const events = createEventSource()
const calls = createFetch()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
@ -436,7 +436,7 @@ test("renders admitted prompts only after promotion", async () => {
test("renders a promoted prompt when admission was missed", async () => {
const events = createEventSource()
const calls = createFetch()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
@ -484,7 +484,7 @@ test("renders a promoted prompt when admission was missed", async () => {
test("projects live context updates with their message ID", async () => {
const events = createEventSource()
const calls = createFetch()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {

View file

@ -17,6 +17,8 @@ export function eventSource(): EventSource {
export function createEventSource() {
let fn: ((event: GlobalEvent) => void) | undefined
let stream: ReadableStreamDefaultController<Uint8Array> | undefined
const pending: Uint8Array[] = []
return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
@ -29,19 +31,44 @@ export function createEventSource() {
emit(event: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(event)
if (!("properties" in event.payload)) return
const chunk = new TextEncoder().encode(
`data: ${JSON.stringify({
...event.payload,
location: { directory: event.directory, workspaceID: event.workspace },
data: event.payload.properties,
})}\n\n`,
)
if (stream) return stream.enqueue(chunk)
pending.push(chunk)
},
response() {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
stream = controller
for (const chunk of pending.splice(0)) controller.enqueue(chunk)
},
cancel() {
stream = undefined
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
}
}
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
export function createFetch(override?: FetchHandler) {
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventSource>) {
const session = [] as URL[]
const fetch = (async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input))
if (url.pathname === "/session") session.push(url)
const overridden = await override?.(url)
if (overridden) return overridden
if (url.pathname === "/api/event" && events) return events.response()
if (
[

Some files were not shown because too many files have changed in this diff Show more