Compare commits

..

3 Commits

Author SHA1 Message Date
Kit Langton f7b2843679 fix(opencode): map filesystem client errors 2026-06-21 13:16:32 -04:00
Kit Langton 868c1fc596 fix(server): map filesystem request errors 2026-06-21 13:15:39 -04:00
Kit Langton e8d632834c fix(core): expose filesystem failures 2026-06-21 13:14:37 -04:00
164 changed files with 4507 additions and 12471 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
+9 -6
View File
@@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
_Avoid_: System update, system notification, raw text diff _Avoid_: System update, system notification, raw text diff
**Context Epoch**: **Context Epoch**:
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline. The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
**Baseline System Context**: **Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**. The full **System Context** rendered at the start of a **Context Epoch**.
@@ -75,28 +75,31 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed. - Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**. - `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked. - `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable. - `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. - **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. - Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**. - Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. - Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool. - Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn. - Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline. - A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline.
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy. - Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**. - A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. - Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
+3 -5
View File
@@ -27,11 +27,9 @@ export default Runtime.handler(
function listen(hostname: string, port: Option.Option<number>, password: string) { function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password) if (Option.isSome(port)) return bind(hostname, port.value, password)
const next = (port: number): ReturnType<typeof bind> => // Preserve the familiar default when available, but let the OS choose a free
bind(hostname, port, password).pipe( // port when another local server already owns 4096.
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))), return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
)
return next(4096)
} }
function bind(hostname: string, port: number, password: string) { function bind(hostname: string, port: number, password: string) {
-2
View File
@@ -1,2 +0,0 @@
[test]
preload = ["./test/preload.ts"]
+32 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad", "id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"], "prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
"ddl": [ "ddl": [
{ {
"name": "workspace", "name": "workspace",
@@ -900,6 +900,16 @@
"entityType": "columns", "entityType": "columns",
"table": "session_context_epoch" "table": "session_context_epoch"
}, },
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'build'",
"generated": null,
"name": "agent",
"entityType": "columns",
"table": "session_context_epoch"
},
{ {
"type": "text", "type": "text",
"notNull": true, "notNull": true,
@@ -920,6 +930,26 @@
"entityType": "columns", "entityType": "columns",
"table": "session_context_epoch" "table": "session_context_epoch"
}, },
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "replacement_seq",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "session_context_epoch"
},
{ {
"type": "text", "type": "text",
"notNull": false, "notNull": false,
+4 -18
View File
@@ -45,17 +45,15 @@ async function generate() {
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`) if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
await Bun.write( await Bun.write(
target, target,
await formatTypescript(
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()), renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
),
) )
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot) await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
} }
await fs.mkdir(full) await fs.mkdir(full)
await drizzle(temporary, full, "schema") await drizzle(temporary, full, "schema")
await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full)))) await Bun.write(schema, renderSchema(await generatedSql(full)))
await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations()))) await Bun.write(registry, renderRegistry(await typescriptMigrations()))
} finally { } finally {
await fs.rm(temporary, { recursive: true, force: true }) await fs.rm(temporary, { recursive: true, force: true })
} }
@@ -78,12 +76,12 @@ async function check() {
await fs.mkdir(full) await fs.mkdir(full)
await drizzle(temporary, full, "schema") await drizzle(temporary, full, "schema")
if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) { if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) {
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.") throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
} }
const migrations = await typescriptMigrations() const migrations = await typescriptMigrations()
if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) { if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.") throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
} }
} finally { } finally {
@@ -172,18 +170,6 @@ function escapeTemplate(line: string) {
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${") 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[]) { function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration" return `import type { DatabaseMigration } from "./migration"
+8 -3
View File
@@ -69,10 +69,10 @@ export const layer = Layer.effect(
const policy = yield* Policy.Service const policy = yield* Policy.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
if (provider.disabled) return false if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true if (typeof provider.request.body.apiKey === "string") return true
if (integration?.connections.length) return true if (connected) return true
return !integration return !integration
} }
@@ -183,8 +183,13 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.provider.available")(function* () { available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const connections = yield* integrations.connection.list()
return (yield* result.provider.all()).filter((provider) => return (yield* result.provider.all()).filter((provider) =>
available(provider, active.get(Integration.ID.make(provider.id))), available(
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
) )
}), }),
}, },
+14 -14
View File
@@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {} }) {}
export const Value = Schema.Union([OAuth, Key]) export const Info = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type")) .pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Value" }) .annotate({ identifier: "Credential.Info" })
export type Value = Schema.Schema.Type<typeof Value> export type Info = Schema.Schema.Type<typeof Info>
export class Info extends Schema.Class<Info>("Credential.Info")({ export class Stored extends Schema.Class<Stored>("Credential.Stored")({
id: ID, id: ID,
integrationID: IntegrationSchema.ID, integrationID: IntegrationSchema.ID,
label: Schema.String, label: Schema.String,
value: Value, value: Info,
}) {} }) {}
export interface Interface { export interface Interface {
/** Returns every stored credential. */ /** Returns every stored credential. */
readonly all: () => Effect.Effect<Info[]> readonly all: () => Effect.Effect<Stored[]>
/** Returns stored credentials belonging to one integration. */ /** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]> readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
/** Returns one stored credential by ID. */ /** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Info | undefined> readonly get: (id: ID) => Effect.Effect<Stored | undefined>
/** Replaces any credential for an integration and returns the new record. */ /** Replaces any credential for an integration and returns the new record. */
readonly create: (input: { readonly create: (input: {
readonly integrationID: IntegrationSchema.ID readonly integrationID: IntegrationSchema.ID
readonly value: Value readonly value: Info
readonly label?: string readonly label?: string
}) => Effect.Effect<Info> }) => Effect.Effect<Stored>
/** Updates the label or secret value of a stored credential. */ /** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void> readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */ /** Removes a stored credential. */
readonly remove: (id: ID) => Effect.Effect<void> readonly remove: (id: ID) => Effect.Effect<void>
} }
@@ -66,10 +66,10 @@ export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Value) const decode = Schema.decodeUnknownSync(Info)
const stored = (row: typeof CredentialTable.$inferSelect) => { const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return if (!row.integration_id) return
return new Info({ return new Stored({
id: row.id, id: row.id,
integrationID: row.integration_id, integrationID: row.integration_id,
label: row.label, label: row.label,
@@ -106,7 +106,7 @@ export const layer = Layer.effect(
return row ? stored(row) : undefined return row ? stored(row) : undefined
}), }),
create: Effect.fn("Credential.create")(function* (input) { create: Effect.fn("Credential.create")(function* (input) {
const credential = new Info({ const credential = new Stored({
id: ID.create(), id: ID.create(),
integrationID: input.integrationID, integrationID: input.integrationID,
label: input.label ?? "default", label: input.label ?? "default",
+1 -1
View File
@@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
id: text().$type<Credential.ID>().primaryKey(), id: text().$type<Credential.ID>().primaryKey(),
integration_id: text().$type<IntegrationSchema.ID>(), integration_id: text().$type<IntegrationSchema.ID>(),
label: text().notNull(), label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Value>().notNull(), value: text({ mode: "json" }).$type<Credential.Info>().notNull(),
connector_id: text(), connector_id: text(),
method_id: text(), method_id: text(),
active: integer({ mode: "boolean" }), active: integer({ mode: "boolean" }),
-1
View File
@@ -37,6 +37,5 @@ export const migrations = (
import("./migration/20260611035744_credential"), import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"), import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"), import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,13 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622142730_simplify_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
})
},
} satisfies DatabaseMigration.Migration
+3
View File
@@ -149,8 +149,11 @@ export default {
CREATE TABLE \`session_context_epoch\` ( CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY, \`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL, \`baseline\` text NOT NULL,
\`agent\` text DEFAULT 'build' NOT NULL,
\`snapshot\` text NOT NULL, \`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL, \`baseline_seq\` integer NOT NULL,
\`replacement_seq\` integer,
\`revision\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
); );
`) `)
+15 -14
View File
@@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util"
import { Location } from "./location" import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema" import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search" import { FileSystemSearch } from "./filesystem/search"
import { Entry, Match } from "./filesystem/schema" import { Entry, Match, PathError } from "./filesystem/schema"
export { Entry, Match, Submatch } from "./filesystem/schema" export { Entry, Match, PathError, Submatch } from "./filesystem/schema"
export const ReadInput = Schema.Struct({ export const ReadInput = Schema.Struct({
path: RelativePath, path: RelativePath,
@@ -58,8 +58,10 @@ export const Event = {
} }
export interface Interface { export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> readonly read: (
readonly list: (input?: ListInput) => Effect.Effect<Entry[]> input: ReadInput,
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, PathError | FSUtil.Error>
readonly list: (input?: ListInput) => Effect.Effect<Entry[], PathError | FSUtil.Error>
readonly find: (input: FindInput) => Effect.Effect<Entry[]> readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]> readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]> readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
@@ -77,9 +79,9 @@ const baseLayer = Layer.effect(
const resolve = Effect.fnUntraced(function* (input?: RelativePath) { const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.resolve(location.directory, input ?? ".") const absolute = path.resolve(location.directory, input ?? ".")
if (!FSUtil.contains(location.directory, absolute)) if (!FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location")) return yield* new PathError({ path: input ?? ".", reason: "lexical_escape" })
const real = yield* fs.realPath(absolute).pipe(Effect.orDie) const real = yield* fs.realPath(absolute)
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location")) if (!FSUtil.contains(root, real)) return yield* new PathError({ path: input ?? ".", reason: "symlink_escape" })
return { absolute, real, directory: location.directory, root } return { absolute, real, directory: location.directory, root }
}) })
return Service.of({ return Service.of({
@@ -88,19 +90,18 @@ const baseLayer = Layer.effect(
grep: search.grep, grep: search.grep,
read: Effect.fn("FileSystem.read")(function* (input) { read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path) const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie) const info = yield* fs.stat(target.real)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) if (info.type !== "File") return yield* new PathError({ path: input.path, reason: "not_file" })
return { return {
content: yield* fs.readFile(target.real).pipe(Effect.orDie), content: yield* fs.readFile(target.real),
mime: FSUtil.mimeType(target.real), mime: FSUtil.mimeType(target.real),
} }
}), }),
list: Effect.fn("FileSystem.list")(function* (input = {}) { list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path) const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie) const info = yield* fs.stat(target.real)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) if (info.type !== "Directory") return yield* new PathError({ path: input.path ?? ".", reason: "not_directory" })
return yield* fs.readDirectoryEntries(target.real).pipe( return yield* fs.readDirectoryEntries(target.real).pipe(
Effect.orDie,
Effect.map((items) => Effect.map((items) =>
items items
.flatMap((item) => { .flatMap((item) => {
@@ -123,6 +124,6 @@ const baseLayer = Layer.effect(
}), }),
) )
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer)) export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer export const locationLayer = layer
+5
View File
@@ -21,3 +21,8 @@ export class Match extends Schema.Class<Match>("FileSystem.Match")({
text: Schema.String, text: Schema.String,
submatches: Schema.Array(Submatch), submatches: Schema.Array(Submatch),
}) {} }) {}
export class PathError extends Schema.TaggedErrorClass<PathError>()("FileSystem.PathError", {
path: Schema.String,
reason: Schema.Literals(["lexical_escape", "symlink_escape", "not_file", "not_directory"]),
}) {}
+1 -1
View File
@@ -232,6 +232,6 @@ export const fffLayer = Layer.effect(
}), }),
) )
export const locationLayer = Layer.unwrap( export const defaultLayer = Layer.unwrap(
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)), Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
) )
+42 -18
View File
@@ -108,11 +108,11 @@ export type OAuthAuthorization = {
} & ( } & (
| { | {
readonly mode: "auto" readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Value, unknown> readonly callback: Effect.Effect<Credential.Info, unknown>
} }
| { | {
readonly mode: "code" readonly mode: "code"
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown> readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown>
} }
) )
@@ -214,6 +214,8 @@ export interface Interface extends State.Transformable<Draft> {
/** Returns all integrations with their methods and current connections. */ /** Returns all integrations with their methods and current connections. */
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
readonly connection: { readonly connection: {
/** Returns active connections for every registered or credential-backed integration. */
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
/** Returns the active connection for one integration. */ /** Returns the active connection for one integration. */
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined> readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Runs a key method and stores the resulting credential. */ /** Runs a key method and stores the resulting credential. */
@@ -239,7 +241,7 @@ export interface Interface extends State.Transformable<Draft> {
/** Updates a stored credential exposed as a connection. */ /** Updates a stored credential exposed as a connection. */
readonly update: ( readonly update: (
credentialID: Credential.ID, credentialID: Credential.ID,
updates: Partial<Pick<Credential.Info, "label">>, updates: Partial<Pick<Credential.Stored, "label">>,
) => Effect.Effect<void> ) => Effect.Effect<void>
/** Removes a stored credential connection. */ /** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void> readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
@@ -351,27 +353,39 @@ export const locationLayer = Layer.effect(
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
}) })
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => { const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
const credentials = saved const connected = saved.map((credential) => ({
.map((credential) => ({
type: "credential" as const, type: "credential" as const,
id: credential.id, id: credential.id,
label: credential.label, label: credential.label,
})) }))
.toReversed() const detected = entry.methods
const env = (entry?.methods ?? [])
.filter((method) => method.type === "env") .filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name])) .flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name })) .map((name) => ({ type: "env" as const, name }))
return [...credentials, ...env] return [...connected, ...detected]
} }
const project = (entry: Entry, connections: IntegrationConnection.Info[]) => 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[]) =>
new Info({ new Info({
id: entry.ref.id, id: entry.ref.id,
name: entry.ref.name, name: entry.ref.name,
methods: entry.methods, methods: entry.methods,
connections, connections: connections(entry, saved),
}) })
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) => const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -385,7 +399,7 @@ export const locationLayer = Layer.effect(
return error instanceof Error ? error.message : String(error) return error instanceof Error ? error.message : String(error)
} }
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) { const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) {
const now = yield* Clock.currentTimeMillis const now = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(attempts, (current) => { const result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID) const attempt = current.get(attemptID)
@@ -436,18 +450,28 @@ export const locationLayer = Layer.effect(
get: Effect.fn("Integration.get")(function* (id) { get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id) const entry = state.get().integrations.get(id)
if (!entry) return undefined if (!entry) return undefined
return project(entry, resolveConnections(entry, yield* credentials.list(id))) return project(entry, yield* credentials.list(id))
}), }),
list: Effect.fn("Integration.list")(function* () { list: Effect.fn("Integration.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) return (yield* Effect.forEach(state.get().integrations.values(), (entry) =>
return Array.from(state.get().integrations.values(), (entry) => Effect.gen(function* () {
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])), return project(entry, yield* credentials.list(entry.ref.id))
).toSorted((a, b) => a.name.localeCompare(b.name)) }),
)).toSorted((a, b) => a.name.localeCompare(b.name))
}), }),
connection: { connection: {
list: Effect.fn("Integration.connection.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return new Map(
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
return connection ? [[id, connection] as const] : []
}),
)
}),
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) { forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
const entry = state.get().integrations.get(id) const entry = state.get().integrations.get(id)
return resolveConnections(entry, yield* credentials.list(id))[0] return activeConnection(entry, yield* credentials.list(id))
}), }),
key: Effect.fn("Integration.connection.key")(function* (input) { key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state const method = state
+3 -11
View File
@@ -103,16 +103,8 @@ export const layer = Layer.effect(
} }
}[keyof Hooks][] = [] }[keyof Hooks][] = []
const events = yield* EventV2.Service const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const locks = KeyedMutex.makeUnsafe<ID>() 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({ const svc = Service.of({
add: Effect.fn("Plugin.add")(function* (input) { add: Effect.fn("Plugin.add")(function* (input) {
@@ -120,7 +112,7 @@ export const layer = Layer.effect(
yield* locks.withLock(id)( yield* locks.withLock(id)(
Effect.gen(function* () { Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id) const existing = hooks.find((item) => item.id === id)
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
const childScope = yield* Scope.fork(scope) const childScope = yield* Scope.fork(scope)
const result = yield* input.effect.pipe( const result = yield* input.effect.pipe(
Scope.provide(childScope), Scope.provide(childScope),
@@ -189,7 +181,7 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id) const existing = hooks.find((item) => item.id === id)
hooks = hooks.filter((item) => item.id !== id) hooks = hooks.filter((item) => item.id !== id)
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
}), }),
) )
}), }),
+58 -5
View File
@@ -1,9 +1,11 @@
export * as OpenCode from "./opencode" export * as OpenCode from "./opencode"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Catalog } from "../catalog"
import { Database } from "../database/database" import { Database } from "../database/database"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { LocationServiceMap } from "../location-layer" import { LocationServiceMap } from "../location-layer"
import { PluginBoot } from "../plugin/boot"
import { ProjectV2 } from "../project" import { ProjectV2 } from "../project"
import { SessionV2 } from "../session" import { SessionV2 } from "../session"
import * as SessionExecutionLocal from "../session/execution/local" import * as SessionExecutionLocal from "../session/execution/local"
@@ -21,22 +23,69 @@ export interface Interface {
/** Intentional public native API for Effect applications embedding OpenCode. */ /** Intentional public native API for Effect applications embedding OpenCode. */
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {} export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
const SessionsLayer = SessionV2.layer.pipe( class SessionModelValidation extends Context.Service<
SessionModelValidation,
{
readonly validate: (
input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
}
>()("@opencode/public/OpenCode/SessionModelValidation") {}
const ApplicationToolsLayer = ApplicationTools.layer
const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer))
const SessionModelValidationLayer = Layer.effect(
SessionModelValidation,
Effect.gen(function* () {
const locations = yield* LocationServiceMap
return SessionModelValidation.of({
validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) {
yield* Effect.gen(function* () {
yield* (yield* PluginBoot.Service).wait()
const catalog = yield* Catalog.Service
const model = (yield* catalog.model.available()).find(
(model) => model.providerID === input.model.providerID && model.id === input.model.id,
)
if (!model)
return yield* new Session.ModelUnavailableError({
providerID: input.model.providerID,
modelID: input.model.id,
})
if (
input.model.variant !== undefined &&
input.model.variant !== "default" &&
!model.variants.some((variant) => variant.id === input.model.variant)
)
return yield* new Session.VariantUnavailableError({
providerID: input.model.providerID,
modelID: input.model.id,
variant: input.model.variant,
})
}).pipe(Effect.provide(locations.get(input.location)))
}),
})
}),
)
const SessionsLayer = Layer.merge(
SessionV2.layer.pipe(
Layer.provide(SessionProjector.layer), Layer.provide(SessionProjector.layer),
Layer.provide(SessionExecutionLocal.layer), Layer.provide(SessionExecutionLocal.layer),
Layer.provide(SessionStore.layer), Layer.provide(SessionStore.layer),
Layer.provide(EventV2.layer), Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer), Layer.provide(ProjectV2.defaultLayer),
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
Layer.orDie, Layer.orDie,
) ),
SessionModelValidationLayer,
).pipe(Layer.provide(LocationServicesLayer))
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const sessions = yield* SessionV2.Service const sessions = yield* SessionV2.Service
const tools = yield* ApplicationTools.Service const tools = yield* ApplicationTools.Service
const validation = yield* SessionModelValidation
return Service.of({ return Service.of({
tools: { register: tools.register }, tools: { register: tools.register },
sessions: { sessions: {
@@ -49,7 +98,11 @@ export const layer = Layer.effect(
}), }),
get: sessions.get, get: sessions.get,
list: sessions.list, list: sessions.list,
switchModel: sessions.switchModel, switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) {
const session = yield* sessions.get(input.sessionID)
yield* validation.validate({ ...input, location: session.location })
yield* sessions.switchModel(input)
}),
interrupt: sessions.interrupt, interrupt: sessions.interrupt,
prompt: (input) => prompt: (input) =>
sessions.prompt({ sessions.prompt({
@@ -71,6 +124,6 @@ export const layer = Layer.effect(
}, },
}) })
}), }),
).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer))) ).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer)))
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. // TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.
+22 -2
View File
@@ -1,6 +1,7 @@
export * as Session from "./session" export * as Session from "./session"
import { Effect, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { ModelV2 } from "../model"
import { SessionV2 } from "../session" import { SessionV2 } from "../session"
import { MessageDecodeError } from "../session/error" import { MessageDecodeError } from "../session/error"
import { SessionEvent } from "../session/event" import { SessionEvent } from "../session/event"
@@ -40,6 +41,23 @@ export type NotFoundError = SessionV2.NotFoundError
export const PromptConflictError = SessionV2.PromptConflictError export const PromptConflictError = SessionV2.PromptConflictError
export type PromptConflictError = SessionV2.PromptConflictError export type PromptConflictError = SessionV2.PromptConflictError
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"Session.ModelUnavailableError",
{
providerID: Model.Ref.fields.providerID,
modelID: Model.Ref.fields.id,
},
) {}
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"Session.VariantUnavailableError",
{
providerID: Model.Ref.fields.providerID,
modelID: Model.Ref.fields.id,
variant: ModelV2.VariantID,
},
) {}
export { MessageDecodeError } export { MessageDecodeError }
export interface CreateInput { export interface CreateInput {
@@ -86,7 +104,9 @@ export interface Interface {
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError> readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<Info[]> readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError> readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError> readonly switchModel: (
input: SwitchModelInput,
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */ /** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
readonly interrupt: (sessionID: ID) => Effect.Effect<void> readonly interrupt: (sessionID: ID) => Effect.Effect<void>
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError> readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
+41 -21
View File
@@ -1,8 +1,8 @@
export * as SessionV2 from "./session" export * as SessionV2 from "./session"
export * from "./session/schema" export * from "./session/schema"
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect" import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm" import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project" import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace" import { WorkspaceV2 } from "./workspace"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
@@ -25,6 +25,7 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index" import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store" import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution" import { SessionExecution } from "./session/execution"
import { logFailure } from "./session/logging"
import { MessageDecodeError } from "./session/error" import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event" import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input" import { SessionInput } from "./session/input"
@@ -125,7 +126,10 @@ export interface Interface {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
after?: number after?: number
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError> }) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError> readonly switchAgent: (input: {
sessionID: SessionSchema.ID
agent: string
}) => Effect.Effect<void, OperationUnavailableError>
readonly switchModel: (input: { readonly switchModel: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
model: ModelV2.Ref model: ModelV2.Ref
@@ -167,6 +171,20 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const scope = yield* Effect.scope
const enqueueWake = (admitted: SessionInput.Admitted) =>
execution.wake(admitted.sessionID, admitted.admittedSeq).pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: logFailure("Failed to wake Session", admitted.sessionID, cause),
),
Effect.ignore,
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
const decode = (row: typeof SessionMessageTable.$inferSelect) => const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError( Effect.mapError(
@@ -248,13 +266,6 @@ export const layer = Layer.effect(
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("subpath" in input && input.subpath)
conditions.push(
or(
eq(SessionTable.path, input.subpath),
and(gte(SessionTable.path, `${input.subpath}/`), lt(SessionTable.path, `${input.subpath}0`)),
)!,
)
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.anchor) { if (input.anchor) {
conditions.push( conditions.push(
@@ -334,6 +345,10 @@ export const layer = Layer.effect(
Effect.uninterruptible( Effect.uninterruptible(
Effect.gen(function* () { Effect.gen(function* () {
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
if (input.resume !== false) yield* enqueueWake(admitted)
return admitted
}, Effect.uninterruptible)
const messageID = input.id ?? SessionMessage.ID.create() const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer" const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
@@ -351,8 +366,7 @@ export const layer = Layer.effect(
) )
if (!SessionInput.equivalent(admitted, expected)) if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) yield* execution.wake(admitted.sessionID) return yield* returnPrompt(admitted)
return admitted
}), }),
), ),
), ),
@@ -362,14 +376,8 @@ export const layer = Layer.effect(
skill: Effect.fn("V2Session.skill")(function* () { skill: Effect.fn("V2Session.skill")(function* () {
return yield* new OperationUnavailableError({ operation: "skill" }) return yield* new OperationUnavailableError({ operation: "skill" })
}), }),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
yield* result.get(input.sessionID) return yield* new OperationUnavailableError({ operation: "switchAgent" })
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) { switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
@@ -393,7 +401,19 @@ export const layer = Layer.effect(
yield* execution.resume(sessionID) yield* execution.resume(sessionID)
}), }),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) => interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)), Effect.uninterruptible(
Effect.gen(function* () {
const session = yield* store.get(sessionID)
if (!session) return yield* execution.interrupt(sessionID)
const event = yield* events.publish(SessionEvent.InterruptRequested, {
sessionID,
timestamp: yield* DateTime.now,
})
if (event.durable === undefined)
return yield* Effect.die("Interrupt request event is missing aggregate sequence")
yield* execution.interrupt(sessionID, event.durable.seq)
}),
),
), ),
}) })
+202 -33
View File
@@ -1,31 +1,54 @@
export * as SessionContextEpoch from "./context-epoch" export * as SessionContextEpoch from "./context-epoch"
import { eq } from "drizzle-orm" import { and, eq, isNull, lt, or, sql } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect" import { DateTime, Effect, Schema } from "effect"
import { AgentV2 } from "../agent"
import type { Database } from "../database/database" import type { Database } from "../database/database"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { Location } from "../location"
import { SystemContext } from "../system-context/index" import { SystemContext } from "../system-context/index"
import { ContextSnapshotDecodeError } from "./error" import { ContextSnapshotDecodeError } from "./error"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionInput } from "./input" import { SessionInput } from "./input"
import { SessionMessageID } from "./message-id" import { SessionMessageID } from "./message-id"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { SessionContextEpochTable } from "./sql" import { SessionContextEpochTable, SessionTable } from "./sql"
type DatabaseService = Database.Interface["db"] type DatabaseService = Database.Interface["db"]
class RevisionMismatch extends Error {}
class LocationMismatch extends Error {}
export class AgentMismatch extends Error {}
export class AgentReplacementBlocked extends Schema.TaggedErrorClass<AgentReplacementBlocked>()(
"SessionContextEpoch.AgentReplacementBlocked",
{ sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID },
) {}
const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect.Effect<A, E> =>
attempt().pipe(
Effect.catchDefect((defect) =>
defect instanceof RevisionMismatch
? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt)))
: Effect.die(defect),
),
)
interface Prepared { interface Prepared {
readonly baseline: string readonly baseline: string
readonly baselineSeq: number readonly baselineSeq: number
readonly revision: number
} }
export function initialize( export function initialize(
db: DatabaseService, db: DatabaseService,
context: Effect.Effect<SystemContext.SystemContext>, context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> { ): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize")) return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe(
Effect.withSpan("SessionContextEpoch.initialize"),
)
} }
export function prepare( export function prepare(
@@ -33,8 +56,12 @@ export function prepare(
events: EventV2.Interface, events: EventV2.Interface,
context: Effect.Effect<SystemContext.SystemContext>, context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> { location: Location.Ref,
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare")) agent: AgentV2.ID,
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError | AgentReplacementBlocked> {
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe(
Effect.withSpan("SessionContextEpoch.prepare"),
)
} }
const prepareOnce = Effect.fnUntraced(function* ( const prepareOnce = Effect.fnUntraced(function* (
@@ -42,50 +69,57 @@ const prepareOnce = Effect.fnUntraced(function* (
events: EventV2.Interface, events: EventV2.Interface,
context: Effect.Effect<SystemContext.SystemContext>, context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
) { ) {
const [value, stored, compaction] = yield* Effect.all( const [value, stored] = yield* Effect.all([context, find(db, sessionID)], { concurrency: "unbounded" })
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
{ concurrency: "unbounded" },
)
if (!stored) { if (!stored) {
const generation = yield* SystemContext.initialize(value) const generation = yield* SystemContext.initialize(value)
const baselineSeq = yield* insert(db, sessionID, generation) const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
return { baseline: generation.baseline, baselineSeq } return { baseline: generation.baseline, baselineSeq, revision: 0 }
} }
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe( const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })), Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
) )
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined const replacingAgent = stored.agent !== agent
const result = replacementSeq const result =
? yield* SystemContext.replace(value, snapshot) stored.replacement_seq === null && !replacingAgent
: yield* SystemContext.reconcile(value, snapshot) ? yield* SystemContext.reconcile(value, snapshot)
: yield* SystemContext.replace(value, snapshot)
if (result._tag === "ReplacementBlocked" && replacingAgent) {
yield* fence(db, sessionID, agent, stored.revision)
return yield* new AgentReplacementBlocked({ sessionID, previous: stored.agent, current: agent })
}
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") { if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } yield* fence(db, sessionID, agent, stored.revision)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision }
} }
if (result._tag === "ReplacementReady") { if (result._tag === "ReplacementReady") {
const baselineSeq = replacementSeq ?? (yield* SessionInput.latestSeq(db, sessionID)) const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
yield* replace(db, sessionID, baselineSeq, result.generation) yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation)
return { baseline: result.generation.baseline, baselineSeq } return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 }
} }
yield* events.publish( yield* events.publish(
SessionEvent.ContextUpdated, SessionEvent.ContextUpdated,
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text }, { sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) }, { commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) },
) )
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision + 1 }
}) })
const initializeOnce = Effect.fnUntraced(function* ( const initializeOnce = Effect.fnUntraced(function* (
db: DatabaseService, db: DatabaseService,
context: Effect.Effect<SystemContext.SystemContext>, context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
) { ) {
if (yield* exists(db, sessionID)) return if (yield* exists(db, sessionID)) return
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize)) const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
const baselineSeq = yield* insert(db, sessionID, generation) const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
return { baseline: generation.baseline, baselineSeq } return { baseline: generation.baseline, baselineSeq, revision: 0 }
}) })
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
@@ -108,6 +142,39 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic
.pipe(Effect.orDie) .pipe(Effect.orDie)
}) })
const requireAgentSelection = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
agent: AgentV2.ID,
) {
const selected = yield* db
.select({ agent: SessionTable.agent })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!selected || (selected.agent !== null && selected.agent !== agent)) return yield* Effect.die(new AgentMismatch())
})
export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
seq: number,
) {
return yield* db
.update(SessionContextEpochTable)
.set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
lt(SessionContextEpochTable.baseline_seq, seq),
or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)),
),
)
.run()
.pipe(Effect.orDie)
})
export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
@@ -122,53 +189,155 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
const insert = Effect.fnUntraced(function* ( const insert = Effect.fnUntraced(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
generation: SystemContext.Generation, generation: SystemContext.Generation,
) { ) {
return yield* db
.transaction(
() =>
Effect.gen(function* () {
const placed = yield* db
.select({ agent: SessionTable.agent })
.from(SessionTable)
.where(
and(
eq(SessionTable.id, sessionID),
eq(SessionTable.directory, location.directory),
location.workspaceID === undefined
? isNull(SessionTable.workspace_id)
: eq(SessionTable.workspace_id, location.workspaceID),
),
)
.get()
.pipe(Effect.orDie)
if (!placed) return yield* Effect.die(new LocationMismatch())
if (placed.agent !== null && placed.agent !== agent) return yield* Effect.die(new AgentMismatch())
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
yield* db yield* db
.insert(SessionContextEpochTable) .insert(SessionContextEpochTable)
.values({ .values({
session_id: sessionID, session_id: sessionID,
baseline: generation.baseline, baseline: generation.baseline,
agent,
snapshot: generation.snapshot, snapshot: generation.snapshot,
baseline_seq: baselineSeq, baseline_seq: baselineSeq,
revision: 0,
}) })
.run() .onConflictDoNothing()
.pipe(Effect.orDie) .returning({ sessionID: SessionContextEpochTable.session_id })
.get()
.pipe(
Effect.orDie,
Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))),
)
return baselineSeq return baselineSeq
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
}) })
const replace = Effect.fnUntraced(function* ( const replace = Effect.fnUntraced(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
agent: AgentV2.ID,
expectedRevision: number,
baselineSeq: number, baselineSeq: number,
generation: SystemContext.Generation, generation: SystemContext.Generation,
) { ) {
yield* db
.transaction(
() =>
Effect.gen(function* () {
yield* requireAgentSelection(db, sessionID, agent)
const updated = yield* db const updated = yield* db
.update(SessionContextEpochTable) .update(SessionContextEpochTable)
.set({ .set({
baseline: generation.baseline, baseline: generation.baseline,
agent,
snapshot: generation.snapshot, snapshot: generation.snapshot,
baseline_seq: baselineSeq, baseline_seq: baselineSeq,
replacement_seq: null,
revision: expectedRevision + 1,
}) })
.where(eq(SessionContextEpochTable.session_id, sessionID)) .where(
.returning({ sessionID: SessionContextEpochTable.session_id }) and(
eq(SessionContextEpochTable.session_id, sessionID),
eq(SessionContextEpochTable.revision, expectedRevision),
),
)
.returning({ revision: SessionContextEpochTable.revision })
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Context Epoch not found") if (!updated) return yield* Effect.die(new RevisionMismatch())
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
const fence = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
agent: AgentV2.ID,
expectedRevision: number,
) {
const current = yield* db
.select({ selected: SessionTable.agent, revision: SessionContextEpochTable.revision })
.from(SessionContextEpochTable)
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
if (!current || (current.selected !== null && current.selected !== agent))
return yield* Effect.die(new AgentMismatch())
if (current.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch())
})
export const current = Effect.fn("SessionContextEpoch.current")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
agent: AgentV2.ID,
revision: number,
) {
const value = yield* db
.select({
agent: SessionContextEpochTable.agent,
selected: SessionTable.agent,
revision: SessionContextEpochTable.revision,
})
.from(SessionContextEpochTable)
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
return (
value !== undefined &&
value.agent === agent &&
(value.selected === null || value.selected === agent) &&
value.revision === revision
)
}) })
const advance = Effect.fnUntraced(function* ( const advance = Effect.fnUntraced(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
expectedRevision: number,
snapshot: SystemContext.Snapshot, snapshot: SystemContext.Snapshot,
) { ) {
const updated = yield* db const updated = yield* db
.update(SessionContextEpochTable) .update(SessionContextEpochTable)
.set({ snapshot }) .set({ snapshot, revision: expectedRevision + 1 })
.where(eq(SessionContextEpochTable.session_id, sessionID)) .where(
.returning({ sessionID: SessionContextEpochTable.session_id }) and(
eq(SessionContextEpochTable.session_id, sessionID),
eq(SessionContextEpochTable.revision, expectedRevision),
isNull(SessionContextEpochTable.replacement_seq),
),
)
.returning({ revision: SessionContextEpochTable.revision })
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Context Epoch not found") if (!updated) return yield* Effect.die(new RevisionMismatch())
}) })
+8
View File
@@ -118,6 +118,13 @@ export namespace PromptLifecycle {
export type Promoted = typeof Promoted.Type export type Promoted = typeof Promoted.Type
} }
export const InterruptRequested = EventV2.define({
type: "session.next.interrupt.requested",
...options,
schema: Base,
})
export type InterruptRequested = typeof InterruptRequested.Type
export const ContextUpdated = EventV2.define({ export const ContextUpdated = EventV2.define({
type: "session.next.context.updated", type: "session.next.context.updated",
...options, ...options,
@@ -468,6 +475,7 @@ const DurableDefinitions = [
Prompted, Prompted,
PromptLifecycle.Admitted, PromptLifecycle.Admitted,
PromptLifecycle.Promoted, PromptLifecycle.Promoted,
InterruptRequested,
ContextUpdated, ContextUpdated,
Synthetic, Synthetic,
Shell.Started, Shell.Started,
+4 -4
View File
@@ -5,12 +5,12 @@ import { SessionRunner } from "./runner/index"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
export interface Interface { export interface Interface {
/** Starts execution while idle or joins the active execution. */ /** Explicitly drain one Session, making at least one provider attempt. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */ /** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void, SessionRunner.RunError>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */ /** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void>
} }
/** Routes execution from a Session ID to the runner owned by that Session's Location. */ /** Routes execution from a Session ID to the runner owned by that Session's Location. */
+6 -9
View File
@@ -1,10 +1,11 @@
import { Cause, Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { LocationServiceMap } from "../../location-layer" import { LocationServiceMap } from "../../location-layer"
import { SessionRunCoordinator } from "../run-coordinator" import { SessionRunCoordinator } from "../run-coordinator"
import { SessionRunner } from "../runner" import { SessionRunner } from "../runner"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import { SessionStore } from "../store" import { SessionStore } from "../store"
import { SessionExecution } from "../execution" import { SessionExecution } from "../execution"
import { logFailure } from "../logging"
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */ /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
export const layer = Layer.effect( export const layer = Layer.effect(
@@ -12,19 +13,15 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap const locations = yield* LocationServiceMap
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({ const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) {
const session = yield* store.get(sessionID) const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe( return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe(
Effect.provide(locations.get(session.location)), Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
) )
}), }),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
}) })
return SessionExecution.Service.of({ return SessionExecution.Service.of({
+2 -2
View File
@@ -10,9 +10,9 @@ type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Message) const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db return yield* db
.select({ seq: SessionMessageTable.seq }) .select()
.from(SessionMessageTable) .from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.seq)) .orderBy(desc(SessionMessageTable.seq))
+8
View File
@@ -0,0 +1,8 @@
import { Cause, Effect } from "effect"
import { SessionSchema } from "./schema"
export const logFailure = (
message: "Failed to drain Session" | "Failed to wake Session",
sessionID: SessionSchema.ID,
cause: Cause.Cause<unknown>,
) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID }))
@@ -138,6 +138,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}, },
"session.next.prompt.admitted": () => Effect.void, "session.next.prompt.admitted": () => Effect.void,
"session.next.prompt.promoted": () => Effect.void, "session.next.prompt.promoted": () => Effect.void,
"session.next.interrupt.requested": () => Effect.void,
"session.next.context.updated": (event) => "session.next.context.updated": (event) =>
adapter.appendMessage( adapter.appendMessage(
new SessionMessage.System({ new SessionMessage.System({
+21 -6
View File
@@ -329,14 +329,19 @@ export const layer = Layer.effectDiscard(
if (next) yield* applyUsage(db, sessionID, next) if (next) yield* applyUsage(db, sessionID, next)
}), }),
) )
yield* events.project(SessionEvent.AgentSwitched, (event) => yield* events.project(SessionEvent.AgentSwitched, (event) => {
db if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
return db
.update(SessionTable) .update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))), .pipe(
Effect.orDie,
Effect.andThen(run(db, event)),
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)),
) )
})
yield* events.project(SessionEvent.ModelSwitched, (event) => yield* events.project(SessionEvent.ModelSwitched, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* db yield* db
@@ -346,6 +351,8 @@ export const layer = Layer.effectDiscard(
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* run(db, event) yield* run(db, event)
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)
}), }),
) )
yield* events.project(SessionEvent.Prompted, (event) => yield* events.project(SessionEvent.Prompted, (event) =>
@@ -399,6 +406,8 @@ export const layer = Layer.effectDiscard(
) )
}), }),
) )
yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
// TODO: Reconstruct context epoch replacement state during replay without adding replay state to every EventV2 payload.
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
@@ -417,9 +426,15 @@ export const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event)) // yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => yield* events.project(SessionEvent.Compaction.Ended, (event) => {
event.durable?.version === 1 ? Effect.void : run(db, event), if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
) if (event.durable.version === 1) return Effect.void
const seq = event.durable.seq
return Effect.gen(function* () {
yield* run(db, event)
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
})
})
}), }),
) )
+244 -62
View File
@@ -1,43 +1,106 @@
export * as SessionRunCoordinator from "./run-coordinator" export * as SessionRunCoordinator from "./run-coordinator"
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect" import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
import { SessionRunner } from "./runner"
import { logFailure } from "./logging"
import { SessionSchema } from "./schema"
/** Serializes execution for each key while allowing different keys to run concurrently. */ export type Mode = "run" | "wake"
export interface Coordinator<Key, E> {
/** Starts execution while idle or joins the active execution. */ /** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */
readonly run: (key: Key) => Effect.Effect<void, E> type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number }
/** Registers one coalesced follow-up after newly recorded work. */
readonly wake: (key: Key) => Effect.Effect<void> /**
/** Stops active execution and waits for its cleanup. */ * Runs at most one drain chain per key while allowing different keys to drain concurrently.
readonly interrupt: (key: Key) => Effect.Effect<void> *
* For each key:
*
* idle --run/wake--> draining --run/wake--> draining + one coalesced rerun --> idle
*
* `run` is an explicit drain request. It starts a chain or joins the current chain and
* upgrades a pending follow-up so the caller receives explicit-run semantics.
*
* `wake` reports that durable work may now be available. It starts a chain while idle or
* requests one coalesced follow-up while draining. Repeated wakes collapse together.
*
* `interrupt` stops the current ownership chain. Advisory wakes from before the interrupt
* boundary are suppressed; advisory wakes after the boundary run after cleanup.
*/
export interface Coordinator<Key, A, E> {
/** Starts or joins one explicit drain generation. */
readonly run: (key: Key) => Effect.Effect<A, E>
/** Coalesces one wake-up after durable work is recorded. */
readonly wake: (key: Key, seq?: number) => Effect.Effect<void>
/** Waits until the current ownership chain settles. */
readonly awaitIdle: (key: Key) => Effect.Effect<void, E>
/** Interrupts the active ownership chain without automatically draining pending wakes. */
readonly interrupt: (key: Key, seq?: number) => Effect.Effect<void>
} }
type Entry<E> = { /** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */
readonly done: Deferred.Deferred<void, E> type Entry<A, E> = {
readonly done: Deferred.Deferred<A, E>
readonly settled: Deferred.Deferred<Exit.Exit<A, E>>
current: Demand
pending?: Demand
explicitWaiter?: Deferred.Deferred<A, E>
interruptSeq?: number
owner?: Fiber.Fiber<void, never> owner?: Fiber.Fiber<void, never>
pendingWake: boolean
stopping: boolean stopping: boolean
} }
export const make = <Key, E>(options: { /** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E> const coalesce = (left: Demand | undefined, right: Demand): Demand => {
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> => if (left?._tag === "run" || right._tag === "run") return { _tag: "run" }
Effect.gen(function* () { return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) }
const active = new Map<Key, Entry<E>>() }
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const makeEntry = (): Entry<E> => ({ const maxSeq = (left: number | undefined, right: number | undefined) => {
done: Deferred.makeUnsafe<void, E>(), if (left === undefined) return right
pendingWake: false, if (right === undefined) return left
return Math.max(left, right)
}
/** Constructs a scoped coordinator. Every in-memory transition is synchronous. */
export const make = <Key, A, E>(options: {
readonly drain: (key: Key, mode: Mode) => Effect.Effect<A, E>
readonly onFailure?: (key: Key, cause: Cause.Cause<E>) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, A, E>, never, Scope.Scope> =>
Effect.gen(function* () {
const active = new Map<Key, Entry<A, E>>()
const interruptSeq = new Map<Key, number>()
const report = yield* FiberSet.makeRuntime<never, void, never>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const shutdown = Deferred.makeUnsafe<void>()
let closed = false
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed = true
Deferred.doneUnsafe(shutdown, Effect.void)
active.clear()
interruptSeq.clear()
}),
)
const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred<A, E>): Entry<A, E> => ({
done: Deferred.makeUnsafe<A, E>(),
settled: Deferred.makeUnsafe<Exit.Exit<A, E>>(),
current,
explicitWaiter,
stopping: false, stopping: false,
}) })
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => { const start = (key: Key, entry: Entry<A, E>, demand: Demand, successor = false) => {
const ready = Deferred.makeUnsafe<void>() const ready = Deferred.makeUnsafe<void>()
const drain = Effect.suspend(() => options.drain(key, demand._tag))
// Initial work retains immediate-start behavior but cannot run before ownership is published.
// Observer-started successors yield once so synchronous drains cannot recurse on the JS stack.
const owner = fork( const owner = fork(
(successor ? Effect.yieldNow : Deferred.await(ready)).pipe( (successor
Effect.andThen(Effect.suspend(() => options.drain(key, force))), ? Effect.yieldNow.pipe(Effect.andThen(drain))
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))), : Deferred.await(ready).pipe(Effect.andThen(drain))
).pipe(
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))),
Effect.exit, Effect.exit,
Effect.asVoid, Effect.asVoid,
), ),
@@ -46,57 +109,176 @@ export const make = <Key, E>(options: {
if (!successor) Deferred.doneUnsafe(ready, Effect.void) if (!successor) Deferred.doneUnsafe(ready, Effect.void)
} }
const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => { const settle = (key: Key, entry: Entry<A, E>, demand: Demand, exit: Exit.Exit<A, E>) => {
if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) { if (closed) {
entry.pendingWake = false
start(key, entry, false, true)
return
}
const successor = entry.pendingWake ? makeEntry() : undefined
if (successor === undefined) active.delete(key)
else {
active.set(key, successor)
start(key, successor, false, true)
}
Deferred.doneUnsafe(entry.done, exit) Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return
} }
if (demand._tag === "run" && entry.explicitWaiter !== undefined) {
const run = (key: Key): Effect.Effect<void, E> => Deferred.doneUnsafe(entry.explicitWaiter, exit)
Effect.uninterruptibleMask((restore) => { entry.explicitWaiter = undefined
const entry = active.get(key)
if (entry !== undefined) {
if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
return restore(Deferred.await(entry.done))
} }
if (entry.stopping && demand._tag === "wake" && entry.explicitWaiter !== undefined) {
const next = makeEntry() Deferred.doneUnsafe(entry.explicitWaiter, exit)
active.set(key, next) entry.explicitWaiter = undefined
start(key, next, true) }
return restore(Deferred.await(next.done)) if (active.get(key) !== entry) {
}) Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
const wake = (key: Key) => return
Effect.sync(() => { }
const entry = active.get(key) if (exit._tag === "Success" && !entry.stopping) {
if (entry !== undefined) { if (entry.pending !== undefined) {
entry.pendingWake = true const pending = entry.pending
entry.pending = undefined
entry.current = pending
start(key, entry, pending, true)
return
}
active.delete(key)
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return return
} }
const next = makeEntry() const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined
if (successor === undefined) active.delete(key)
else active.set(key, successor)
if (successor !== undefined) start(key, successor, successor.current, true)
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
if (
exit._tag === "Failure" &&
!(entry.stopping && Cause.hasInterruptsOnly(exit.cause)) &&
demand._tag === "wake" &&
options.onFailure !== undefined
) {
report(Effect.suspend(() => options.onFailure!(key, exit.cause)))
}
}
const wake = (key: Key, seq?: number) =>
Effect.sync(() => {
if (closed) return
if (!isAfterInterrupt(key, seq)) return
const entry = active.get(key)
if (entry !== undefined) {
if (!acceptsWake(entry, seq)) return
entry.pending = coalesce(entry.pending, { _tag: "wake", seq })
return
}
const next = makeEntry({ _tag: "wake", seq })
active.set(key, next) active.set(key, next)
start(key, next, false) start(key, next, next.current)
}) })
const interrupt = (key: Key): Effect.Effect<void> => const awaitIdle = (key: Key): Effect.Effect<void, E> =>
Effect.gen(function* () {
let firstFailure: Cause.Cause<E> | undefined
while (!closed) {
const entry = active.get(key)
if (entry === undefined) break
const exit = yield* Effect.raceFirst(
Deferred.await(entry.settled),
Deferred.await(shutdown).pipe(Effect.as(Exit.void)),
)
if (closed) break
if (exit._tag === "Failure" && firstFailure === undefined) firstFailure = exit.cause
}
if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure)
})
const interrupt = (key: Key, seq?: number): Effect.Effect<void> =>
Effect.suspend(() => { Effect.suspend(() => {
const entry = active.get(key) const entry = active.get(key)
const latest = interruptSeq.get(key)
if (seq !== undefined && latest !== undefined && seq <= latest)
return entry?.stopping && entry.owner !== undefined ? Fiber.interrupt(entry.owner) : Effect.void
if (seq !== undefined) interruptSeq.set(key, seq)
if (entry?.owner === undefined) return Effect.void if (entry?.owner === undefined) return Effect.void
if (
seq !== undefined &&
entry.current._tag === "wake" &&
entry.current.seq !== undefined &&
entry.current.seq > seq
)
return Effect.void
if (entry.stopping) {
entry.interruptSeq = maxSeq(entry.interruptSeq, seq)
suppressPendingAtOrBefore(entry, seq)
return Fiber.interrupt(entry.owner)
}
entry.stopping = true entry.stopping = true
entry.pendingWake = false entry.interruptSeq = seq
suppressPendingAtOrBefore(entry, seq)
return Fiber.interrupt(entry.owner) return Fiber.interrupt(entry.owner)
}) })
return { run, wake, interrupt } return { run, wake, awaitIdle, interrupt }
function run(key: Key): Effect.Effect<A, E> {
return Effect.uninterruptibleMask((restore) => {
if (closed) return Effect.interrupt
const entry = active.get(key)
if (entry !== undefined) {
if (entry.stopping) {
return restore(Deferred.await(entry.settled).pipe(Effect.andThen(run(key))))
}
if (entry.current._tag === "wake") {
entry.pending = coalesce(entry.pending, { _tag: "run" })
entry.explicitWaiter ??= Deferred.makeUnsafe<A, E>()
return restore(awaitRun(entry.explicitWaiter))
}
return restore(awaitRun(entry.done))
}
const next = makeEntry({ _tag: "run" })
active.set(key, next)
start(key, next, next.current)
return restore(awaitRun(next.done))
}) })
}
function awaitRun(done: Deferred.Deferred<A, E>): Effect.Effect<A, E> {
return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
}
function acceptsWake(entry: Entry<A, E>, seq: number | undefined) {
return !entry.stopping || (entry.interruptSeq !== undefined && seq !== undefined && seq > entry.interruptSeq)
}
function isAfterInterrupt(key: Key, seq: number | undefined) {
const latest = interruptSeq.get(key)
return latest === undefined || (seq !== undefined && seq > latest)
}
function suppressPendingAtOrBefore(entry: Entry<A, E>, seq: number | undefined) {
if (
entry.pending?._tag === "wake" &&
seq !== undefined &&
entry.pending.seq !== undefined &&
entry.pending.seq > seq
)
return
entry.pending = undefined
}
})
export interface Interface extends Coordinator<SessionSchema.ID, void, SessionRunner.RunError> {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunCoordinator") {}
export const layer = Layer.effect(
Service,
SessionRunner.Service.pipe(
Effect.flatMap((runner) =>
make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
}),
),
Effect.map(Service.of),
),
)
+3 -1
View File
@@ -6,6 +6,7 @@ import { SessionSchema } from "../schema"
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model" import { SessionRunnerModel } from "./model"
import type { SystemContext } from "../../system-context/index" import type { SystemContext } from "../../system-context/index"
import type { SessionContextEpoch } from "../context-epoch"
import type { ToolOutputStore } from "../../tool-output-store" import type { ToolOutputStore } from "../../tool-output-store"
export type RunError = export type RunError =
@@ -14,6 +15,7 @@ export type RunError =
| MessageDecodeError | MessageDecodeError
| ContextSnapshotDecodeError | ContextSnapshotDecodeError
| SystemContext.InitializationBlocked | SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked
| ToolOutputStore.Error | ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */ /** Runs one local continuation from already-recorded Session history. */
@@ -21,7 +23,7 @@ export interface Interface {
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */ /** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */
readonly run: (input: { readonly run: (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force?: boolean
}) => Effect.Effect<void, RunError> }) => Effect.Effect<void, RunError>
} }
+48 -15
View File
@@ -8,7 +8,7 @@ import {
isContextOverflowFailure, isContextOverflowFailure,
type ProviderErrorEvent, type ProviderErrorEvent,
} from "@opencode-ai/llm" } from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent" import { AgentV2 } from "../../agent"
import { Config } from "../../config" import { Config } from "../../config"
import { Database } from "../../database/database" import { Database } from "../../database/database"
@@ -141,8 +141,8 @@ export const layer = Layer.effect(
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
type TurnTransition = type TurnTransition =
// Automatic compaction completed; rebuild the request from compacted history. // Request preparation observed a concurrent Session change and must restart from durable state.
| { readonly _tag: "ContinueAfterCompaction" } | { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery }
// Overflow compaction completed; rebuild once through the path without overflow recovery. // Overflow compaction completed; rebuild once through the path without overflow recovery.
| { readonly _tag: "ContinueAfterOverflowCompaction" } | { readonly _tag: "ContinueAfterOverflowCompaction" }
@@ -152,11 +152,20 @@ export const layer = Layer.effect(
} }
} }
const continueAfterCompaction = new TurnTransitionError({ _tag: "ContinueAfterCompaction" }) const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) =>
new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion })
const continueAfterOverflowCompaction = new TurnTransitionError({ const continueAfterOverflowCompaction = new TurnTransitionError({
_tag: "ContinueAfterOverflowCompaction", _tag: "ContinueAfterOverflowCompaction",
}) })
const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) =>
Effect.catchDefect((defect) =>
defect instanceof SessionContextEpoch.AgentMismatch
? Effect.die(rebuildPreparedTurn(promotion))
: Effect.die(defect),
)
const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref))
const loadSystemContext = (agent: AgentV2.Selection) => const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], { Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
concurrency: "unbounded", concurrency: "unbounded",
@@ -172,7 +181,13 @@ export const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt return yield* Effect.interrupt
const agent = yield* agents.select(session.agent) const agent = yield* agents.select(session.agent)
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id) const initialized = yield* SessionContextEpoch.initialize(
db,
loadSystemContext(agent),
session.id,
session.location,
agent.id,
).pipe(retryAgentMismatch(promotion))
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>() const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false let needsContinuation = false
if (promotion) { if (promotion) {
@@ -184,7 +199,18 @@ export const layer = Layer.effect(
} }
} }
const system = const system =
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id)) initialized ??
(yield* SessionContextEpoch.prepare(
db,
events,
loadSystemContext(agent),
session.id,
session.location,
agent.id,
).pipe(retryAgentMismatch(undefined)))
const current = yield* getSession(sessionID)
if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model))
return yield* Effect.die(rebuildPreparedTurn())
const model = yield* models.resolve(session) const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message) const context = entries.map((entry) => entry.message)
@@ -202,7 +228,7 @@ export const layer = Layer.effect(
toolChoice: isLastStep ? "none" : undefined, toolChoice: isLastStep ? "none" : undefined,
}) })
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(continueAfterCompaction) return yield* Effect.die(rebuildPreparedTurn())
const publisher = createLLMEventPublisher(events, { const publisher = createLLMEventPublisher(events, {
sessionID: session.id, sessionID: session.id,
agent: agent.id, agent: agent.id,
@@ -216,6 +242,8 @@ export const layer = Layer.effect(
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) => const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
withPublication(publisher.publish(event, outputPaths)) withPublication(publisher.publish(event, outputPaths))
let overflowFailure: ProviderErrorEvent | undefined let overflowFailure: ProviderErrorEvent | undefined
if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision)))
return yield* Effect.die(rebuildPreparedTurn())
const providerStream = llm.stream(request).pipe( const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) => Stream.runForEach((event) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -277,7 +305,14 @@ export const layer = Layer.effect(
const llmFailure = failure instanceof LLMError ? failure : undefined const llmFailure = failure instanceof LLMError ? failure : undefined
if (llmFailure && !publisher.hasProviderError()) { if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(publisher.failAssistant(llmFailure.reason.message)) yield* withPublication(
events.publish(SessionEvent.Step.Failed, {
sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID: yield* publisher.startAssistant(),
error: { type: "unknown", message: llmFailure.reason.message },
}),
)
} }
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers) if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
@@ -292,8 +327,6 @@ export const layer = Layer.effect(
) { ) {
yield* FiberSet.clear(toolFibers) yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (publisher.hasActiveAssistant())
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
} }
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause) const failure = Cause.squash(settled.cause)
@@ -324,7 +357,7 @@ export const layer = Layer.effect(
if (defect.transition._tag === "ContinueAfterOverflowCompaction") if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
yield* Effect.yieldNow yield* Effect.yieldNow
return yield* runAfterOverflowCompaction(sessionID, undefined, step) return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
}), }),
), ),
) )
@@ -338,7 +371,7 @@ export const layer = Layer.effect(
yield* Effect.yieldNow yield* Effect.yieldNow
if (defect.transition._tag === "ContinueAfterOverflowCompaction") if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* runAfterOverflowCompaction(sessionID, undefined, step) return yield* runAfterOverflowCompaction(sessionID, undefined, step)
return yield* runTurn(sessionID, undefined, step) return yield* runTurn(sessionID, defect.transition.promotion, step)
}), }),
), ),
) )
@@ -346,14 +379,14 @@ export const layer = Layer.effect(
const run = Effect.fn("SessionRunner.run")(function* (input: { const run = Effect.fn("SessionRunner.run")(function* (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force?: boolean
}) { }) {
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (!input.force && !hasSteer && !hasQueue) return if (input.force !== true && !hasSteer && !hasQueue) return
yield* failInterruptedTools(input.sessionID) yield* failInterruptedTools(input.sessionID)
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let openActivity = input.force || hasSteer || hasQueue let openActivity = input.force === true || hasSteer || hasQueue
while (openActivity) { while (openActivity) {
let needsContinuation = true let needsContinuation = true
for (let step = 1; needsContinuation; step++) { for (let step = 1; needsContinuation; step++) {
+12 -58
View File
@@ -5,7 +5,7 @@ import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-message
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/llm/route" import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Option, Schema } from "effect"
import { produce } from "immer" import { produce } from "immer"
import { Catalog } from "../../catalog" import { Catalog } from "../../catalog"
import { Credential } from "../../credential" import { Credential } from "../../credential"
@@ -24,23 +24,6 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
}, },
) {} ) {}
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"SessionRunnerModel.ModelUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
},
) {}
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
variant: ModelV2.VariantID,
},
) {}
export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()( export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
"SessionRunnerModel.UnsupportedApiError", "SessionRunnerModel.UnsupportedApiError",
{ {
@@ -50,7 +33,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
}, },
) {} ) {}
export type Error = ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError export type Error = ModelNotSelectedError | UnsupportedApiError
export interface Interface { export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error> readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
@@ -61,7 +44,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Test or embedding seam for supplying a model resolver directly. */ /** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => { const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
if (credential?.value.type === "key") return Auth.value(credential.value.key) if (credential?.value.type === "key") return Auth.value(credential.value.key)
if (credential?.value.type === "oauth") return Auth.value(credential.value.access) if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
const value = model.request.body.apiKey ?? model.api.settings?.apiKey const value = model.request.body.apiKey ?? model.api.settings?.apiKey
@@ -87,27 +70,13 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
}) })
} }
const withVariant = ( const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined,
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
const variant = model.variants.find((item) => item.id === id) const variant = model.variants.find((item) => item.id === id)
if (!variant && variantID !== undefined && variantID !== "default") if (!variant) return model
return Effect.fail( return produce(model, (draft) => {
new VariantUnavailableError({
providerID: model.providerID,
modelID: model.id,
variant: variantID,
}),
)
return Effect.succeed(
variant
? produce(model, (draft) => {
ModelRequest.assign(draft.request, variant) ModelRequest.assign(draft.request, variant)
}) })
: model,
)
} }
const apiName = (model: ModelV2.Info) => const apiName = (model: ModelV2.Info) =>
@@ -116,7 +85,7 @@ const apiName = (model: ModelV2.Info) =>
export const fromCatalogModel = ( export const fromCatalogModel = (
model: ModelV2.Info, model: ModelV2.Info,
connection?: IntegrationConnection.Info, connection?: IntegrationConnection.Info,
credential?: Credential.Info, credential?: Credential.Stored,
): Effect.Effect<Model, UnsupportedApiError> => { ): Effect.Effect<Model, UnsupportedApiError> => {
const resolved = const resolved =
credential?.value.metadata === undefined credential?.value.metadata === undefined
@@ -155,15 +124,8 @@ export const fromCatalogModel = (
) )
} }
export const resolve = ( export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
session: SessionSchema.Info, fromCatalogModel(withVariant(model, session.model?.variant))
model: ModelV2.Info,
connection?: IntegrationConnection.Info,
credential?: Credential.Info,
) =>
withVariant(model, session.model?.variant).pipe(
Effect.flatMap((model) => fromCatalogModel(model, connection, credential)),
)
export const supported = (model: ModelV2.Info) => export const supported = (model: ModelV2.Info) =>
model.api.type === "aisdk" && model.api.type === "aisdk" &&
@@ -185,22 +147,14 @@ export const locationLayer = Layer.effect(
yield* boot.wait() yield* boot.wait()
const defaultModel = session.model ? undefined : yield* catalog.model.default() const defaultModel = session.model ? undefined : yield* catalog.model.default()
const selected = session.model const selected = session.model
? (yield* catalog.model.available()).find( ? yield* catalog.model.get(session.model.providerID, session.model.id)
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
)
: defaultModel && supported(defaultModel) : defaultModel && supported(defaultModel)
? defaultModel ? defaultModel
: (yield* catalog.model.available()).find(supported) : (yield* catalog.model.available()).find(supported)
if (!selected && session.model)
return yield* new ModelUnavailableError({
providerID: session.model.providerID,
modelID: session.model.id,
})
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID)) const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
return yield* resolve( return yield* fromCatalogModel(
session, withVariant(selected, session.model?.variant),
selected,
connection, connection,
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined, connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
) )
@@ -65,14 +65,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
>() >()
const timestamp = DateTime.now const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined let assistantMessageID: SessionMessage.ID | undefined
let assistantActive = false
let assistantFailed = false
let providerFailed = false let providerFailed = false
const startAssistant = Effect.fnUntraced(function* () { const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID if (assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = SessionMessage.ID.create() assistantMessageID = SessionMessage.ID.create()
assistantActive = true
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
...input, ...input,
assistantMessageID, assistantMessageID,
@@ -193,20 +190,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* flushFragments() yield* flushFragments()
}) })
const failAssistant = Effect.fnUntraced(function* (message: string) {
if (assistantFailed) return
yield* flush()
const assistantMessageID = yield* startAssistant()
assistantActive = false
assistantFailed = true
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID,
error: { type: "unknown", message },
})
})
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* ( const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
message: string, message: string,
hostedOnly = false, hostedOnly = false,
@@ -392,7 +375,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
} }
case "step-finish": case "step-finish":
yield* flush() yield* flush()
assistantActive = false
yield* events.publish(SessionEvent.Step.Ended, { yield* events.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp, timestamp: yield* timestamp,
@@ -406,7 +388,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return return
case "provider-error": case "provider-error":
providerFailed = true providerFailed = true
yield* failAssistant(event.message) yield* flush()
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: yield* startAssistant(),
error: { type: "unknown", message: event.message },
})
return return
} }
}) })
@@ -414,11 +402,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return { return {
publish, publish,
flush, flush,
failAssistant,
failUnsettledTools, failUnsettledTools,
hasActiveAssistant: () => assistantActive,
hasAssistantStarted: () => assistantMessageID !== undefined, hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed, hasProviderError: () => providerFailed,
startAssistant,
assistantMessageID: assistantMessageIDForTool, assistantMessageID: assistantMessageIDForTool,
} }
} }
@@ -82,21 +82,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined) const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
return item.provider?.executed === true && result ? [call, result] : [call] return item.provider?.executed === true && result ? [call, result] : [call]
}) })
const meaningful = content.filter((part) => {
if (part.type === "text") return part.text !== ""
if (part.type !== "reasoning") return true
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
})
const results = message.content const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)) .map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
.filter((message) => message !== undefined) .filter((message) => message !== undefined)
.map(Message.tool) .map(Message.tool)
if (meaningful.length === 0) return results return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results]
return [
Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }),
...results,
]
} }
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] { function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
+3
View File
@@ -170,6 +170,9 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
.primaryKey() .primaryKey()
.references(() => SessionTable.id, { onDelete: "cascade" }), .references(() => SessionTable.id, { onDelete: "cascade" }),
baseline: text().notNull(), baseline: text().notNull(),
agent: text().$type<AgentV2.ID>().notNull().default(AgentV2.defaultID),
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(), snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
baseline_seq: integer().notNull(), baseline_seq: integer().notNull(),
replacement_seq: integer(),
revision: integer().notNull().default(0),
}) })
-30
View File
@@ -1,30 +0,0 @@
import { Effect, Stream } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
export const collectBoundedResponseBody = (
response: HttpClientResponse.HttpClientResponse,
maximumBytes: number,
tooLarge: () => Error,
) =>
Effect.gen(function* () {
const contentLength = response.headers["content-length"]
const parsedSize = contentLength ? Number.parseInt(contentLength, 10) : undefined
const declaredSize =
parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
if (declaredSize !== undefined && declaredSize > maximumBytes) return yield* Effect.fail(tooLarge())
let body = Buffer.allocUnsafe(Math.min(maximumBytes, declaredSize || 64 * 1024))
let size = 0
yield* Stream.runForEach(response.stream, (chunk) => {
if (chunk.byteLength === 0) return Effect.void
if (size + chunk.byteLength > maximumBytes) return Effect.fail(tooLarge())
if (size + chunk.byteLength > body.byteLength) {
const grown = Buffer.allocUnsafe(Math.min(maximumBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)))
body.copy(grown, 0, 0, size)
body = grown
}
body.set(chunk, size)
size += chunk.byteLength
return Effect.void
})
return body.subarray(0, size)
})
+55 -115
View File
@@ -13,61 +13,23 @@ export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const MAX_LINE_LENGTH = 2_000 const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", { export class BinaryFileError extends Error {
resource: Schema.String, constructor(readonly resource: string) {
}) { super(`Cannot read binary file: ${resource}`)
override get message() { this.name = "BinaryFileError"
return `Cannot read binary file: ${this.resource}`
} }
} }
export class MediaIngestLimitError extends Schema.TaggedErrorClass<MediaIngestLimitError>()( export class MediaIngestLimitError extends Error {
"ReadTool.MediaIngestLimitError", constructor(
{ readonly resource: string,
resource: Schema.String, readonly maximumBytes: number,
maximumBytes: Schema.Number,
},
) { ) {
override get message() { super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`)
return `Media exceeds ${this.maximumBytes} byte ingestion limit: ${this.resource}` this.name = "MediaIngestLimitError"
} }
} }
export class MalformedUtf8Error extends Schema.TaggedErrorClass<MalformedUtf8Error>()("ReadTool.MalformedUtf8Error", {
resource: Schema.String,
}) {
override get message() {
return `File is not valid UTF-8: ${this.resource}`
}
}
export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()(
"ReadTool.OffsetOutOfRangeError",
{ offset: Schema.Number },
) {
override get message() {
return `Offset ${this.offset} is out of range`
}
}
export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("ReadTool.PathKindError", {
resource: Schema.String,
expected: Schema.Literals(["a file", "a file or directory"]),
}) {
override get message() {
return `Path is not ${this.expected}: ${this.resource}`
}
}
export type InspectError = FSUtil.Error | PathKindError
export type ReadError =
| FSUtil.Error
| BinaryFileError
| MediaIngestLimitError
| MalformedUtf8Error
| OffsetOutOfRangeError
| PathKindError
export const PageInput = Schema.Struct({ export const PageInput = Schema.Struct({
offset: PositiveInt.pipe(Schema.optional), offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
@@ -90,13 +52,13 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
}) {} }) {}
export interface Interface { export interface Interface {
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError> readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory">
readonly read: ( readonly read: (
path: AbsolutePath, path: AbsolutePath,
resource: string, resource: string,
page?: PageInput, page?: PageInput,
) => Effect.Effect<FileSystem.Content | TextPage, ReadError> ) => Effect.Effect<FileSystem.Content | TextPage>
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error> readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
@@ -149,21 +111,11 @@ const binary = (resource: string, bytes: Uint8Array) => {
} }
return nonPrintable / bytes.length > 0.3 return nonPrintable / bytes.length > 0.3
} }
const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) =>
Effect.try({
try: () => decoder.decode(bytes, { stream: bytes !== undefined }),
catch: (error) => {
if (error instanceof TypeError) return new MalformedUtf8Error({ resource })
throw error
},
})
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) { export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
const info = yield* fs.stat(input) const info = yield* fs.stat(input).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" })) if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
return type return type
}) })
@@ -173,30 +125,32 @@ export const read = Effect.fn("ReadTool.read")(function* (
resource: string, resource: string,
page: PageInput = {}, page: PageInput = {},
) { ) {
const real = yield* fs.realPath(input) const real = yield* fs.realPath(input).pipe(Effect.orDie)
return yield* Effect.scoped( return yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const file = yield* fs.open(real, { flag: "r" }) const file = yield* fs.open(real, { flag: "r" }).pipe(Effect.orDie)
const info = yield* file.stat const info = yield* file.stat.pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" })) if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const first = Option.getOrElse( const first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)), yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)).pipe(Effect.orDie),
() => new Uint8Array(), () => new Uint8Array(),
) )
const mime = imageMime(first) const mime = imageMime(first)
if (mime) { if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES) if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
const chunks = [first] const chunks = [first]
let total = first.length let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) { while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) const chunk = yield* file
.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
.pipe(Effect.orDie)
if (Option.isNone(chunk)) break if (Option.isNone(chunk)) break
chunks.push(chunk.value) chunks.push(chunk.value)
total += chunk.value.length total += chunk.value.length
} }
if (total > MAX_MEDIA_INGEST_BYTES) if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
return { return {
uri: pathToFileURL(real).href, uri: pathToFileURL(real).href,
name: path.basename(real), name: path.basename(real),
@@ -208,19 +162,19 @@ export const read = Effect.fn("ReadTool.read")(function* (
mime, mime,
} }
} }
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || extensions.has(path.extname(resource).toLowerCase())) if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || binary(resource, first))
return yield* Effect.fail(new BinaryFileError({ resource })) return yield* Effect.die(new BinaryFileError(resource))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) { if (!paged) {
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource }))
const decoder = new TextDecoder("utf-8", { fatal: true }) const decoder = new TextDecoder("utf-8", { fatal: true })
const text = [yield* decodeUtf8(resource, decoder, first)] const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))]
while (true) { while (true) {
const chunk = yield* file.readAlloc(64 * 1024) const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break if (Option.isNone(chunk)) break
text.push(yield* decodeChunk(resource, decoder, chunk.value)) if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(resource))
text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true })))
} }
text.push(yield* decodeUtf8(resource, decoder)) text.push(yield* Effect.sync(() => decoder.decode()))
return { return {
uri: pathToFileURL(real).href, uri: pathToFileURL(real).href,
name: path.basename(real), name: path.basename(real),
@@ -237,29 +191,34 @@ export const read = Effect.fn("ReadTool.read")(function* (
let discard = false let discard = false
let line = 1 let line = 1
let bytes = 0 let bytes = 0
let found = false
let truncated = false
let next: number | undefined let next: number | undefined
const append = (input: string) => { const append = (input: string) => {
if (line < offset) { if (line < offset) {
line++ line++
return true return
} }
if (lines.length >= limit || bytes >= MAX_READ_BYTES) { if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line truncated = true
return false next ??= line++
return
} }
found = true
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) { if (bytes + size > MAX_READ_BYTES) {
next = line truncated = true
return false next ??= line++
return
} }
lines.push(text) lines.push(text)
bytes += size bytes += size
line++ line++
return true
} }
const consume = (input: string) => { const consume = (chunk: Uint8Array) => {
let text = input if (chunk.includes(0)) throw new BinaryFileError(resource)
let text = decoder.decode(chunk, { stream: true })
while (true) { while (true) {
const index = text.indexOf("\n") const index = text.indexOf("\n")
if (index === -1) { if (index === -1) {
@@ -276,44 +235,25 @@ export const read = Effect.fn("ReadTool.read")(function* (
pending = "" pending = ""
discard = false discard = false
text = text.slice(index + 1) text = text.slice(index + 1)
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false append(current.endsWith("\r") ? current.slice(0, -1) : current)
} }
return true
} }
const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { yield* Effect.sync(() => consume(first))
let start = 0 while (true) {
while (start < chunk.length) { const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line
return false
}
const newline = chunk.indexOf(10, start)
const end = newline === -1 ? chunk.length : newline + 1
const segment = chunk.subarray(start, end)
if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false
start = end
}
return true
})
let done = !(yield* consumeChunk(first))
while (!done) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break if (Option.isNone(chunk)) break
done = !(yield* consumeChunk(chunk.value)) yield* Effect.sync(() => consume(chunk.value))
} }
if (!done) { const tail = yield* Effect.sync(() => decoder.decode())
const tail = yield* decodeUtf8(resource, decoder)
if (!discard) pending += tail if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
} if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset }))
return new TextPage({ return new TextPage({
type: "text-page", type: "text-page",
content: lines.join("\n"), content: lines.join("\n"),
mime: FSUtil.mimeType(real), mime: FSUtil.mimeType(real),
offset, offset,
truncated: next !== undefined, truncated,
...(next === undefined ? {} : { next }), ...(next === undefined ? {} : { next }),
}) })
}), }),
@@ -321,8 +261,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
}) })
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) { export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
const real = yield* fs.realPath(input) const real = yield* fs.realPath(input).pipe(Effect.orDie)
const items = yield* fs.readDirectoryEntries(real) const items = yield* fs.readDirectoryEntries(real).pipe(Effect.orDie)
const offset = page.offset ?? 1 const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const entries = yield* Effect.forEach( const entries = yield* Effect.forEach(
+3 -3
View File
@@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard(
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute)) if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the allowed read root")) return yield* Effect.die(new Error("Path escapes the allowed read root"))
const real = yield* fs.realPath(absolute) const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
const root = yield* fs.realPath(selected) const root = yield* fs.realPath(selected).pipe(Effect.orDie)
if (!FSUtil.contains(root, real)) if (!FSUtil.contains(root, real))
return yield* Effect.die(new Error("Path escapes the allowed read root")) return yield* Effect.die(new Error("Path escapes the allowed read root"))
const resource = path.relative(root, real).replaceAll("\\", "/") || "." const resource = path.relative(root, real).replaceAll("\\", "/") || "."
@@ -83,7 +83,7 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
} }
if ("encoding" in content && content.encoding === "base64") if ("encoding" in content && content.encoding === "base64")
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError(resource))
return content return content
}).pipe( }).pipe(
Effect.mapError((error) => { Effect.mapError((error) => {
+20 -12
View File
@@ -1,12 +1,11 @@
export * as WebFetchTool from "./webfetch" export * as WebFetchTool from "./webfetch"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect" import { Duration, Effect, Layer, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2" import { Parser } from "htmlparser2"
import TurndownService from "turndown" import TurndownService from "turndown"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { collectBoundedResponseBody } from "./http-body"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -87,11 +86,24 @@ const execute = (http: HttpClient.HttpClient, url: string, format: Format, userA
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)) http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
const collectBody = (response: HttpClientResponse.HttpClientResponse) => const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
collectBoundedResponseBody( Effect.gen(function* () {
response, const contentLength = response.headers["content-length"]
MAX_RESPONSE_BYTES, if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
() => new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`), return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
}
const chunks: Uint8Array[] = []
let size = 0
yield* Stream.runForEach(response.stream, (chunk) =>
Effect.gen(function* () {
size += chunk.byteLength
if (size > MAX_RESPONSE_BYTES)
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
chunks.push(chunk)
return undefined
}),
) )
return Buffer.concat(chunks, size)
})
const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "" const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""
const isImageAttachment = (mime: string) => const isImageAttachment = (mime: string) =>
@@ -159,16 +171,12 @@ export const layer = Layer.effectDiscard(
orElse: () => Effect.fail(new Error("Request timed out")), orElse: () => Effect.fail(new Error("Request timed out")),
}), }),
) )
const content = new TextDecoder().decode(body) const content = convert(new TextDecoder().decode(body), contentType, input.format)
const output = yield* Effect.try({
try: () => convert(content, contentType, input.format),
catch: (error) => error,
})
return { return {
url: input.url, url: input.url,
contentType, contentType,
format: input.format, format: input.format,
output, output: content,
} }
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
}), }),
+4 -7
View File
@@ -9,7 +9,6 @@ import { PositiveInt } from "../schema"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
import { collectBoundedResponseBody } from "./http-body"
import { checksum } from "../util/encode" import { checksum } from "../util/encode"
export const name = "websearch" export const name = "websearch"
@@ -165,12 +164,10 @@ const callMcp = <F extends Schema.Struct.Fields>(
) )
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request) const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody( const body = yield* response.text
response, if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES)
MAX_RESPONSE_BYTES, return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`), return yield* parseResponse(body)
)
return yield* parseResponse(body.toString("utf8"))
}).pipe( }).pipe(
Effect.timeoutOrElse({ Effect.timeoutOrElse({
duration: Duration.seconds(25), duration: Duration.seconds(25),
+27 -18
View File
@@ -11,11 +11,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { required } from "./plugin/provider-helper"
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
const locationLayer = Layer.succeed( const locationLayer = Layer.succeed(
Location.Service, Location.Service,
@@ -25,7 +21,12 @@ const it = testEffect(
Catalog.locationLayer.pipe( Catalog.locationLayer.pipe(
Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer), Layer.provideMerge(locationLayer),
Layer.provideMerge(Credential.defaultLayer), Layer.provideMerge(
Layer.mock(Credential.Service)({
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}),
),
), ),
) )
@@ -47,30 +48,38 @@ describe("CatalogV2", () => {
it.effect("derives availability from active credentials without changing provider state", () => { it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test") const integrationID = Integration.ID.make("test")
const first = {
id: Credential.ID.create(),
integrationID,
label: "First",
value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
}
const second = {
id: Credential.ID.create(),
integrationID,
label: "Second",
value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
}
let active = first
const layer = Catalog.locationLayer.pipe( const layer = Catalog.locationLayer.pipe(
Layer.fresh, Layer.fresh,
Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer), Layer.provideMerge(locationLayer),
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)), Layer.provideMerge(
Layer.mock(Credential.Service)({
all: () => Effect.sync(() => [active]),
list: () => Effect.sync(() => [active]),
}),
),
) )
return Effect.gen(function* () { return Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
yield* credentials.create({
integrationID,
label: "First",
value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
yield* credentials.create({ active = second
integrationID,
label: "Second",
value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
}).pipe(Effect.provide(layer)) }).pipe(Effect.provide(layer))
+26 -44
View File
@@ -1,52 +1,14 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { it, required, withEnv } from "../plugin/provider-helper"
import { PluginTestLayer } from "../plugin/fixture" import { catalogHost, host, integrationHost } from "../plugin/host"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* (config: Config.Interface) {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)),
})
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
function request(headers: Record<string, string>, variant?: string) { function request(headers: Record<string, string>, variant?: string) {
return { return {
@@ -61,6 +23,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("partitions existing model variant bodies without changing config shape", () => it.effect("partitions existing model variant bodies without changing config shape", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next") const modelID = ModelV2.ID.make("alpha-gpt-next")
const config = Config.Service.of({ const config = Config.Service.of({
@@ -93,7 +57,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
]), ]),
}) })
yield* addPlugin(config) yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
})
const model = required(yield* catalog.model.get(providerID, modelID)) const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.variants).toMatchObject([ expect(model.variants).toMatchObject([
@@ -113,6 +82,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("uses the effective provider package across layered config", () => it.effect("uses the effective provider package across layered config", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next") const modelID = ModelV2.ID.make("alpha-gpt-next")
const config = Config.Service.of({ const config = Config.Service.of({
@@ -145,7 +116,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
]), ]),
}) })
yield* addPlugin(config) yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
})
const model = required(yield* catalog.model.get(providerID, modelID)) const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.variants[0]).toMatchObject({ expect(model.variants[0]).toMatchObject({
@@ -161,6 +137,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom") const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat") const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({ const config = Config.Service.of({
@@ -240,7 +217,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
]), ]),
}) })
yield* addPlugin(config) yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
})
const provider = required(yield* catalog.provider.get(providerID)) const provider = required(yield* catalog.provider.get(providerID))
const model = required(yield* catalog.model.get(providerID, modelID)) const model = required(yield* catalog.model.get(providerID, modelID))
+19 -5
View File
@@ -1,13 +1,25 @@
import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "./lib/effect" import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const it = testEffect(Credential.defaultLayer) function layer(directory: string) {
return Credential.layer.pipe(
Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)),
)
}
describe("Credential", () => { describe("Credential", () => {
it.effect("stores, updates, lists, and removes credentials", () => 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* () { Effect.gen(function* () {
const credentials = yield* Credential.Service const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
@@ -30,6 +42,8 @@ describe("Credential", () => {
yield* credentials.remove(replacement.id) yield* credentials.remove(replacement.id)
expect(yield* credentials.list(integrationID)).toEqual([]) expect(yield* credentials.list(integrationID)).toEqual([])
}), }).pipe(Effect.provide(layer(tmp.path))),
),
),
) )
}) })
@@ -71,9 +71,9 @@ describe("DatabaseMigration", () => {
).toEqual({ name: "session_context_epoch" }) ).toEqual({ name: "session_context_epoch" })
expect( expect(
yield* db.get( yield* db.get(
sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name IN ('agent', 'replacement_seq', 'revision')`, sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`,
), ),
).toBeUndefined() ).toEqual({ name: "agent", dflt_value: "'build'" })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect( expect(
yield* db.all( yield* db.all(
+3 -2
View File
@@ -420,12 +420,13 @@ describe("EventV2", () => {
const readStarted = yield* Deferred.make<void>() const readStarted = yield* Deferred.make<void>()
const continueRead = yield* Deferred.make<void>() const continueRead = yield* Deferred.make<void>()
let pause = true let pause = true
const database = Database.layerFromPath(":memory:")
const eventLayer = EventV2.layerWith({ const eventLayer = EventV2.layerWith({
beforeAggregateRead: () => beforeAggregateRead: () =>
pause pause
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void, : Effect.void,
}).pipe(Layer.provide(Database.defaultLayer)) }).pipe(Layer.provide(database))
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
@@ -440,7 +441,7 @@ describe("EventV2", () => {
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, { id: aggregateID, text: "during handoff" }], [0, { id: aggregateID, text: "during handoff" }],
]) ])
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer))) }).pipe(Effect.provide(Layer.mergeAll(database, eventLayer)))
}), }),
) )
@@ -5,7 +5,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import path from "path" import path from "path"
const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer) const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
const { effect: it } = testEffect(live) const { effect: it } = testEffect(live)
describe("FSUtil", () => { describe("FSUtil", () => {
+2 -2
View File
@@ -22,7 +22,7 @@ describe("Ripgrep", () => {
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src"))) yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n")) yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 }) const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")]) expect(result.map((item) => item.path)).toEqual([RelativePath.make(path.join("src", "match.ts"))])
}), }),
), ),
) )
@@ -35,7 +35,7 @@ describe("Ripgrep", () => {
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n")) yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 }) const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts")) expect(result[0]?.entry.path).toBe(RelativePath.make(path.join("src", "match.ts")))
expect(result[0]?.submatches[0]?.text).toBe("needle") expect(result[0]?.submatches[0]?.text).toBe("needle")
}), }),
), ),
-14
View File
@@ -1,8 +1,6 @@
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Layer } from "effect"
import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return { return {
@@ -12,15 +10,3 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
vcs: input.vcs, vcs: input.vcs,
} satisfies Location.Interface } satisfies Location.Interface
} }
export const tempLocationLayer = Layer.unwrap(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.map((tmp) => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
}),
),
)
+118 -56
View File
@@ -4,12 +4,45 @@ import * as TestClock from "effect/testing/TestClock"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { testEffect } from "./lib/effect" import { it } from "./lib/effect"
const it = testEffect( const layer = Integration.locationLayer.pipe(
Integration.locationLayer.pipe(Layer.provideMerge(Credential.defaultLayer), Layer.provideMerge(EventV2.defaultLayer)), Layer.provide(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
create: () => Effect.die("unexpected credential creation"),
list: () => Effect.succeed([]),
}),
),
) )
function connectionLayer(
created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}>,
) {
return Integration.locationLayer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
create: (input) =>
Effect.sync(() => {
created.push(input)
return new Credential.Stored({
id: Credential.ID.create(),
integrationID: input.integrationID,
label: input.label ?? "default",
value: input.value,
})
}),
list: () => Effect.succeed([]),
}),
),
)
}
describe("Integration", () => { describe("Integration", () => {
it.effect("registers integrations through the editor", () => it.effect("registers integrations through the editor", () =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -26,7 +59,7 @@ describe("Integration", () => {
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
expect(yield* integrations.get(openai)).toBeUndefined() expect(yield* integrations.get(openai)).toBeUndefined()
}), }).pipe(Effect.provide(layer)),
) )
it.effect("reveals the previous registration when an override closes", () => it.effect("reveals the previous registration when an override closes", () =>
@@ -47,7 +80,7 @@ describe("Integration", () => {
yield* Scope.close(second, Exit.void) yield* Scope.close(second, Exit.void)
expect((yield* integrations.get(id))?.name).toBe("OpenAI") expect((yield* integrations.get(id))?.name).toBe("OpenAI")
expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id]) expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id])
}), }).pipe(Effect.provide(layer)),
) )
it.effect("registers and overrides methods independently", () => it.effect("registers and overrides methods independently", () =>
@@ -95,13 +128,17 @@ describe("Integration", () => {
yield* Scope.close(second, Exit.void) yield* Scope.close(second, Exit.void)
expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" }) expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" })
expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })]) expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })])
}), }).pipe(Effect.provide(layer)),
) )
it.effect("connects with a key and stores the credential", () => it.effect("connects with a key and stores the credential", () => {
Effect.gen(function* () { const created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
yield* integrations.transform((editor) => yield* integrations.transform((editor) =>
@@ -121,21 +158,25 @@ describe("Integration", () => {
label: "Work", label: "Work",
}) })
expect(yield* credentials.list(integrationID)).toEqual([ expect(created).toEqual([
expect.objectContaining({ {
integrationID, integrationID,
label: "Work", label: "Work",
value: new Credential.Key({ type: "key", key: "secret" }), value: new Credential.Key({ type: "key", key: "secret" }),
}), },
]) ])
expect((yield* Fiber.join(updated)).length).toBe(1) expect((yield* Fiber.join(updated)).length).toBe(1)
}), }).pipe(Effect.provide(connectionLayer(created)))
) })
it.effect("completes code OAuth once and stores the credential", () => it.effect("completes code OAuth once and stores the credential", () => {
Effect.gen(function* () { const created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt") const methodID = Integration.MethodID.make("chatgpt")
yield* integrations.transform((editor) => yield* integrations.transform((editor) =>
@@ -171,8 +212,7 @@ describe("Integration", () => {
expect(attempt.mode).toBe("code") expect(attempt.mode).toBe("code")
yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }) yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" })
expect((yield* credentials.list(integrationID))[0]).toEqual( expect(created[0]).toEqual({
expect.objectContaining({
integrationID, integrationID,
label: "Personal", label: "Personal",
value: new Credential.OAuth({ value: new Credential.OAuth({
@@ -183,15 +223,18 @@ describe("Integration", () => {
expires: 1, expires: 1,
metadata: { code: "1234" }, metadata: { code: "1234" },
}), }),
}), })
) }).pipe(Effect.provide(connectionLayer(created)))
}), })
)
it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => {
Effect.gen(function* () { const created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt") const methodID = Integration.MethodID.make("chatgpt")
let closed = false let closed = false
@@ -218,14 +261,18 @@ describe("Integration", () => {
expect(closed).toBe(false) expect(closed).toBe(false)
yield* integrations.attempt.cancel(attempt.attemptID) yield* integrations.attempt.cancel(attempt.attemptID)
expect(closed).toBe(true) expect(closed).toBe(true)
expect(yield* credentials.list(integrationID)).toEqual([]) expect(created).toEqual([])
}), }).pipe(Effect.provide(connectionLayer(created)))
) })
it.effect("completes auto OAuth in the background", () => it.effect("completes auto OAuth in the background", () => {
Effect.gen(function* () { const created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("browser") const methodID = Integration.MethodID.make("browser")
yield* integrations.transform((editor) => yield* integrations.transform((editor) =>
@@ -250,14 +297,18 @@ describe("Integration", () => {
status: "complete", status: "complete",
time: attempt.time, time: attempt.time,
}) })
expect(yield* credentials.list(integrationID)).toHaveLength(1) expect(created).toHaveLength(1)
}), }).pipe(Effect.provide(connectionLayer(created)))
) })
it.effect("expires abandoned OAuth attempts", () => it.effect("expires abandoned OAuth attempts", () => {
Effect.gen(function* () { const created: Array<{
integrationID: Integration.ID
label?: string
value: Credential.Info
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai") const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("browser") const methodID = Integration.MethodID.make("browser")
let closed = false let closed = false
@@ -286,12 +337,34 @@ describe("Integration", () => {
time: attempt.time, time: attempt.time,
}) })
expect(closed).toBe(true) expect(closed).toBe(true)
expect(yield* credentials.list(integrationID)).toEqual([]) expect(created).toEqual([])
}), }).pipe(Effect.provide(connectionLayer(created)))
) })
it.effect("projects credential and env connections", () => { it.effect("projects credential and env connections", () => {
const integrationID = Integration.ID.make("acme") const integrationID = Integration.ID.make("acme")
const rows = [
{
id: Credential.ID.create(),
integrationID,
label: "Work",
value: new Credential.Key({ type: "key", key: "a" }),
},
{
id: Credential.ID.create(),
integrationID,
label: "Personal",
value: new Credential.Key({ type: "key", key: "b" }),
},
]
const projectionLayer = Integration.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
list: () => Effect.succeed(rows.map((row) => new Credential.Stored(row))),
}),
),
)
return Effect.acquireUseRelease( return Effect.acquireUseRelease(
Effect.sync(() => { Effect.sync(() => {
const previous = process.env.INTEGRATION_TEST_ACME_KEY const previous = process.env.INTEGRATION_TEST_ACME_KEY
@@ -302,7 +375,6 @@ describe("Integration", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
yield* integrations.transform((editor) => yield* integrations.transform((editor) =>
editor.method.update({ editor.method.update({
integrationID, integrationID,
@@ -312,33 +384,23 @@ describe("Integration", () => {
}, },
}), }),
) )
const work = yield* credentials.create({
integrationID,
label: "Work",
value: new Credential.Key({ type: "key", key: "a" }),
})
const personal = yield* credentials.create({
integrationID,
label: "Personal",
value: new Credential.Key({ type: "key", key: "b" }),
})
// Stored credentials and detected env vars appear as connections. // Stored credentials and detected env vars appear as connections.
expect((yield* integrations.get(integrationID))?.connections).toEqual([ expect((yield* integrations.get(integrationID))?.connections).toEqual([
{ type: "credential", id: rows[0]!.id, label: "Work" },
{ {
type: "credential", type: "credential",
id: personal.id, id: rows[1]!.id,
label: "Personal", label: "Personal",
}, },
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }, { type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
]) ])
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({ expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
type: "credential", type: "credential",
id: personal.id, id: rows[1]!.id,
label: "Personal", label: "Personal",
}) })
expect(work.id).not.toBe(personal.id) }).pipe(Effect.provide(projectionLayer)),
}),
(previous) => (previous) =>
Effect.sync(() => { Effect.sync(() => {
if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY
+38 -5
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Exit, Layer } from "effect" import { Cause, Effect, Exit, Layer } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@@ -31,6 +31,13 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
).pipe(Effect.flatMap((tmp) => f(tmp.path))) ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
describe("FileSystem", () => { describe("FileSystem", () => {
const expectFail = (exit: Exit.Exit<unknown, unknown>) => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) return
expect(Cause.hasFails(exit.cause)).toBe(true)
expect(Cause.hasDies(exit.cause)).toBe(false)
}
it.live("reads text and binary files", () => it.live("reads text and binary files", () =>
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -60,13 +67,39 @@ describe("FileSystem", () => {
), ),
) )
it.live("rejects lexical escapes", () => it.live("fails for missing paths", () =>
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const result = yield* (yield* FileSystem.Service) const exit = yield* (yield* FileSystem.Service)
.read({ path: RelativePath.make("../outside.txt") }) .read({ path: RelativePath.make("missing.txt") })
.pipe(Effect.exit) .pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true) expectFail(exit)
}).pipe(provide(directory)),
),
)
it.live("fails for wrong path kinds", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
const service = yield* FileSystem.Service
expectFail(yield* service.read({ path: RelativePath.make("src") }).pipe(Effect.exit))
expectFail(yield* service.list({ path: RelativePath.make("README.md") }).pipe(Effect.exit))
}).pipe(provide(directory)),
),
)
it.live("fails for lexical and symlink escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
const outside = path.join(directory, "..", "outside.txt")
yield* Effect.promise(() => fs.writeFile(outside, "outside"))
yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "linked.txt")))
const service = yield* FileSystem.Service
expectFail(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit))
expectFail(yield* service.read({ path: RelativePath.make("linked.txt") }).pipe(Effect.exit))
yield* Effect.promise(() => fs.rm(outside))
}).pipe(provide(directory)), }).pipe(provide(directory)),
), ),
) )
+3 -56
View File
@@ -1,20 +1,16 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { DateTime, Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect" import { Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect"
import { Tool } from "@opencode-ai/core/public" import { Tool } from "@opencode-ai/core/public"
import { define } from "@opencode-ai/plugin/v2/effect" import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolDefinitions } from "./lib/tool" import { toolDefinitions } from "./lib/tool"
@@ -40,7 +36,8 @@ const it = testEffect(
Layer.mergeAll( Layer.mergeAll(
Project.defaultLayer, Project.defaultLayer,
EventV2.defaultLayer, EventV2.defaultLayer,
Credential.defaultLayer.pipe(Layer.fresh), Credential.defaultLayer,
Credential.layer.pipe(Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh))),
Npm.defaultLayer, Npm.defaultLayer,
ModelsDev.defaultLayer, ModelsDev.defaultLayer,
FSUtil.defaultLayer, FSUtil.defaultLayer,
@@ -140,56 +137,6 @@ describe("LocationServiceMap", () => {
), ),
) )
it.live("rejects an unavailable selected model during location model resolution", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
yield* Effect.promise(() =>
fs.writeFile(
path.join(dir.path, "opencode.json"),
JSON.stringify({
providers: {
unavailable: {
name: "Unavailable",
api: { type: "native", settings: {} },
models: { chat: { disabled: true } },
},
},
}),
),
)
const failure = yield* SessionRunnerModel.Service.use((models) =>
models.resolve(
SessionV2.Info.make({
id: SessionV2.ID.make("ses_unavailable_model"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: ModelV2.ID.make("chat"),
providerID: ProviderV2.ID.make("unavailable"),
},
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
),
).pipe(Effect.provide(LocationServiceMap.get(location)), Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelUnavailableError",
providerID: "unavailable",
modelID: "chat",
})
}),
),
),
)
it.live("installs public plugins into a location", () => it.live("installs public plugins into a location", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
+13 -18
View File
@@ -21,39 +21,34 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events))
const project = Project.layer.pipe( const project = Project.layer.pipe(
Layer.provide(Database.defaultLayer), Layer.provide(database),
Layer.provide(FSUtil.defaultLayer), Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
Layer.provide(ProjectDirectories.defaultLayer), Layer.provide(directories),
) )
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(Database.defaultLayer), Layer.provide(database),
Layer.provide(EventV2.defaultLayer), Layer.provide(events),
Layer.provide(project), Layer.provide(project),
Layer.provide(SessionStore.defaultLayer), Layer.provide(store),
Layer.provide(SessionExecution.noopLayer), Layer.provide(SessionExecution.noopLayer),
) )
const layer = MoveSession.layer.pipe( const layer = MoveSession.layer.pipe(
Layer.provide(Database.defaultLayer), Layer.provide(database),
Layer.provide(FSUtil.defaultLayer), Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(events),
Layer.provide(project), Layer.provide(project),
Layer.provide(sessions), Layer.provide(sessions),
) )
const it = testEffect( const it = testEffect(
Layer.mergeAll( Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions),
layer,
Database.defaultLayer,
EventV2.defaultLayer,
ProjectDirectories.defaultLayer,
project,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
SessionExecution.noopLayer,
sessions,
),
) )
function abs(input: string) { function abs(input: string) {
+11 -7
View File
@@ -18,25 +18,29 @@ import { eq } from "drizzle-orm"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const current = Layer.succeed( const current = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })), Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
) )
const events = EventV2.layer.pipe(Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(EventV2.defaultLayer), Layer.provide(events),
Layer.provide(Database.defaultLayer), Layer.provide(database),
Layer.provide(SessionStore.defaultLayer), Layer.provide(store),
Layer.provide(Project.defaultLayer), Layer.provide(Project.defaultLayer),
Layer.provide(SessionExecution.noopLayer), Layer.provide(SessionExecution.noopLayer),
) )
const saved = PermissionSaved.layer.pipe(Layer.provide(database))
const layer = PermissionV2.locationLayer.pipe( const layer = PermissionV2.locationLayer.pipe(
Layer.provideMerge(Database.defaultLayer), Layer.provideMerge(database),
Layer.provideMerge(SessionStore.defaultLayer), Layer.provideMerge(store),
Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(events),
Layer.provideMerge(current), Layer.provideMerge(current),
Layer.provideMerge(sessions), Layer.provideMerge(sessions),
Layer.provideMerge(SessionExecution.noopLayer), Layer.provideMerge(SessionExecution.noopLayer),
Layer.provideMerge(PermissionSaved.defaultLayer), Layer.provideMerge(saved),
) )
const it = testEffect(layer) const it = testEffect(layer)
-34
View File
@@ -46,40 +46,6 @@ 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", () => it.effect("serializes same-ID additions and leaves one removable attachment", () =>
Effect.gen(function* () { Effect.gen(function* () {
const values = state() const values = state()
-48
View File
@@ -1,48 +0,0 @@
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,
),
),
)
+5 -1
View File
@@ -24,7 +24,11 @@ const locationLayer = Layer.succeed(
) )
const plugins = PluginV2.layer.pipe(Layer.provide(events)) const plugins = PluginV2.layer.pipe(Layer.provide(events))
const policy = Policy.layer.pipe(Layer.provide(locationLayer)) const policy = Policy.layer.pipe(Layer.provide(locationLayer))
const connections = Credential.defaultLayer.pipe(Layer.fresh) const connections = Credential.layer.pipe(
Layer.fresh,
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
Layer.provide(events),
)
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
const catalog = Catalog.layer.pipe( const catalog = Catalog.layer.pipe(
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
@@ -3,35 +3,17 @@ import { createAlibaba } from "@ai-sdk/alibaba"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
import { ProviderV2 } from "@opencode-ai/core/provider" import { addPlugin, it, model } 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: AlibabaPlugin.id, effect: AlibabaPlugin.effect(host) })
})
describe("AlibabaPlugin", () => { describe("AlibabaPlugin", () => {
it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AlibabaPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/alibaba",
options: { name: "alibaba" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -41,17 +23,10 @@ describe("AlibabaPlugin", () => {
it.effect("ignores non-Alibaba SDK packages", () => it.effect("ignores non-Alibaba SDK packages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AlibabaPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "alibaba" },
},
{}, {},
) )
expect(result.sdk).toBeUndefined() expect(result.sdk).toBeUndefined()
@@ -61,14 +36,11 @@ describe("AlibabaPlugin", () => {
it.effect("matches the old bundled Alibaba SDK provider naming", () => it.effect("matches the old bundled Alibaba SDK provider naming", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AlibabaPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-alibaba", "qwen"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")),
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/alibaba", package: "@ai-sdk/alibaba",
options: { name: "custom-alibaba", apiKey: "test" }, options: { name: "custom-alibaba", apiKey: "test" },
}, },
@@ -84,11 +56,8 @@ describe("AlibabaPlugin", () => {
it.effect("uses the old default languageModel(api.id) behavior", () => it.effect("uses the old default languageModel(api.id) behavior", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AlibabaPlugin)
const item = new ModelV2.Info({ const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } })
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" },
})
const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {})
const language = result.sdk?.languageModel(item.api.id) const language = result.sdk?.languageModel(item.api.id)
expect(language?.modelId).toBe("qwen-plus") expect(language?.modelId).toBe("qwen-plus")
@@ -1,61 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: AmazonBedrockPlugin.id, effect: AmazonBedrockPlugin.effect(host) })
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
fx,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
@@ -79,10 +28,11 @@ function openAIUrl(language: unknown, path: string, modelId: string) {
describe("AmazonBedrockPlugin", () => { describe("AmazonBedrockPlugin", () => {
it.effect("moves endpoint option to api URL", () => it.effect("moves endpoint option to api URL", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const bedrock = new ProviderV2.Info({ const bedrock = provider("amazon-bedrock", {
...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock),
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
request: { request: {
headers: {}, headers: {},
@@ -94,7 +44,6 @@ describe("AmazonBedrockPlugin", () => {
item.request = bedrock.request item.request = bedrock.request
}) })
}) })
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)) const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock))
expect(result.api).toEqual({ expect(result.api).toEqual({
type: "aisdk", type: "aisdk",
@@ -109,14 +58,11 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock", package: "@ai-sdk/amazon-bedrock",
options: { options: {
name: "amazon-bedrock", name: "amazon-bedrock",
@@ -137,14 +83,11 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock", package: "@ai-sdk/amazon-bedrock",
options: { options: {
name: "amazon-bedrock", name: "amazon-bedrock",
@@ -174,18 +117,11 @@ describe("AmazonBedrockPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: {
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/amazon-bedrock", package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" }, options: { name: "amazon-bedrock" },
}, },
@@ -201,14 +137,11 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock", package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock", region: "eu-west-1" }, options: { name: "amazon-bedrock", region: "eu-west-1" },
}, },
@@ -223,14 +156,11 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock", package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" }, options: { name: "amazon-bedrock" },
}, },
@@ -245,14 +175,11 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock", package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" }, options: { name: "amazon-bedrock" },
}, },
@@ -268,14 +195,11 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const headers: Array<string | null> = [] const headers: Array<string | null> = []
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock", package: "@ai-sdk/amazon-bedrock",
options: { options: {
name: "amazon-bedrock", name: "amazon-bedrock",
@@ -300,14 +224,11 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const headers: Array<string | null> = [] const headers: Array<string | null> = []
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/amazon-bedrock", package: "@ai-sdk/amazon-bedrock",
options: { options: {
name: "amazon-bedrock", name: "amazon-bedrock",
@@ -331,17 +252,12 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "openai.gpt-5.5", {
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
api: {
id: ModelV2.ID.make("openai.gpt-5.5"),
type: "aisdk",
package: "@ai-sdk/amazon-bedrock/mantle",
},
}), }),
package: "@ai-sdk/amazon-bedrock/mantle", package: "@ai-sdk/amazon-bedrock/mantle",
options: { options: {
@@ -365,17 +281,12 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "openai.gpt-5.5", {
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
api: {
id: ModelV2.ID.make("openai.gpt-5.5"),
type: "aisdk",
package: "@ai-sdk/amazon-bedrock/mantle",
},
}), }),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" },
@@ -385,13 +296,8 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "openai.gpt-oss-safeguard-120b", {
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
api: {
id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
type: "aisdk",
package: "@ai-sdk/amazon-bedrock/mantle",
},
}), }),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
options: { region: "us-east-1" }, options: { region: "us-east-1" },
@@ -405,17 +311,12 @@ describe("AmazonBedrockPlugin", () => {
it.effect("ignores other Bedrock provider subpaths", () => it.effect("ignores other Bedrock provider subpaths", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5", {
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/anthropic" },
api: {
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
type: "aisdk",
package: "@ai-sdk/amazon-bedrock/anthropic",
},
}), }),
package: "@ai-sdk/amazon-bedrock/anthropic", package: "@ai-sdk/amazon-bedrock/anthropic",
options: { name: "amazon-bedrock" }, options: { name: "amazon-bedrock" },
@@ -439,18 +340,11 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const headers: Array<string | null> = [] const headers: Array<string | null> = []
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: {
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/amazon-bedrock", package: "@ai-sdk/amazon-bedrock",
options: { options: {
name: "amazon-bedrock", name: "amazon-bedrock",
@@ -477,14 +371,11 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -493,10 +384,7 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" }, options: { region: "eu-west-1" },
}, },
@@ -505,14 +393,7 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")),
api: {
id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"),
type: "aisdk",
package: "test-provider",
},
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" }, options: { region: "eu-west-1" },
}, },
@@ -521,10 +402,7 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "ap-northeast-1" }, options: { region: "ap-northeast-1" },
}, },
@@ -533,10 +411,7 @@ describe("AmazonBedrockPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "ap-southeast-2" }, options: { region: "ap-southeast-2" },
}, },
@@ -557,14 +432,11 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -645,15 +517,12 @@ describe("AmazonBedrockPlugin", () => {
expected: "au.anthropic.claude-sonnet-4-5", expected: "au.anthropic.claude-sonnet-4-5",
}, },
] ]
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
for (const item of cases) { for (const item of cases) {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("amazon-bedrock", item.modelID),
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)),
api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: item.region }, options: { region: item.region },
}, },
@@ -668,14 +537,11 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, AmazonBedrockPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("openai", "anthropic.claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" }, options: { region: "eu-west-1" },
}, },
@@ -1,34 +1,19 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, it, model, provider, required } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: AnthropicPlugin.id, effect: AnthropicPlugin.effect(host) })
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
describe("AnthropicPlugin", () => { describe("AnthropicPlugin", () => {
it.effect("applies legacy beta headers", () => it.effect("applies legacy beta headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AnthropicPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = new ProviderV2.Info({ const item = provider("anthropic", {
...ProviderV2.Info.empty(ProviderV2.ID.anthropic),
api: { type: "aisdk", package: "@ai-sdk/anthropic" }, api: { type: "aisdk", package: "@ai-sdk/anthropic" },
request: { headers: { Existing: "1" }, body: {} }, request: { headers: { Existing: "1" }, body: {} },
}) })
@@ -37,7 +22,6 @@ describe("AnthropicPlugin", () => {
draft.request = item.request draft.request = item.request
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe(
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
) )
@@ -47,9 +31,10 @@ describe("AnthropicPlugin", () => {
it.effect("ignores non-Anthropic providers", () => it.effect("ignores non-Anthropic providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {})) yield* addPlugin(plugin, AnthropicPlugin)
yield* addPlugin() yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {}))
expect( expect(
required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"], required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"],
).toBeUndefined() ).toBeUndefined()
@@ -59,40 +44,54 @@ describe("AnthropicPlugin", () => {
it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () => it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() const providers: string[] = []
const result = yield* plugin.trigger( 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(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-anthropic", "claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" },
}),
package: "@ai-sdk/anthropic", package: "@ai-sdk/anthropic",
options: { name: "custom-anthropic", apiKey: "test" }, options: { name: "custom-anthropic", apiKey: "test" },
}, },
{}, {},
) )
expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("custom-anthropic") expect(providers).toEqual(["custom-anthropic"])
}), }),
) )
it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () => it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() const providers: string[] = []
const result = yield* plugin.trigger( 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(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("anthropic", "claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" },
}),
package: "@ai-sdk/anthropic", package: "@ai-sdk/anthropic",
options: { name: "anthropic", apiKey: "test" }, options: { name: "anthropic", apiKey: "test" },
}, },
{}, {},
) )
expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic") expect(providers).toEqual(["anthropic"])
}), }),
) )
}) })
@@ -1,73 +1,23 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: AzureCognitiveServicesPlugin.id, effect: AzureCognitiveServicesPlugin.effect(host) })
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
fx,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("AzureCognitiveServicesPlugin", () => { describe("AzureCognitiveServicesPlugin", () => {
it.effect("maps the resource env var to the Azure SDK baseURL", () => it.effect("maps the resource env var to the Azure SDK baseURL", () =>
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
}) })
}) })
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
expect(result.api).toEqual({ expect(result.api).toEqual({
type: "aisdk", type: "aisdk",
@@ -83,16 +33,14 @@ describe("AzureCognitiveServicesPlugin", () => {
it.effect("leaves baseURL unset without resource env and ignores other providers", () => it.effect("leaves baseURL unset without resource env and ignores other providers", () =>
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const azure = new ProviderV2.Info({ const azure = provider("azure-cognitive-services", {
...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")),
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
}) })
const openai = new ProviderV2.Info({ const openai = provider("openai")
...ProviderV2.Info.empty(ProviderV2.ID.openai),
api: { type: "aisdk", package: "test-provider" },
})
catalog.provider.update(azure.id, (item) => { catalog.provider.update(azure.id, (item) => {
item.api = azure.api item.api = azure.api
}) })
@@ -100,7 +48,6 @@ describe("AzureCognitiveServicesPlugin", () => {
item.api = openai.api item.api = openai.api
}) })
}) })
yield* addPlugin()
const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai)) const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai))
expect(azure.request.body.baseURL).toBeUndefined() expect(azure.request.body.baseURL).toBeUndefined()
@@ -115,14 +62,11 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("azure-cognitive-services", "deployment"),
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
options: { useCompletionUrls: true }, options: { useCompletionUrls: true },
}, },
@@ -136,29 +80,15 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
expect(calls).toEqual(["responses:deployment"]) expect(calls).toEqual(["responses:deployment"])
@@ -171,17 +101,11 @@ describe("AzureCognitiveServicesPlugin", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
const sdk = fakeSelectorSdk(calls) const sdk = fakeSelectorSdk(calls)
yield* addPlugin() yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("azure-cognitive-services", "messages-deployment"),
...ModelV2.Info.empty(
ProviderV2.ID.make("azure-cognitive-services"),
ModelV2.ID.make("messages-deployment"),
),
api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel },
options: {}, options: {},
}, },
@@ -190,10 +114,7 @@ describe("AzureCognitiveServicesPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("azure-cognitive-services", "chat-deployment"),
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")),
api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, sdk: { chat: sdk.chat, languageModel: sdk.languageModel },
options: {}, options: {},
}, },
@@ -202,13 +123,7 @@ describe("AzureCognitiveServicesPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("azure-cognitive-services", "language-deployment"),
...ModelV2.Info.empty(
ProviderV2.ID.make("azure-cognitive-services"),
ModelV2.ID.make("language-deployment"),
),
api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: sdk.languageModel }, sdk: { languageModel: sdk.languageModel },
options: {}, options: {},
}, },
+28 -128
View File
@@ -1,73 +1,23 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: AzurePlugin.id, effect: AzurePlugin.effect(host) })
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
fx,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("AzurePlugin", () => { describe("AzurePlugin", () => {
it.effect("resolves resourceName from env", () => it.effect("resolves resourceName from env", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => { catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/azure" } item.api = { type: "aisdk", package: "@ai-sdk/azure" }
}) })
}) })
yield* addPlugin() yield* addPlugin(plugin, AzurePlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
}), }),
), ),
@@ -76,10 +26,10 @@ describe("AzurePlugin", () => {
it.effect("keeps explicit resourceName over env and ignores other providers", () => it.effect("keeps explicit resourceName over env and ignores other providers", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const azure = new ProviderV2.Info({ const azure = provider("azure", {
...ProviderV2.Info.empty(ProviderV2.ID.azure),
api: { type: "aisdk", package: "@ai-sdk/azure" }, api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: "from-config" } }, request: { headers: {}, body: { resourceName: "from-config" } },
}) })
@@ -89,7 +39,7 @@ describe("AzurePlugin", () => {
}) })
catalog.provider.update(ProviderV2.ID.openai, () => {}) catalog.provider.update(ProviderV2.ID.openai, () => {})
}) })
yield* addPlugin() yield* addPlugin(plugin, AzurePlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config") expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config")
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined()
}), }),
@@ -99,10 +49,10 @@ describe("AzurePlugin", () => {
it.effect("falls back to env when configured resourceName is blank", () => it.effect("falls back to env when configured resourceName is blank", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const azure = new ProviderV2.Info({ const azure = provider("azure", {
...ProviderV2.Info.empty(ProviderV2.ID.azure),
api: { type: "aisdk", package: "@ai-sdk/azure" }, api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: "" } }, request: { headers: {}, body: { resourceName: "" } },
}) })
@@ -111,7 +61,7 @@ describe("AzurePlugin", () => {
item.request = azure.request item.request = azure.request
}) })
}) })
yield* addPlugin() yield* addPlugin(plugin, AzurePlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
}), }),
), ),
@@ -120,10 +70,10 @@ describe("AzurePlugin", () => {
it.effect("falls back to env when configured resourceName is whitespace", () => it.effect("falls back to env when configured resourceName is whitespace", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const azure = new ProviderV2.Info({ const azure = provider("azure", {
...ProviderV2.Info.empty(ProviderV2.ID.azure),
api: { type: "aisdk", package: "@ai-sdk/azure" }, api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: " " } }, request: { headers: {}, body: { resourceName: " " } },
}) })
@@ -132,7 +82,7 @@ describe("AzurePlugin", () => {
item.request = azure.request item.request = azure.request
}) })
}) })
yield* addPlugin() yield* addPlugin(plugin, AzurePlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
}), }),
), ),
@@ -142,14 +92,11 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: undefined }, () => withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AzurePlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("azure", "deployment"),
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/azure", package: "@ai-sdk/azure",
options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, options: { name: "azure", baseURL: "https://proxy.example.com/openai" },
}, },
@@ -164,18 +111,11 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: undefined }, () => withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, AzurePlugin)
const exit = yield* plugin const exit = yield* plugin
.trigger( .trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/azure",
options: { name: "azure" },
},
{}, {},
) )
.pipe(Effect.exit) .pipe(Effect.exit)
@@ -188,17 +128,10 @@ describe("AzurePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, AzurePlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: { useCompletionUrls: true },
},
{}, {},
) )
expect(calls).toEqual(["chat:deployment"]) expect(calls).toEqual(["chat:deployment"])
@@ -209,17 +142,10 @@ describe("AzurePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, AzurePlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: { useCompletionUrls: true },
},
{}, {},
) )
expect(calls).toEqual(["chat:deployment"]) expect(calls).toEqual(["chat:deployment"])
@@ -230,13 +156,11 @@ describe("AzurePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, AzurePlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("azure", "deployment", {
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
request: { headers: {}, body: { useCompletionUrls: true } }, request: { headers: {}, body: { useCompletionUrls: true } },
}), }),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
@@ -252,29 +176,15 @@ describe("AzurePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, AzurePlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
expect(calls).toEqual(["responses:deployment"]) expect(calls).toEqual(["responses:deployment"])
@@ -290,14 +200,11 @@ describe("AzurePlugin", () => {
calls.push(`${method}:${id}`) calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } return { modelId: id, provider: method, specificationVersion: "v3" }
} }
yield* addPlugin() yield* addPlugin(plugin, AzurePlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("azure", "messages-deployment"),
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")),
api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") },
options: {}, options: {},
}, },
@@ -305,14 +212,7 @@ describe("AzurePlugin", () => {
) )
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")),
api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: make("languageModel") },
options: {},
},
{}, {},
) )
expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"]) expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"])
@@ -1,22 +1,12 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, it, model, required } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const cerebrasOptions: Record<string, unknown>[] = [] const cerebrasOptions: Record<string, unknown>[] = []
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: CerebrasPlugin.id, effect: CerebrasPlugin.effect(host) })
})
void mock.module("@ai-sdk/cerebras", () => ({ void mock.module("@ai-sdk/cerebras", () => ({
createCerebras: (options: Record<string, unknown>) => { createCerebras: (options: Record<string, unknown>) => {
@@ -31,15 +21,16 @@ void mock.module("@ai-sdk/cerebras", () => ({
describe("CerebrasPlugin", () => { describe("CerebrasPlugin", () => {
it.effect("applies the legacy integration header", () => it.effect("applies the legacy integration header", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/cerebras" } item.api = { type: "aisdk", package: "@ai-sdk/cerebras" }
item.request.headers.Existing = "1" item.request.headers.Existing = "1"
}) })
}) })
yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.request.headers).toEqual({
Existing: "1", Existing: "1",
"X-Cerebras-3rd-Party-Integration": "opencode", "X-Cerebras-3rd-Party-Integration": "opencode",
}) })
@@ -48,10 +39,11 @@ describe("CerebrasPlugin", () => {
it.effect("ignores non-Cerebras providers", () => it.effect("ignores non-Cerebras providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({})
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.request.headers).toEqual({})
}), }),
) )
@@ -59,21 +51,11 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
cerebrasOptions.length = 0 cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CerebrasPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
...ModelV2.Info.empty(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
),
api: {
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/cerebras", package: "@ai-sdk/cerebras",
options: { name: "custom-cerebras", apiKey: "test" }, options: { name: "custom-cerebras", apiKey: "test" },
}, },
@@ -88,21 +70,11 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
cerebrasOptions.length = 0 cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CerebrasPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
...ModelV2.Info.empty(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
),
api: {
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/cerebras", package: "@ai-sdk/cerebras",
options: { name: "configured-cerebras", apiKey: "test" }, options: { name: "configured-cerebras", apiKey: "test" },
}, },
@@ -116,21 +88,11 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
cerebrasOptions.length = 0 cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CerebrasPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
...ModelV2.Info.empty(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
),
api: {
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/groq", package: "@ai-sdk/groq",
options: { name: "custom-cerebras", apiKey: "test" }, options: { name: "custom-cerebras", apiKey: "test" },
}, },
@@ -1,41 +1,8 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
import { ProviderV2 } from "@opencode-ai/core/provider" import { addPlugin, it, model, 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: CloudflareAIGatewayPlugin.id, effect: CloudflareAIGatewayPlugin.effect(host) })
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
fx,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
const aiGatewayCalls: Record<string, unknown>[] = [] const aiGatewayCalls: Record<string, unknown>[] = []
const unifiedCalls: string[] = [] const unifiedCalls: string[] = []
@@ -111,14 +78,11 @@ describe("CloudflareAIGatewayPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-ai-gateway", "openai/gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -134,15 +98,12 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-ai-gateway", "openai/gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider", package: "ai-gateway-provider",
options: { options: {
name: "cloudflare-ai-gateway", name: "cloudflare-ai-gateway",
@@ -181,15 +142,12 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-ai-gateway", "openai/gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider", package: "ai-gateway-provider",
options: { options: {
name: "cloudflare-ai-gateway", name: "cloudflare-ai-gateway",
@@ -213,15 +171,12 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-ai-gateway", "openai/gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider", package: "ai-gateway-provider",
options: { options: {
name: "cloudflare-ai-gateway", name: "cloudflare-ai-gateway",
@@ -253,15 +208,12 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-ai-gateway", "openai/gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider", package: "ai-gateway-provider",
options: { options: {
name: "cloudflare-ai-gateway", name: "cloudflare-ai-gateway",
@@ -287,15 +239,12 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-ai-gateway", "openai/gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -312,15 +261,12 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-ai-gateway", "openai/gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -338,15 +284,12 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-ai-gateway", "openai/gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -370,15 +313,12 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-ai-gateway", "openai/gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "ai-gateway-provider", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" },
}, },
@@ -396,22 +336,12 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"),
...ModelV2.Info.empty(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
),
api: {
id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
type: "aisdk",
package: "test-provider",
},
}),
package: "ai-gateway-provider", package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -434,15 +364,12 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetCalls() resetCalls()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-ai-gateway", "openai/gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-ai-gateway" }, options: { name: "cloudflare-ai-gateway" },
}, },
@@ -3,59 +3,9 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import type { LanguageModelV3 } from "@ai-sdk/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* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: CloudflareWorkersAIPlugin.id, effect: CloudflareWorkersAIPlugin.effect(host) })
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
@@ -87,15 +37,12 @@ describe("CloudflareWorkersAIPlugin", () => {
provider.api = { type: "aisdk", package: "test-provider" } provider.api = { type: "aisdk", package: "test-provider" }
}), }),
) )
yield* addPlugin() yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-workers-ai", "@cf/model", { api: provider.api }),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
api: { id: ModelV2.ID.make("@cf/model"), ...provider.api },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
}, },
@@ -114,13 +61,14 @@ describe("CloudflareWorkersAIPlugin", () => {
it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () => it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
}), }),
) )
yield* addPlugin() yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
@@ -134,18 +82,12 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-workers-ai", "@cf/model", {
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
api: {
id: ModelV2.ID.make("@cf/model"),
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://proxy.example/v1",
},
}), }),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
@@ -160,6 +102,7 @@ describe("CloudflareWorkersAIPlugin", () => {
it.effect("uses env account ID over configured account ID", () => it.effect("uses env account ID over configured account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
@@ -167,7 +110,7 @@ describe("CloudflareWorkersAIPlugin", () => {
provider.request.body.accountId = "configured-acct" provider.request.body.accountId = "configured-acct"
}), }),
) )
yield* addPlugin() yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
@@ -181,18 +124,12 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-workers-ai", "@cf/model", {
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
api: {
id: ModelV2.ID.make("@cf/model"),
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://proxy.example/v1",
},
}), }),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { options: {
@@ -216,14 +153,12 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-workers-ai", "@cf/model", {
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
api: { api: {
id: ModelV2.ID.make("@cf/model"),
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
@@ -248,14 +183,11 @@ describe("CloudflareWorkersAIPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("cloudflare-workers-ai", "alias", { api: { id: ModelV2.ID.make("@cf/api-model") } }),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -270,18 +202,12 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-workers-ai", "@cf/model", {
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" },
api: {
id: ModelV2.ID.make("@cf/model"),
type: "aisdk",
package: "@ai-sdk/anthropic",
url: "https://proxy.example/v1",
},
}), }),
package: "@ai-sdk/anthropic", package: "@ai-sdk/anthropic",
options: { name: "cloudflare-workers-ai" }, options: { name: "cloudflare-workers-ai" },
@@ -2,34 +2,10 @@ import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
import { ProviderV2 } from "@opencode-ai/core/provider" import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const cohereOptions: Record<string, any>[] = [] const cohereOptions: Record<string, any>[] = []
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: CoherePlugin.id, effect: CoherePlugin.effect(host) })
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
void mock.module("@ai-sdk/cohere", () => ({ void mock.module("@ai-sdk/cohere", () => ({
createCohere: (options: Record<string, any>) => { createCohere: (options: Record<string, any>) => {
@@ -48,32 +24,18 @@ describe("CoherePlugin", () => {
it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CoherePlugin)
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("cohere", "command"), package: "@ai-sdk/openai-compatible", options: { name: "cohere" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cohere" },
},
{}, {},
) )
expect(ignored.sdk).toBeUndefined() expect(ignored.sdk).toBeUndefined()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("cohere", "command"), package: "@ai-sdk/cohere", options: { name: "cohere" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/cohere",
options: { name: "cohere" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -83,14 +45,11 @@ describe("CoherePlugin", () => {
it.effect("uses the model provider ID as the bundled SDK name", () => it.effect("uses the model provider ID as the bundled SDK name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, CoherePlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-cohere", "command-r-plus"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")),
api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/cohere", package: "@ai-sdk/cohere",
options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" },
}, },
@@ -111,17 +70,10 @@ describe("CoherePlugin", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
const sdk = fakeSelectorSdk(calls) const sdk = fakeSelectorSdk(calls)
yield* addPlugin() yield* addPlugin(plugin, CoherePlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" },
}),
sdk,
options: {},
},
{}, {},
) )
@@ -1,25 +1,20 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { ModelV2 } from "@opencode-ai/core/model" import { AISDK } from "@opencode-ai/core/aisdk"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { addPlugin, it, model } from "./provider-helper"
const it = testEffect(PluginTestLayer) const itAISDK = testEffect(
const deepinfraOptions: Record<string, unknown>[] = [] Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))),
)
const deepinfraOptions: Record<string, any>[] = []
const deepinfraLanguageModels: string[] = [] const deepinfraLanguageModels: string[] = []
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: DeepInfraPlugin.id, effect: DeepInfraPlugin.effect(host) })
})
void mock.module("@ai-sdk/deepinfra", () => ({ void mock.module("@ai-sdk/deepinfra", () => ({
createDeepInfra: (options: Record<string, unknown>) => { createDeepInfra: (options: Record<string, any>) => {
const captured = { ...options } const captured = { ...options }
deepinfraOptions.push(captured) deepinfraOptions.push(captured)
return { return {
@@ -41,17 +36,10 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetDeepInfraMock() resetDeepInfraMock()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, DeepInfraPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
package: "@ai-sdk/deepinfra",
options: { name: "deepinfra" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -62,14 +50,11 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetDeepInfraMock() resetDeepInfraMock()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, DeepInfraPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-deepinfra", "model"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
package: "@ai-sdk/deepinfra", package: "@ai-sdk/deepinfra",
options: { name: "custom-deepinfra", apiKey: "test" }, options: { name: "custom-deepinfra", apiKey: "test" },
}, },
@@ -84,14 +69,11 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetDeepInfraMock() resetDeepInfraMock()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, DeepInfraPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("deepinfra", "model"),
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
package: "@ai-sdk/deepinfra", package: "@ai-sdk/deepinfra",
options: { name: "deepinfra", apiKey: "test" }, options: { name: "deepinfra", apiKey: "test" },
}, },
@@ -106,7 +88,7 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
resetDeepInfraMock() resetDeepInfraMock()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, DeepInfraPlugin)
const packages = [ const packages = [
"unmatched-package", "unmatched-package",
"@ai-sdk/deepinfra-compatible", "@ai-sdk/deepinfra-compatible",
@@ -116,14 +98,7 @@ describe("DeepInfraPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
package: item,
options: { name: "deepinfra" },
},
{}, {},
) )
expect(ignored.sdk).toBeUndefined() expect(ignored.sdk).toBeUndefined()
@@ -131,14 +106,7 @@ describe("DeepInfraPlugin", () => {
) )
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
package: "@ai-sdk/deepinfra",
options: { name: "deepinfra" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -146,36 +114,17 @@ describe("DeepInfraPlugin", () => {
}), }),
) )
it.effect("uses the default languageModel selection for DeepInfra models", () => itAISDK.effect("uses the default languageModel selection for DeepInfra models", () =>
Effect.gen(function* () { Effect.gen(function* () {
resetDeepInfraMock() resetDeepInfraMock()
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() const aisdk = yield* AISDK.Service
const sdkEvent = yield* plugin.trigger( yield* addPlugin(plugin, DeepInfraPlugin)
"aisdk.sdk", const language = yield* aisdk.language(
{ model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", {
model: new ModelV2.Info({ api: { type: "aisdk", package: "@ai-sdk/deepinfra" },
...ModelV2.Info.empty(
ProviderV2.ID.make("deepinfra"),
ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"),
),
api: {
id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"),
type: "aisdk",
package: "@ai-sdk/deepinfra",
},
}), }),
package: "@ai-sdk/deepinfra",
options: { name: "deepinfra" },
},
{},
) )
const result = yield* plugin.trigger(
"aisdk.language",
{ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options },
{},
)
const language = result.language ?? result.sdk.languageModel(result.model.api.id)
expect(language.provider).toBe("deepinfra.chat") expect(language.provider).toBe("deepinfra.chat")
expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"])
}), }),
@@ -1,40 +1,43 @@
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Cause, Effect, Layer } from "effect" import { Cause, Effect, Layer, Option } from "effect"
import fs from "fs/promises" import fs from "fs/promises"
import os from "os" import os from "os"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { AISDK } from "@opencode-ai/core/aisdk" import { AISDK } from "@opencode-ai/core/aisdk"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic" import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { host } from "./host"
import { fixtureProvider, it, model, npmLayer } from "./provider-helper"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const fixtureProviderPath = fileURLToPath(fixtureProvider) const fixtureProviderPath = fileURLToPath(fixtureProvider)
const it = testEffect(PluginTestLayer) const itWithAISDK = testEffect(
const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginTestLayer))) AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
)
function npmEntrypoint(entrypoint?: string) { function npmEntrypointLayer(entrypoint?: string) {
return Npm.Service.of({ return Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }), add: () => Effect.succeed({ directory: "", entrypoint }),
install: () => Effect.void, install: () => Effect.void,
which: () => Effect.succeed(undefined), which: () => Effect.succeed(undefined),
}) }),
)
} }
const addPlugin = Effect.fn(function* (npm?: Npm.Interface) { function dynamicPlugin(layer = npmLayer) {
const plugin = yield* PluginV2.Service return {
const host = yield* PluginHost.make()
yield* plugin.add({
id: DynamicProviderPlugin.id, id: DynamicProviderPlugin.id,
effect: DynamicProviderPlugin.effect(npm ? { ...host, npm } : host), effect: Effect.gen(function* () {
}) yield* DynamicProviderPlugin.effect(host({ npm: yield* Npm.Service }))
}) }).pipe(Effect.provide(layer)),
}
}
function tempEntrypoint(source: string) { function tempEntrypoint(source: string) {
return Effect.acquireRelease( return Effect.acquireRelease(
@@ -52,14 +55,11 @@ describe("DynamicProviderPlugin", () => {
it.effect("creates an SDK from a provider factory export", () => it.effect("creates an SDK from a provider factory export", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* plugin.add(dynamicPlugin())
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom", "test-model"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
}),
package: fixtureProvider, package: fixtureProvider,
options: { name: "custom", marker: "dynamic" }, options: { name: "custom", marker: "dynamic" },
}, },
@@ -74,14 +74,11 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const sdk = { marker: "existing" } const sdk = { marker: "existing" }
yield* addPlugin() yield* plugin.add(dynamicPlugin())
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom", "test-model"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
}),
package: fixtureProvider, package: fixtureProvider,
options: { name: "custom", marker: "dynamic" }, options: { name: "custom", marker: "dynamic" },
}, },
@@ -94,14 +91,11 @@ describe("DynamicProviderPlugin", () => {
it.effect("injects the provider ID as the SDK factory name", () => it.effect("injects the provider ID as the SDK factory name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* plugin.add(dynamicPlugin())
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-provider", "test-model"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
}),
package: fixtureProvider, package: fixtureProvider,
options: { name: "custom-provider", marker: "dynamic" }, options: { name: "custom-provider", marker: "dynamic" },
}, },
@@ -114,14 +108,11 @@ describe("DynamicProviderPlugin", () => {
it.effect("loads npm packages through their resolved import entrypoint", () => it.effect("loads npm packages through their resolved import entrypoint", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(npmEntrypoint(fixtureProviderPath)) yield* plugin.add(dynamicPlugin(npmEntrypointLayer(fixtureProviderPath)))
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("npm-provider", "test-model"),
...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" },
}),
package: "fixture-provider", package: "fixture-provider",
options: { name: "npm-provider", marker: "npm" }, options: { name: "npm-provider", marker: "npm" },
}, },
@@ -135,14 +126,9 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
yield* addPlugin(npmEntrypoint()) yield* plugin.add(dynamicPlugin(npmEntrypointLayer()))
const exit = yield* aisdk const exit = yield* aisdk
.language( .language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } }))
new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
}),
)
.pipe(Effect.exit) .pipe(Effect.exit)
expect(exit._tag).toBe("Failure") expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
@@ -153,13 +139,10 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
yield* addPlugin() yield* plugin.add(dynamicPlugin())
const exit = yield* aisdk const exit = yield* aisdk
.language( .language(
new ModelV2.Info({ model("bad-import", "alias", { api: { type: "aisdk", package: "file:///missing/provider-factory.js" } }),
...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" },
}),
) )
.pipe(Effect.exit) .pipe(Effect.exit)
expect(exit._tag).toBe("Failure") expect(exit._tag).toBe("Failure")
@@ -172,14 +155,9 @@ describe("DynamicProviderPlugin", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n") const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
yield* addPlugin(npmEntrypoint(tmp.entrypoint)) yield* plugin.add(dynamicPlugin(npmEntrypointLayer(tmp.entrypoint)))
const exit = yield* aisdk const exit = yield* aisdk
.language( .language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } }))
new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
}),
)
.pipe(Effect.exit) .pipe(Effect.exit)
expect(exit._tag).toBe("Failure") expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
@@ -190,10 +168,9 @@ describe("DynamicProviderPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
yield* addPlugin() yield* plugin.add(dynamicPlugin())
const language = yield* aisdk.language( const language = yield* aisdk.language(
new ModelV2.Info({ model("custom", "alias", {
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider }, api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider },
}), }),
) )
@@ -1,22 +1,11 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
import { ProviderV2 } from "@opencode-ai/core/provider" import { addPlugin, it, model } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const gatewayCalls: Record<string, unknown>[] = [] const gatewayCalls: Record<string, unknown>[] = []
const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"]
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: GatewayPlugin.id, effect: GatewayPlugin.effect(host) })
})
mock.module("@ai-sdk/gateway", () => ({ mock.module("@ai-sdk/gateway", () => ({
createGateway(options: Record<string, unknown>) { createGateway(options: Record<string, unknown>) {
@@ -38,17 +27,10 @@ describe("GatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gatewayCalls.length = 0 gatewayCalls.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GatewayPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/gateway",
options: { name: "gateway" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -60,19 +42,12 @@ describe("GatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gatewayCalls.length = 0 gatewayCalls.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GatewayPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("vercel", "anthropic/claude-sonnet-4"),
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")),
api: {
id: ModelV2.ID.make("anthropic/claude-sonnet-4"),
type: "aisdk",
package: "test-provider",
},
}),
package: "@ai-sdk/gateway", package: "@ai-sdk/gateway",
options: { name: "vercel", apiKey: "test-key" }, options: { name: "vercel", apiKey: "test-key" },
}, },
@@ -88,33 +63,19 @@ describe("GatewayPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gatewayCalls.length = 0 gatewayCalls.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GatewayPlugin)
for (const modelID of vercelGatewayModels) { for (const modelID of vercelGatewayModels) {
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("vercel", modelID), package: "@ai-sdk/vercel", options: { name: "vercel" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/vercel",
options: { name: "vercel" },
},
{}, {},
) )
expect(ignored.sdk).toBeUndefined() expect(ignored.sdk).toBeUndefined()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("vercel", modelID), package: "@ai-sdk/gateway", options: { name: "vercel" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/gateway",
options: { name: "vercel" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -3,51 +3,19 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import type { LanguageModelV3 } from "@ai-sdk/provider" import { addPlugin, fakeSelectorSdk, it, model, 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: GithubCopilotPlugin.id, effect: GithubCopilotPlugin.effect(host) })
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("GithubCopilotPlugin", () => { describe("GithubCopilotPlugin", () => {
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GithubCopilotPlugin)
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("github-copilot", "gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "github-copilot" }, options: { name: "github-copilot" },
}, },
@@ -56,10 +24,7 @@ describe("GithubCopilotPlugin", () => {
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("github-copilot", "gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/github-copilot", package: "@ai-sdk/github-copilot",
options: { name: "github-copilot" }, options: { name: "github-copilot" },
}, },
@@ -74,14 +39,11 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, GithubCopilotPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("github-copilot", "claude-sonnet-4"),
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")),
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -95,14 +57,11 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, GithubCopilotPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("github-copilot", "alias", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }),
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -116,65 +75,30 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, GithubCopilotPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")),
api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")),
api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")),
api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")),
api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
expect(calls).toEqual([ expect(calls).toEqual([
@@ -191,14 +115,11 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, GithubCopilotPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("github-copilot", "default", { api: { id: ModelV2.ID.make("gpt-5") } }),
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -207,10 +128,7 @@ describe("GithubCopilotPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("github-copilot", "small", { api: { id: ModelV2.ID.make("gpt-5-mini") } }),
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")),
api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -219,10 +137,7 @@ describe("GithubCopilotPlugin", () => {
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("github-copilot", "sonnet", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }),
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")),
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -234,12 +149,13 @@ describe("GithubCopilotPlugin", () => {
it.effect("disables gpt-5-chat-latest before Copilot language selection", () => it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
}) })
yield* addPlugin()
expect( expect(
required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
.enabled, .enabled,
@@ -249,12 +165,13 @@ describe("GithubCopilotPlugin", () => {
it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () => it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GithubCopilotPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
}) })
yield* addPlugin()
expect( expect(
required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
.enabled, .enabled,
@@ -266,17 +183,10 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, GithubCopilotPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
expect(calls).toEqual([]) expect(calls).toEqual([])
@@ -1,43 +1,12 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, it, model, required, withEnv } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const gitlabSDKOptions: Record<string, unknown>[] = [] const gitlabSDKOptions: Record<string, unknown>[] = []
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: GitLabPlugin.id, effect: GitLabPlugin.effect(host) })
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
void mock.module("gitlab-ai-provider", () => ({ void mock.module("gitlab-ai-provider", () => ({
VERSION: "test-version", VERSION: "test-version",
@@ -63,17 +32,10 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GitLabPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
}),
package: "gitlab-ai-provider",
options: { name: "gitlab" },
},
{}, {},
) )
expect(gitlabSDKOptions).toHaveLength(1) expect(gitlabSDKOptions).toHaveLength(1)
@@ -103,17 +65,10 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GitLabPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
}),
package: "gitlab-ai-provider",
options: { name: "gitlab" },
},
{}, {},
) )
expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example") expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example")
@@ -131,14 +86,11 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GitLabPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("gitlab", "claude"),
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
}),
package: "gitlab-ai-provider", package: "gitlab-ai-provider",
options: { options: {
name: "gitlab", name: "gitlab",
@@ -175,17 +127,10 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GitLabPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai",
options: { name: "gitlab" },
},
{}, {},
) )
expect(result.sdk).toBeUndefined() expect(result.sdk).toBeUndefined()
@@ -197,13 +142,11 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = [] const calls: [string, unknown][] = []
yield* addPlugin() yield* addPlugin(plugin, GitLabPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("gitlab", "duo-workflow-custom", {
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" },
request: { request: {
headers: {}, headers: {},
body: { workflowRef: "ref", workflowDefinition: "definition" }, body: { workflowRef: "ref", workflowDefinition: "definition" },
@@ -235,14 +178,11 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = [] const calls: [string, unknown][] = []
yield* addPlugin() yield* addPlugin(plugin, GitLabPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("gitlab", "duo-workflow-exact"),
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")),
api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" },
}),
sdk: { sdk: {
workflowChat: (id: string, options: unknown) => { workflowChat: (id: string, options: unknown) => {
calls.push([id, options]) calls.push([id, options])
@@ -265,13 +205,11 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = [] const calls: [string, unknown][] = []
yield* addPlugin() yield* addPlugin(plugin, GitLabPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("gitlab", "duo-workflow-custom", {
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" },
request: { request: {
headers: {}, headers: {},
body: { featureFlags: { request_flag: true } }, body: { featureFlags: { request_flag: true } },
@@ -296,13 +234,11 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = [] const calls: [string, unknown][] = []
yield* addPlugin() yield* addPlugin(plugin, GitLabPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("gitlab", "claude", {
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
request: { headers: { h: "v" }, body: {} }, request: { headers: { h: "v" }, body: {} },
}), }),
sdk: { sdk: {
@@ -1,50 +1,10 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: definition.id, effect: definition.effect(host) })
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
function selector(calls: string[]) {
return (id: string) => {
calls.push(`languageModel:${id}`)
return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3
}
}
describe("GoogleVertexAnthropicPlugin", () => { describe("GoogleVertexAnthropicPlugin", () => {
it.effect("resolves legacy project and location env on provider update", () => it.effect("resolves legacy project and location env on provider update", () =>
@@ -59,19 +19,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
}, },
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
}), }),
) )
yield* addPlugin(GoogleVertexAnthropicPlugin) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))
expect( expect(provider.request.body.project).toBe("cloud-project")
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project, expect(provider.request.body.location).toBe("cloud-location")
).toBe("cloud-project")
expect(
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location,
).toBe("cloud-location")
}), }),
), ),
) )
@@ -79,7 +37,9 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("keeps configured project and location over env fallback", () => it.effect("keeps configured project and location over env fallback", () =>
withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () => withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
@@ -87,13 +47,9 @@ describe("GoogleVertexAnthropicPlugin", () => {
provider.request.body.location = "configured-location" provider.request.body.location = "configured-location"
}), }),
) )
yield* addPlugin(GoogleVertexAnthropicPlugin) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe( expect(provider.request.body.project).toBe("configured-project")
"configured-project", expect(provider.request.body.location).toBe("configured-location")
)
expect(
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location,
).toBe("configured-location")
}), }),
), ),
) )
@@ -111,17 +67,11 @@ describe("GoogleVertexAnthropicPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(GoogleVertexAnthropicPlugin) yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("google-vertex-anthropic", "claude-sonnet-4-5"),
...ModelV2.Info.empty(
ProviderV2.ID.make("google-vertex-anthropic"),
ModelV2.ID.make("claude-sonnet-4-5"),
),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/google-vertex/anthropic", package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex-anthropic" }, options: { name: "google-vertex-anthropic" },
}, },
@@ -140,17 +90,11 @@ describe("GoogleVertexAnthropicPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(GoogleVertexAnthropicPlugin) yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("google-vertex-anthropic", "claude-sonnet-4-5"),
...ModelV2.Info.empty(
ProviderV2.ID.make("google-vertex-anthropic"),
ModelV2.ID.make("claude-sonnet-4-5"),
),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/google-vertex/anthropic", package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex-anthropic" }, options: { name: "google-vertex-anthropic" },
}, },
@@ -166,14 +110,11 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () => it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(GoogleVertexAnthropicPlugin) yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("google-vertex", "claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/google-vertex/anthropic", package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "eu" }, options: { name: "google-vertex", project: "project", location: "eu" },
}, },
@@ -188,14 +129,11 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("keeps configured baseURL for google-vertex Anthropic models", () => it.effect("keeps configured baseURL for google-vertex Anthropic models", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(GoogleVertexAnthropicPlugin) yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("google-vertex", "claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/google-vertex/anthropic", package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" },
}, },
@@ -208,15 +146,12 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("selects google-vertex Anthropic language models through V2 plugins", () => it.effect("selects google-vertex Anthropic language models through V2 plugins", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin(GoogleVertexPlugin) yield* addPlugin(plugin, GoogleVertexPlugin)
yield* addPlugin(GoogleVertexAnthropicPlugin) yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
const sdkResult = yield* plugin.trigger( const sdkResult = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("google-vertex", " claude-sonnet-4-5 "),
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/google-vertex/anthropic", package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "us" }, options: { name: "google-vertex", project: "project", location: "us" },
}, },
@@ -225,10 +160,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
const languageResult = yield* plugin.trigger( const languageResult = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("google-vertex", " claude-sonnet-4-5 "),
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
}),
sdk: sdkResult.sdk, sdk: sdkResult.sdk,
options: {}, options: {},
}, },
@@ -246,18 +178,12 @@ describe("GoogleVertexAnthropicPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(GoogleVertexAnthropicPlugin) yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("google-vertex-anthropic", " claude-sonnet-4-5 "),
...ModelV2.Info.empty( sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
ProviderV2.ID.make("google-vertex-anthropic"),
ModelV2.ID.make(" claude-sonnet-4-5 "),
),
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: selector(calls) },
options: {}, options: {},
}, },
{}, {},
@@ -270,15 +196,12 @@ describe("GoogleVertexAnthropicPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin(GoogleVertexAnthropicPlugin) yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("google-vertex", "claude-sonnet-4-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: selector(calls) },
options: {}, options: {},
}, },
{}, {},
@@ -1,63 +1,13 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import type { LanguageModelV3 } from "@ai-sdk/provider" import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const vertexOptions: Record<string, any>[] = [] const vertexOptions: Record<string, any>[] = []
const googleAuthOptions: Record<string, any>[] = [] const googleAuthOptions: Record<string, any>[] = []
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: GoogleVertexPlugin.id, effect: GoogleVertexPlugin.effect(host) })
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
void mock.module("@ai-sdk/google-vertex", () => ({ void mock.module("@ai-sdk/google-vertex", () => ({
createVertex: (options: Record<string, any>) => { createVertex: (options: Record<string, any>) => {
@@ -87,7 +37,9 @@ void mock.module("google-auth-library", () => ({
describe("GoogleVertexPlugin", () => { describe("GoogleVertexPlugin", () => {
it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () => it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.opencode, (provider) => { catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
provider.api = { provider.api = {
@@ -97,7 +49,6 @@ describe("GoogleVertexPlugin", () => {
} }
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode)) const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode))
expect(provider.request.body).toEqual({}) expect(provider.request.body).toEqual({})
@@ -116,7 +67,9 @@ describe("GoogleVertexPlugin", () => {
}, },
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = { provider.api = {
@@ -126,7 +79,6 @@ describe("GoogleVertexPlugin", () => {
} }
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
expect(provider.request.body.project).toBe("google-cloud-project") expect(provider.request.body.project).toBe("google-cloud-project")
expect(provider.request.body.location).toBe("google-vertex-location") expect(provider.request.body.location).toBe("google-vertex-location")
@@ -155,6 +107,7 @@ describe("GoogleVertexPlugin", () => {
vertexOptions.length = 0 vertexOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = { provider.api = {
@@ -164,18 +117,12 @@ describe("GoogleVertexPlugin", () => {
} }
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("google-vertex", "gemini", {
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
api: {
id: ModelV2.ID.make("gemini"),
type: "aisdk",
package: "@ai-sdk/google-vertex",
},
}), }),
package: "@ai-sdk/google-vertex", package: "@ai-sdk/google-vertex",
options: { name: "google-vertex" }, options: { name: "google-vertex" },
@@ -207,7 +154,9 @@ describe("GoogleVertexPlugin", () => {
}, },
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = { provider.api = {
@@ -219,7 +168,6 @@ describe("GoogleVertexPlugin", () => {
provider.request.body.location = "global" provider.request.body.location = "global"
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.project).toBe("config-project")
expect(provider.request.body.location).toBe("global") expect(provider.request.body.location).toBe("global")
@@ -234,7 +182,9 @@ describe("GoogleVertexPlugin", () => {
it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () => it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = { provider.api = {
@@ -246,7 +196,6 @@ describe("GoogleVertexPlugin", () => {
provider.request.body.location = "eu" provider.request.body.location = "eu"
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
expect(provider.api).toEqual({ expect(provider.api).toEqual({
type: "aisdk", type: "aisdk",
@@ -268,14 +217,15 @@ describe("GoogleVertexPlugin", () => {
}, },
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, GoogleVertexPlugin)
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" }
provider.request.body.project = "config-project" provider.request.body.project = "config-project"
}), }),
) )
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.project).toBe("config-project")
expect(provider.request.body.location).toBe("us-central1") expect(provider.request.body.location).toBe("us-central1")
@@ -293,17 +243,12 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
vertexOptions.length = 0 vertexOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GoogleVertexPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("google-vertex", "gemini", {
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
api: {
id: ModelV2.ID.make("gemini"),
type: "aisdk",
package: "@ai-sdk/google-vertex",
},
}), }),
package: "@ai-sdk/google-vertex", package: "@ai-sdk/google-vertex",
options: { name: "google-vertex" }, options: { name: "google-vertex" },
@@ -323,8 +268,11 @@ describe("GoogleVertexPlugin", () => {
googleAuthOptions.length = 0 googleAuthOptions.length = 0
const fetchCalls: { input: Parameters<typeof fetch>[0]; init?: RequestInit }[] = [] const fetchCalls: { input: Parameters<typeof fetch>[0]; init?: RequestInit }[] = []
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GoogleVertexPlugin)
yield* plugin.hook("aisdk.sdk", (evt) => yield* plugin.add({
id: PluginV2.ID.make("capture-openai-compatible"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.promise(async () => { Effect.promise(async () => {
if (evt.model.providerID !== "google-vertex") return if (evt.model.providerID !== "google-vertex") return
if (evt.package !== "@ai-sdk/openai-compatible") return if (evt.package !== "@ai-sdk/openai-compatible") return
@@ -333,7 +281,8 @@ describe("GoogleVertexPlugin", () => {
headers: { "x-test": "1" }, headers: { "x-test": "1" },
}) })
}), }),
) }),
})
const originalFetch = fetch const originalFetch = fetch
;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async ( ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async (
input: Parameters<typeof fetch>[0], input: Parameters<typeof fetch>[0],
@@ -348,13 +297,8 @@ describe("GoogleVertexPlugin", () => {
plugin.trigger( plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("google-vertex", "gemini", {
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
api: {
id: ModelV2.ID.make("gemini"),
type: "aisdk",
package: "@ai-sdk/openai-compatible",
},
}), }),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "google-vertex" }, options: { name: "google-vertex" },
@@ -378,14 +322,11 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, GoogleVertexPlugin)
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("google-vertex", " gemini-2.5-pro "),
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")),
api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" },
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -1,33 +1,26 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google" import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { addPlugin, it, model } from "./provider-helper"
const it = testEffect(PluginTestLayer) const itWithAISDK = testEffect(
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
const addPlugin = Effect.fn(function* () { )
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: GooglePlugin.id, effect: GooglePlugin.effect(host) })
})
describe("GooglePlugin", () => { describe("GooglePlugin", () => {
it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GooglePlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-google", "gemini"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")),
api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" },
}),
package: "@ai-sdk/google", package: "@ai-sdk/google",
options: { name: "custom-google", apiKey: "test" }, options: { name: "custom-google", apiKey: "test" },
}, },
@@ -41,49 +34,34 @@ describe("GooglePlugin", () => {
it.effect("ignores non-Google SDK packages", () => it.effect("ignores non-Google SDK packages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GooglePlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")),
api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" },
}),
package: "@ai-sdk/google-vertex",
options: { name: "google" },
},
{}, {},
) )
expect(result.sdk).toBeUndefined() expect(result.sdk).toBeUndefined()
}), }),
) )
it.effect("uses default languageModel loading with provider ID parity", () => itWithAISDK.effect("uses default languageModel loading with provider ID parity", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() const aisdk = yield* AISDK.Service
const sdkEvent = yield* plugin.trigger( yield* addPlugin(plugin, GooglePlugin)
"aisdk.sdk", const language = yield* aisdk.language(
{ model("custom-google", "alias", {
model: new ModelV2.Info({ api: {
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), id: ModelV2.ID.make("gemini-api"),
api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" }, type: "aisdk",
}),
package: "@ai-sdk/google", package: "@ai-sdk/google",
options: { name: "custom-google", apiKey: "test" },
}, },
{}, request: {
) headers: {},
const result = yield* plugin.trigger( body: { apiKey: "test" },
"aisdk.language",
{
model: sdkEvent.model,
sdk: sdkEvent.sdk,
options: sdkEvent.options,
}, },
{}, }),
) )
const language = result.language ?? result.sdk.languageModel(result.model.api.id)
expect(language.modelId).toBe("gemini-api") expect(language.modelId).toBe("gemini-api")
expect(language.provider).toBe("custom-google") expect(language.provider).toBe("custom-google")
}), }),
+26 -60
View File
@@ -1,37 +1,26 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { createGroq } from "@ai-sdk/groq" import { createGroq } from "@ai-sdk/groq"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
import { ProviderV2 } from "@opencode-ai/core/provider" import { addPlugin, it, model } from "./provider-helper"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer) const aisdkIt = testEffect(
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
const addPlugin = Effect.fn(function* () { )
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: GroqPlugin.id, effect: GroqPlugin.effect(host) })
})
describe("GroqPlugin", () => { describe("GroqPlugin", () => {
it.effect("creates a Groq SDK for @ai-sdk/groq", () => it.effect("creates a Groq SDK for @ai-sdk/groq", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GroqPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
}),
package: "@ai-sdk/groq",
options: { name: "groq" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -41,17 +30,10 @@ describe("GroqPlugin", () => {
it.effect("ignores non-Groq SDK packages", () => it.effect("ignores non-Groq SDK packages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GroqPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "groq" },
},
{}, {},
) )
expect(result.sdk).toBeUndefined() expect(result.sdk).toBeUndefined()
@@ -61,17 +43,10 @@ describe("GroqPlugin", () => {
it.effect("only matches the bundled @ai-sdk/groq package exactly", () => it.effect("only matches the bundled @ai-sdk/groq package exactly", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GroqPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
}),
package: "@ai-sdk/groq/compat",
options: { name: "groq" },
},
{}, {},
) )
expect(result.sdk).toBeUndefined() expect(result.sdk).toBeUndefined()
@@ -81,14 +56,11 @@ describe("GroqPlugin", () => {
it.effect("matches the old bundled Groq SDK provider naming", () => it.effect("matches the old bundled Groq SDK provider naming", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, GroqPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-groq", "llama"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")),
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
}),
package: "@ai-sdk/groq", package: "@ai-sdk/groq",
options: { name: "custom-groq", apiKey: "test" }, options: { name: "custom-groq", apiKey: "test" },
}, },
@@ -103,32 +75,26 @@ describe("GroqPlugin", () => {
}), }),
) )
it.effect("uses the default languageModel(api.id) behavior", () => aisdkIt.effect("uses the default languageModel(api.id) behavior", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() const aisdk = yield* AISDK.Service
const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters<typeof createGroq>[0] & { yield* addPlugin(plugin, GroqPlugin)
name: string const result = yield* aisdk.language(
}) model("groq", "alias", {
const result = yield* plugin.trigger(
"aisdk.language",
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")),
api: { api: {
id: ModelV2.ID.make("llama-api"), id: ModelV2.ID.make("llama-api"),
type: "aisdk", type: "aisdk",
package: "@ai-sdk/groq", package: "@ai-sdk/groq",
}, },
}), request: {
sdk, headers: {},
options: { name: "groq", apiKey: "test" }, body: { apiKey: "test" },
}, },
{}, }),
) )
const language = result.language ?? sdk.languageModel(result.model.api.id) expect(result.modelId).toBe("llama-api")
expect(language.modelId).toBe("llama-api") expect(result.provider).toBe("groq.chat")
expect(language.provider).toBe("groq.chat")
}), }),
) )
}) })
@@ -0,0 +1,189 @@
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))
}
+45 -47
View File
@@ -2,98 +2,96 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: KiloPlugin.id, effect: KiloPlugin.effect(host) })
})
describe("KiloPlugin", () => { describe("KiloPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () => it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))), Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"kilo",
),
),
) )
it.effect("applies legacy referer headers only to kilo", () => it.effect("applies legacy referer headers only to kilo", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, KiloPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { const kilo = provider("kilo", {
provider.api = { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
type: "aisdk", request: { headers: { Existing: "value" }, body: {} },
package: "@ai-sdk/openai-compatible",
url: "https://api.kilo.ai/api/gateway",
}
provider.request = { headers: { Existing: "value" }, body: {} }
}) })
catalog.provider.update(ProviderV2.ID.openrouter, () => {}) catalog.provider.update(kilo.id, (draft) => {
draft.api = kilo.api
draft.request = kilo.request
}) })
yield* addPlugin() catalog.provider.update(provider("openrouter").id, () => {})
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ })
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
}), }),
) )
it.effect("uses the exact legacy Kilo header casing and set", () => it.effect("uses the exact legacy Kilo header casing and set", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, KiloPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { const item = provider("kilo", {
provider.api = { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
type: "aisdk", })
package: "@ai-sdk/openai-compatible", catalog.provider.update(item.id, (draft) => {
url: "https://api.kilo.ai/api/gateway", draft.api = item.api
}
}) })
}) })
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ const result = required(yield* catalog.provider.get(ProviderV2.ID.make("kilo")))
expect(result.request.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty( expect(result.request.headers).not.toHaveProperty("http-referer")
"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("x-title")
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("X-Source")
}), }),
) )
it.effect("uses the legacy provider-id guard instead of endpoint package matching", () => it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, KiloPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { const kilo = provider("kilo", {
provider.api = { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.kilo.ai/api/gateway",
}
}) })
catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => { catalog.provider.update(kilo.id, (draft) => {
provider.api = { type: "aisdk", package: "kilo" } draft.api = kilo.api
})
const custom = provider("custom-kilo", {
api: { type: "aisdk", package: "kilo" },
})
catalog.provider.update(custom.id, (draft) => {
draft.api = custom.api
}) })
}) })
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.request.headers).toEqual({}) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({})
}), }),
) )
}) })
@@ -3,28 +3,36 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { expectPluginRegistered, it, provider, required } from "./provider-helper"
import { PluginTestLayer } from "./fixture" import { catalogHost, host, integrationHost } from "./host"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: LLMGatewayPlugin.id, effect: LLMGatewayPlugin.effect(host) })
})
describe("LLMGatewayPlugin", () => { describe("LLMGatewayPlugin", () => {
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
yield* plugin.add({
...LLMGatewayPlugin,
effect: LLMGatewayPlugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
),
})
})
it.effect("is registered so legacy referer headers can be applied", () => it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))), Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"llmgateway",
),
),
) )
it.effect("applies legacy referer headers only to enabled llmgateway", () => it.effect("applies legacy referer headers only to enabled llmgateway", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
yield* integrations.transform((editor) => { yield* integrations.transform((editor) => {
@@ -32,48 +40,43 @@ describe("LLMGatewayPlugin", () => {
editor.update(Integration.ID.make("openrouter"), () => {}) editor.update(Integration.ID.make("openrouter"), () => {})
}) })
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { const llmgateway = provider("llmgateway", {
provider.api = { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
type: "aisdk", request: { headers: { Existing: "value" }, body: {} },
package: "@ai-sdk/openai-compatible", })
url: "https://api.llmgateway.io/v1", catalog.provider.update(llmgateway.id, (draft) => {
} draft.api = llmgateway.api
provider.request = { headers: { Existing: "value" }, body: {} } draft.request = llmgateway.request
}) })
catalog.provider.update(ProviderV2.ID.openrouter, () => {}) catalog.provider.update(ProviderV2.ID.openrouter, () => {})
}) })
yield* addPlugin() yield* add(plugin)
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-Source": "opencode", "X-Source": "opencode",
}) })
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
}), }),
) )
it.effect("does not apply legacy headers to a disabled llmgateway provider", () => it.effect("does not apply legacy headers to a disabled llmgateway provider", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service yield* add(plugin)
yield* integrations.transform((editor) => {
editor.update(Integration.ID.make("llmgateway"), () => {})
})
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { const item = provider("llmgateway", {
provider.disabled = true api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
provider.api = { })
type: "aisdk", catalog.provider.update(item.id, (draft) => {
package: "@ai-sdk/openai-compatible", draft.api = item.api
url: "https://api.llmgateway.io/v1",
}
}) })
}) })
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({}) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
}), }),
) )
}) })
@@ -1,37 +1,18 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
import { ProviderV2 } from "@opencode-ai/core/provider" import { addPlugin, fakeSelectorSdk, it, model } 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: MistralPlugin.id, effect: MistralPlugin.effect(host) })
})
describe("MistralPlugin", () => { describe("MistralPlugin", () => {
it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => it.effect("creates a Mistral SDK for @ai-sdk/mistral", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, MistralPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/mistral",
options: { name: "mistral" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -41,14 +22,11 @@ describe("MistralPlugin", () => {
it.effect("ignores non-Mistral SDK packages", () => it.effect("ignores non-Mistral SDK packages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, MistralPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("mistral", "mistral-large"),
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "mistral" }, options: { name: "mistral" },
}, },
@@ -62,22 +40,19 @@ describe("MistralPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providers: string[] = [] const providers: string[] = []
yield* addPlugin() yield* addPlugin(plugin, MistralPlugin)
yield* plugin.hook("aisdk.sdk", (event) => yield* plugin.add({
id: PluginV2.ID.make("mistral-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => { Effect.sync(() => {
providers.push(event.sdk.languageModel("mistral-large").provider) providers.push(evt.sdk.languageModel("mistral-large").provider)
}), }),
) }),
})
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/mistral",
options: { name: "mistral" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -89,19 +64,20 @@ describe("MistralPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providers: string[] = [] const providers: string[] = []
yield* addPlugin() yield* addPlugin(plugin, MistralPlugin)
yield* plugin.hook("aisdk.sdk", (event) => yield* plugin.add({
id: PluginV2.ID.make("mistral-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => { Effect.sync(() => {
providers.push(event.sdk.languageModel("mistral-large").provider) providers.push(evt.sdk.languageModel("mistral-large").provider)
}), }),
) }),
})
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-mistral", "mistral-large"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")),
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/mistral", package: "@ai-sdk/mistral",
options: { name: "custom-mistral" }, options: { name: "custom-mistral" },
}, },
@@ -115,23 +91,11 @@ describe("MistralPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
const sdk = { const sdk = fakeSelectorSdk(calls)
languageModel: (id: string) => { yield* addPlugin(plugin, MistralPlugin)
calls.push(`languageModel:${id}`)
return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3
},
}
yield* addPlugin()
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
}),
sdk,
options: {},
},
{}, {},
) )
const language = result.language ?? sdk.languageModel(result.model.api.id) const language = result.language ?? sdk.languageModel(result.model.api.id)
@@ -2,66 +2,64 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: NvidiaPlugin.id, effect: NvidiaPlugin.effect(host) })
})
describe("NvidiaPlugin", () => { describe("NvidiaPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () => it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))), Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"nvidia",
),
),
) )
it.effect("applies NVIDIA tracking headers only to nvidia", () => it.effect("applies NVIDIA tracking headers only to nvidia", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, NvidiaPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { const nvidia = provider("nvidia", {
provider.api = { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
type: "aisdk", request: { headers: { Existing: "value" }, body: {} },
package: "@ai-sdk/openai-compatible",
url: "https://integrate.api.nvidia.com/v1",
}
provider.request = { headers: { Existing: "value" }, body: {} }
}) })
catalog.provider.update(ProviderV2.ID.openrouter, () => {}) catalog.provider.update(nvidia.id, (draft) => {
draft.api = nvidia.api
draft.request = nvidia.request
}) })
yield* addPlugin() catalog.provider.update(provider("openrouter").id, () => {})
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ })
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode", "X-BILLING-INVOKE-ORIGIN": "OpenCode",
}) })
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
}), }),
) )
it.effect("adds billing origin for custom NVIDIA endpoints", () => it.effect("adds billing origin for custom NVIDIA endpoints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, NvidiaPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { const item = provider("nvidia", {
provider.api = { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
type: "aisdk", request: { headers: {}, body: {} },
package: "@ai-sdk/openai-compatible", })
url: "https://integrate.api.nvidia.com/v1", catalog.provider.update(item.id, (draft) => {
} draft.api = item.api
draft.request = item.request
}) })
}) })
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode", "X-BILLING-INVOKE-ORIGIN": "OpenCode",
@@ -71,23 +69,24 @@ describe("NvidiaPlugin", () => {
it.effect("preserves an explicit NVIDIA billing origin header", () => it.effect("preserves an explicit NVIDIA billing origin header", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, NvidiaPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { const item = provider("nvidia", {
provider.api = { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
type: "aisdk", request: {
package: "@ai-sdk/openai-compatible",
url: "https://integrate.api.nvidia.com/v1",
}
provider.request = {
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
body: { baseURL: "https://integrate.api.nvidia.com/v1" }, body: { baseURL: "https://integrate.api.nvidia.com/v1" },
} },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
draft.request = item.request
}) })
}) })
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
@@ -1,45 +1,23 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible" import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible"
import { ProviderV2 } from "@opencode-ai/core/provider" import { addPlugin, it, model } 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: OpenAICompatiblePlugin.id, effect: OpenAICompatiblePlugin.effect(host) })
})
describe("OpenAICompatiblePlugin", () => { describe("OpenAICompatiblePlugin", () => {
it.effect("preserves explicit includeUsage false and defaults it to true", () => it.effect("preserves explicit includeUsage false and defaults it to true", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, OpenAICompatiblePlugin)
const defaulted = yield* plugin.trigger( const defaulted = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "custom" },
},
{}, {},
) )
const disabled = yield* plugin.trigger( const disabled = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom", "model"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "custom", includeUsage: false }, options: { name: "custom", includeUsage: false },
}, },
@@ -53,14 +31,11 @@ describe("OpenAICompatiblePlugin", () => {
it.effect("defaults includeUsage for OpenAI-compatible package matches", () => it.effect("defaults includeUsage for OpenAI-compatible package matches", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, OpenAICompatiblePlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom", "model"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", package: "file:///tmp/@ai-sdk/openai-compatible-provider.js",
options: { name: "custom" }, options: { name: "custom" },
}, },
@@ -74,19 +49,20 @@ describe("OpenAICompatiblePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const observed: string[] = [] const observed: string[] = []
yield* addPlugin() yield* addPlugin(plugin, OpenAICompatiblePlugin)
yield* plugin.hook("aisdk.sdk", (event) => yield* plugin.add({
id: PluginV2.ID.make("inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => { Effect.sync(() => {
observed.push(event.sdk.languageModel("model").provider) observed.push(evt.sdk.languageModel("model").provider)
}), }),
) }),
})
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-provider", "model"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "custom-provider", baseURL: "https://example.com/v1" }, options: { name: "custom-provider", baseURL: "https://example.com/v1" },
}, },
@@ -109,14 +85,11 @@ describe("OpenAICompatiblePlugin", () => {
}), }),
}), }),
}) })
yield* addPlugin() yield* addPlugin(plugin, OpenAICompatiblePlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("cloudflare-workers-ai", "model"),
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai" }, options: { name: "cloudflare-workers-ai" },
}, },
@@ -1,50 +1,28 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, provider, required } from "./provider-helper"
import { PluginTestLayer } from "./fixture" import { host, integrationHost } from "./host"
const it = testEffect(PluginTestLayer) function add(plugin: PluginV2.Interface, integrations: Integration.Interface) {
return plugin.add({
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
const integrations = yield* Integration.Service
yield* plugin.add({
id: OpenAIPlugin.id, id: OpenAIPlugin.id,
effect: OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)), effect: OpenAIPlugin.effect(host({ integration: integrationHost(integrations) })).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", () => { describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () => it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* addPlugin() const plugin = yield* PluginV2.Service
yield* add(plugin, yield* Integration.Service)
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([ expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
{ {
id: Integration.MethodID.make("chatgpt-browser"), id: Integration.MethodID.make("chatgpt-browser"),
@@ -63,14 +41,11 @@ describe("OpenAIPlugin", () => {
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* add(plugin, yield* Integration.Service)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-openai", "gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai", package: "@ai-sdk/openai",
options: { name: "custom-openai", apiKey: "test" }, options: { name: "custom-openai", apiKey: "test" },
}, },
@@ -83,17 +58,10 @@ describe("OpenAIPlugin", () => {
it.effect("ignores non-OpenAI SDK packages", () => it.effect("ignores non-OpenAI SDK packages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* add(plugin, yield* Integration.Service)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "openai" },
},
{}, {},
) )
expect(result.sdk).toBeUndefined() expect(result.sdk).toBeUndefined()
@@ -104,12 +72,11 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* add(plugin, yield* Integration.Service)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("openai", "alias", {
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}), }),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
@@ -126,17 +93,10 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* add(plugin, yield* Integration.Service)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")),
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
expect(calls).toEqual([]) expect(calls).toEqual([])
@@ -146,19 +106,17 @@ describe("OpenAIPlugin", () => {
it.effect("disables gpt-5-chat-latest during catalog transforms", () => it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* add(plugin, yield* Integration.Service)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = new ProviderV2.Info({ const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } })
...ProviderV2.Info.empty(ProviderV2.ID.openai),
api: { type: "aisdk", package: "@ai-sdk/openai" },
})
catalog.provider.update(item.id, (draft) => { catalog.provider.update(item.id, (draft) => {
draft.api = item.api draft.api = item.api
}) })
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
}) })
yield* addPlugin()
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true)
expect( expect(
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled, required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
@@ -168,18 +126,14 @@ describe("OpenAIPlugin", () => {
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () => it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* add(plugin, yield* Integration.Service)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const item = new ProviderV2.Info({ const item = provider("custom-openai")
...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")), catalog.provider.update(item.id, () => {})
api: { type: "aisdk", package: "test-provider" },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
}) })
yield* addPlugin()
expect( expect(
required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))) required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest")))
.enabled, .enabled,
@@ -1,72 +1,45 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { AbsolutePath } from "@opencode-ai/core/schema"
import { PluginTestLayer } from "./fixture" import { location } from "../fixture/location"
import { it, model, provider, required, withEnv } from "./provider-helper"
const it = testEffect(PluginTestLayer) import { catalogHost, host, integrationHost } from "./host"
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: OpencodePlugin.id, effect: OpencodePlugin.effect(host) })
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const pluginWithIntegrations = (catalog: Catalog.Interface, integrations: Integration.Interface) => ({
...OpencodePlugin,
effect: OpencodePlugin.effect(host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) })),
})
describe("OpencodePlugin", () => { describe("OpencodePlugin", () => {
it.effect("uses a public key and disables paid models without credentials", () => it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const provider = new ProviderV2.Info({ const item = provider("opencode")
...ProviderV2.Info.empty(ProviderV2.ID.opencode), catalog.provider.update(item.id, () => {})
api: { type: "aisdk", package: "test-provider" }, const paid = model("opencode", "paid", { cost: cost(1) })
}) catalog.model.update(item.id, paid.id, (draft) => {
const model = new ModelV2.Info({ draft.cost = [...paid.cost]
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
}), }),
@@ -76,23 +49,17 @@ describe("OpencodePlugin", () => {
it.effect("keeps free models without credentials", () => it.effect("keeps free models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const provider = new ProviderV2.Info({ const item = provider("opencode")
...ProviderV2.Info.empty(ProviderV2.ID.opencode), catalog.provider.update(item.id, () => {})
api: { type: "aisdk", package: "test-provider" }, const free = model("opencode", "free", { cost: cost(0) })
}) catalog.model.update(item.id, free.id, (draft) => {
const model = new ModelV2.Info({ draft.cost = [...free.cost]
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" },
cost: cost(0),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
}), }),
@@ -102,23 +69,17 @@ describe("OpencodePlugin", () => {
it.effect("treats output-only cost as free without credentials", () => it.effect("treats output-only cost as free without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const provider = new ProviderV2.Info({ const item = provider("opencode")
...ProviderV2.Info.empty(ProviderV2.ID.opencode), catalog.provider.update(item.id, () => {})
api: { type: "aisdk", package: "test-provider" }, const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
}) catalog.model.update(item.id, outputOnly.id, (draft) => {
const model = new ModelV2.Info({ draft.cost = [...outputOnly.cost]
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" },
cost: cost(0, 1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe( expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
true, true,
@@ -130,23 +91,17 @@ describe("OpencodePlugin", () => {
it.effect("uses OPENCODE_API_KEY as credentials", () => it.effect("uses OPENCODE_API_KEY as credentials", () =>
withEnv({ OPENCODE_API_KEY: "secret" }, () => withEnv({ OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const provider = new ProviderV2.Info({ const item = provider("opencode")
...ProviderV2.Info.empty(ProviderV2.ID.opencode), catalog.provider.update(item.id, () => {})
api: { type: "aisdk", package: "test-provider" }, const paid = model("opencode", "paid", { cost: cost(1) })
}) catalog.model.update(item.id, paid.id, (draft) => {
const model = new ModelV2.Info({ draft.cost = [...paid.cost]
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
@@ -156,8 +111,10 @@ describe("OpencodePlugin", () => {
it.effect("uses configured provider env vars as credentials", () => it.effect("uses configured provider env vars as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () => withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
yield* plugin.add(pluginWithIntegrations(catalog, integrations))
yield* integrations.transform((editor) => { yield* integrations.transform((editor) => {
editor.method.update({ editor.method.update({
integrationID: Integration.ID.make("opencode"), integrationID: Integration.ID.make("opencode"),
@@ -165,21 +122,13 @@ describe("OpencodePlugin", () => {
}) })
}) })
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const provider = new ProviderV2.Info({ const item = provider("opencode")
...ProviderV2.Info.empty(ProviderV2.ID.opencode), catalog.provider.update(item.id, () => {})
api: { type: "aisdk", package: "test-provider" }, const paid = model("opencode", "paid", { cost: cost(1) })
}) catalog.model.update(item.id, paid.id, (draft) => {
const model = new ModelV2.Info({ draft.cost = [...paid.cost]
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
@@ -189,29 +138,24 @@ describe("OpencodePlugin", () => {
it.effect("uses configured apiKey as credentials", () => it.effect("uses configured apiKey as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const provider = new ProviderV2.Info({ const item = provider("opencode", {
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
api: { type: "aisdk", package: "test-provider" },
request: { request: {
headers: {}, headers: {},
body: { apiKey: "configured" }, body: { apiKey: "configured" },
}, },
}) })
const model = new ModelV2.Info({ catalog.provider.update(item.id, (draft) => {
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), draft.request = item.request
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
cost: cost(1),
}) })
catalog.provider.update(provider.id, (draft) => { const paid = model("opencode", "paid", { cost: cost(1) })
draft.request = provider.request catalog.model.update(item.id, paid.id, (draft) => {
}) draft.cost = [...paid.cost]
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured") expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
@@ -221,23 +165,17 @@ describe("OpencodePlugin", () => {
it.effect("ignores non-opencode providers and models", () => it.effect("ignores non-opencode providers and models", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
const provider = new ProviderV2.Info({ const item = provider("openai")
...ProviderV2.Info.empty(ProviderV2.ID.openai), catalog.provider.update(item.id, () => {})
api: { type: "aisdk", package: "test-provider" }, const paid = model("openai", "paid", { cost: cost(1) })
}) catalog.model.update(item.id, paid.id, (draft) => {
const model = new ModelV2.Info({ draft.cost = [...paid.cost]
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
@@ -268,6 +206,8 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID) const selected = yield* catalog.model.small(providerID)
expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano")) expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}), }).pipe(
Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))),
),
) )
}) })
@@ -3,59 +3,56 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, expectPluginRegistered, it, model, provider, required } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: OpenRouterPlugin.id, effect: OpenRouterPlugin.effect(host) })
})
describe("OpenRouterPlugin", () => { describe("OpenRouterPlugin", () => {
it.effect("is registered so legacy OpenRouter behavior can be applied", () => it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))), Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"openrouter",
),
),
) )
it.effect("applies legacy referer headers only to openrouter", () => it.effect("applies legacy referer headers only to openrouter", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, OpenRouterPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { const openrouter = provider("openrouter", {
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
provider.request = { headers: { Existing: "value" }, body: {} } request: { headers: { Existing: "value" }, body: {} },
})
catalog.provider.update(openrouter.id, (item) => {
item.api = openrouter.api
item.request = openrouter.request
}) })
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {}) catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
}) })
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({}) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({})
}), }),
) )
it.effect("creates an SDK only for the OpenRouter package", () => it.effect("creates an SDK only for the OpenRouter package", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, OpenRouterPlugin)
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("openrouter", "openai/gpt-5"),
...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "openrouter" }, options: { name: "openrouter" },
}, },
@@ -65,14 +62,7 @@ describe("OpenRouterPlugin", () => {
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")),
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
}),
package: "@openrouter/ai-sdk-provider",
options: { name: "custom" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -81,37 +71,52 @@ describe("OpenRouterPlugin", () => {
it.effect("filters OpenRouter's gpt-5 chat alias", () => it.effect("filters OpenRouter's gpt-5 chat alias", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, OpenRouterPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { const openrouter = provider("openrouter", {
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
})
catalog.provider.update(openrouter.id, (item) => {
item.api = openrouter.api
}) })
catalog.provider.update(ProviderV2.ID.openai, () => {}) catalog.provider.update(ProviderV2.ID.openai, () => {})
catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) for (const item of [
catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5"), () => {}) model("openrouter", "openai/gpt-5-chat"),
catalog.model.update(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) model("openrouter", "openai/gpt-5"),
model("openai", "openai/gpt-5-chat"),
]) {
catalog.model.update(item.providerID, item.id, () => {})
}
}) })
yield* addPlugin()
expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe( expect(
false, required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat")))
) .enabled,
expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")))?.enabled).toBe(true) ).toBe(false)
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(true) 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)
}), }),
) )
it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () => it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, OpenRouterPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
}) })
yield* addPlugin()
expect( expect(
(yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"))) required(
?.enabled, yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")),
).enabled,
).toBe(true) ).toBe(true)
}), }),
) )
@@ -1,50 +1,18 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
import { ProviderV2 } from "@opencode-ai/core/provider" import { addPlugin, fakeSelectorSdk, it, model } 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: PerplexityPlugin.id, effect: PerplexityPlugin.effect(host) })
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("PerplexityPlugin", () => { describe("PerplexityPlugin", () => {
it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, PerplexityPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/perplexity",
options: { name: "perplexity" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -54,14 +22,11 @@ describe("PerplexityPlugin", () => {
it.effect("ignores packages that are not the bundled Perplexity package", () => it.effect("ignores packages that are not the bundled Perplexity package", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, PerplexityPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("perplexity", "sonar"),
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/perplexity-compatible", package: "@ai-sdk/perplexity-compatible",
options: { name: "perplexity" }, options: { name: "perplexity" },
}, },
@@ -74,40 +39,50 @@ describe("PerplexityPlugin", () => {
it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() const providers: string[] = []
const result = yield* plugin.trigger( yield* addPlugin(plugin, PerplexityPlugin)
"aisdk.sdk", yield* plugin.add({
{ id: PluginV2.ID.make("perplexity-sdk-inspector"),
model: new ModelV2.Info({ effect: Effect.succeed({
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), "aisdk.sdk": (evt) =>
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, Effect.sync(() => {
providers.push(evt.sdk.languageModel("sonar").provider)
}), }),
package: "@ai-sdk/perplexity", }),
options: { name: "perplexity" }, })
}, yield* plugin.trigger(
"aisdk.sdk",
{ model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } },
{}, {},
) )
expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") expect(providers).toEqual(["perplexity"])
}), }),
) )
it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => it.effect("creates bundled Perplexity SDKs for custom provider IDs", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() const providers: string[] = []
const result = yield* plugin.trigger( 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(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-perplexity", "sonar"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")),
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/perplexity", package: "@ai-sdk/perplexity",
options: { name: "custom-perplexity" }, options: { name: "custom-perplexity" },
}, },
{}, {},
) )
expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") expect(providers).toEqual(["perplexity"])
}), }),
) )
@@ -115,14 +90,11 @@ describe("PerplexityPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, PerplexityPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("perplexity", "alias", { api: { id: ModelV2.ID.make("sonar") } }),
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -1,54 +1,16 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core"
import { ProviderV2 } from "@opencode-ai/core/provider" import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper"
import { testEffect } from "../lib/effect" import { host } from "./host"
import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href const pluginWithNpm = {
const it = testEffect(PluginTestLayer) id: SapAICorePlugin.id,
const npm = Npm.Service.of({ effect: Effect.gen(function* () {
add: () => Effect.succeed({ directory: "", entrypoint: undefined }), yield* SapAICorePlugin.effect(host({ npm: yield* Npm.Service }))
install: () => Effect.void, }).pipe(Effect.provide(npmLayer)),
which: () => Effect.succeed(undefined),
})
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: SapAICorePlugin.id, effect: SapAICorePlugin.effect({ ...host, npm }) })
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
for (const [key, value] of Object.entries(vars)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}),
)
}
function model(providerID: string) {
return new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")),
api: { id: ModelV2.ID.make("sap-model"), type: "aisdk", package: fixtureProvider },
})
} }
describe("SapAICorePlugin", () => { describe("SapAICorePlugin", () => {
@@ -58,11 +20,11 @@ describe("SapAICorePlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* plugin.add(pluginWithNpm)
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("sap-ai-core"), model: model("sap-ai-core", "sap-model"),
package: fixtureProvider, package: fixtureProvider,
options: { name: "sap-ai-core", serviceKey: "service-key" }, options: { name: "sap-ai-core", serviceKey: "service-key" },
}, },
@@ -84,11 +46,11 @@ describe("SapAICorePlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* plugin.add(pluginWithNpm)
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("sap-ai-core"), model: model("sap-ai-core", "sap-model"),
package: fixtureProvider, package: fixtureProvider,
options: { name: "sap-ai-core", serviceKey: "option-service-key" }, options: { name: "sap-ai-core", serviceKey: "option-service-key" },
}, },
@@ -106,10 +68,10 @@ describe("SapAICorePlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* plugin.add(pluginWithNpm)
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core" } }, { model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } },
{}, {},
) )
expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() expect(process.env.AICORE_SERVICE_KEY).toBeUndefined()
@@ -121,13 +83,17 @@ describe("SapAICorePlugin", () => {
it.effect("uses the callable SDK for language selection", () => it.effect("uses the callable SDK for language selection", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* plugin.add(pluginWithNpm)
const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), { const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), {
languageModel() { languageModel() {
throw new Error("SAP AI Core should call the SDK directly") throw new Error("SAP AI Core should call the SDK directly")
}, },
}) })
const language = yield* plugin.trigger("aisdk.language", { model: model("sap-ai-core"), sdk, options: {} }, {}) const language = yield* plugin.trigger(
"aisdk.language",
{ model: model("sap-ai-core", "sap-model"), sdk, options: {} },
{},
)
expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" }) expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" })
}), }),
) )
@@ -138,11 +104,11 @@ describe("SapAICorePlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* plugin.add(pluginWithNpm)
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("openai"), model: model("openai", "sap-model"),
package: fixtureProvider, package: fixtureProvider,
options: { name: "openai", serviceKey: "service-key" }, options: { name: "openai", serviceKey: "service-key" },
}, },
@@ -151,7 +117,7 @@ describe("SapAICorePlugin", () => {
const language = yield* plugin.trigger( const language = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: model("openai"), model: model("openai", "sap-model"),
sdk: () => { sdk: () => {
throw new Error("SAP AI Core should ignore other providers") throw new Error("SAP AI Core should ignore other providers")
}, },
@@ -1,48 +1,18 @@
import { describe, expect, it as bun_it } from "bun:test" import { describe, expect, it as bun_it } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ProviderV2 } from "@opencode-ai/core/provider" import { addPlugin, expectPluginRegistered, it, model, 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: SnowflakeCortexPlugin.id, effect: SnowflakeCortexPlugin.effect(host) })
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
describe("SnowflakeCortexPlugin", () => { describe("SnowflakeCortexPlugin", () => {
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => { Effect.sync(() => {
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex")) expectPluginRegistered(
const ids = ProviderPlugins.map((p) => p.id) ProviderPlugins.map((item) => item.id),
"snowflake-cortex",
)
const ids = ProviderPlugins.map((p) => p.id as string)
expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible")) expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible"))
}), }),
) )
@@ -50,17 +20,10 @@ describe("SnowflakeCortexPlugin", () => {
it.effect("ignores non-snowflake-cortex providers", () => it.effect("ignores non-snowflake-cortex providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, SnowflakeCortexPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")),
api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai",
options: { name: "openai" },
},
{}, {},
) )
expect(result.sdk).toBeUndefined() expect(result.sdk).toBeUndefined()
@@ -71,14 +34,11 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, SnowflakeCortexPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("snowflake-cortex", "claude-sonnet-4-6"),
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
}, },
@@ -93,14 +53,11 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, SnowflakeCortexPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("snowflake-cortex", "claude-sonnet-4-6"),
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { options: {
name: "snowflake-cortex", name: "snowflake-cortex",
@@ -119,14 +76,11 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () => withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, SnowflakeCortexPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("snowflake-cortex", "claude-sonnet-4-6"),
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
}, },
@@ -141,14 +95,11 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () => withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, SnowflakeCortexPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("snowflake-cortex", "claude-sonnet-4-6"),
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { options: {
name: "snowflake-cortex", name: "snowflake-cortex",
@@ -167,20 +118,27 @@ describe("SnowflakeCortexPlugin", () => {
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() const captured: Record<string, unknown>[] = []
const result = yield* plugin.trigger( 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(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("snowflake-cortex", "claude-sonnet-4-6"),
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
}, },
{}, {},
) )
expect(result.options.includeUsage).toBe(true) expect(captured[0]?.includeUsage).toBe(true)
}), }),
), ),
) )
@@ -1,50 +1,17 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai"
import { ProviderV2 } from "@opencode-ai/core/provider" import { addPlugin, fakeSelectorSdk, it, model } 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: TogetherAIPlugin.id, effect: TogetherAIPlugin.effect(host) })
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("TogetherAIPlugin", () => { describe("TogetherAIPlugin", () => {
it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, TogetherAIPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/togetherai",
options: { name: "togetherai" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -54,15 +21,12 @@ describe("TogetherAIPlugin", () => {
it.effect("matches the old bundled provider package exactly", () => it.effect("matches the old bundled provider package exactly", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, TogetherAIPlugin)
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("togetherai", "model"),
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "file:///tmp/@ai-sdk/togetherai-provider.js", package: "file:///tmp/@ai-sdk/togetherai-provider.js",
options: { name: "togetherai" }, options: { name: "togetherai" },
}, },
@@ -72,14 +36,7 @@ describe("TogetherAIPlugin", () => {
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/togetherai",
options: { name: "togetherai" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -89,22 +46,29 @@ describe("TogetherAIPlugin", () => {
it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() 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)
}),
}),
})
const result = yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-togetherai", "model"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/togetherai", package: "@ai-sdk/togetherai",
options: { name: "custom-togetherai" }, options: { name: "custom-togetherai" },
}, },
{}, {},
) )
expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat") expect(observed).toEqual(["togetherai.chat"])
}), }),
) )
@@ -112,22 +76,12 @@ describe("TogetherAIPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, TogetherAIPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
...ModelV2.Info.empty(
ProviderV2.ID.make("togetherai"),
ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
),
api: {
id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
type: "aisdk",
package: "test-provider",
},
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {}, options: {},
}, },
@@ -1,50 +1,17 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
import { ProviderV2 } from "@opencode-ai/core/provider" import { addPlugin, fakeSelectorSdk, it, model } 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: VenicePlugin.id, effect: VenicePlugin.effect(host) })
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("VenicePlugin", () => { describe("VenicePlugin", () => {
it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => it.effect("creates a Venice SDK for venice-ai-sdk-provider", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, VenicePlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "venice-ai-sdk-provider",
options: { name: "venice" },
},
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
@@ -54,35 +21,39 @@ describe("VenicePlugin", () => {
it.effect("uses the model provider ID as the bundled Venice SDK name", () => it.effect("uses the model provider ID as the bundled Venice SDK name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() 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)
}),
}),
})
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("custom-venice", "model"),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "venice-ai-sdk-provider", package: "venice-ai-sdk-provider",
options: { name: "custom-venice", apiKey: "test" }, options: { name: "custom-venice", apiKey: "test" },
}, },
{}, {},
) )
expect(result.sdk).toBeDefined() expect(result.sdk).toBeDefined()
expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat") expect(observed).toEqual(["custom-venice.chat"])
}), }),
) )
it.effect("only handles the bundled venice-ai-sdk-provider package", () => it.effect("only handles the bundled venice-ai-sdk-provider package", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, VenicePlugin)
const similar = yield* plugin.trigger( const similar = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: model("venice", "model"),
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "file:///tmp/venice-ai-sdk-provider.js", package: "file:///tmp/venice-ai-sdk-provider.js",
options: { name: "venice" }, options: { name: "venice" },
}, },
@@ -90,14 +61,7 @@ describe("VenicePlugin", () => {
) )
const other = yield* plugin.trigger( const other = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("venice", "model"), package: "@ai-sdk/openai-compatible", options: { name: "venice" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "venice" },
},
{}, {},
) )
expect(similar.sdk).toBeUndefined() expect(similar.sdk).toBeUndefined()
@@ -109,17 +73,10 @@ describe("VenicePlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, VenicePlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ { model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{}, {},
) )
expect(calls).toEqual([]) expect(calls).toEqual([])
@@ -1,34 +1,28 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, it, model, provider, required } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: VercelPlugin.id, effect: VercelPlugin.effect(host) })
})
describe("VercelPlugin", () => { describe("VercelPlugin", () => {
it.effect("applies legacy lower-case referer headers", () => it.effect("applies legacy lower-case referer headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, VercelPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { const item = provider("vercel", {
provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } api: { type: "aisdk", package: "@ai-sdk/vercel" },
provider.request.headers.Existing = "1" request: { headers: { Existing: "1" }, body: {} },
})
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("vercel"))).request.headers).toEqual({
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).toEqual({
Existing: "1", Existing: "1",
"http-referer": "https://opencode.ai/", "http-referer": "https://opencode.ai/",
"x-title": "opencode", "x-title": "opencode",
@@ -38,34 +32,31 @@ describe("VercelPlugin", () => {
it.effect("does not add legacy upper-case referer headers", () => it.effect("does not add legacy upper-case referer headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => yield* addPlugin(plugin, VercelPlugin)
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { yield* catalog.transform((catalog) => {
provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } })
}), catalog.provider.update(item.id, (draft) => {
) draft.api = item.api
yield* addPlugin() })
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty( })
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty(
"HTTP-Referer", "HTTP-Referer",
) )
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty("X-Title") expect(required(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", () => it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, VercelPlugin)
const event = yield* plugin.trigger( const event = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")),
api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" },
}),
package: "@ai-sdk/vercel",
options: { name: "custom-vercel" },
},
{}, {},
) )
expect(event.sdk).toBeDefined() expect(event.sdk).toBeDefined()
@@ -75,10 +66,11 @@ describe("VercelPlugin", () => {
it.effect("ignores non-Vercel providers", () => it.effect("ignores non-Vercel providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {})) yield* addPlugin(plugin, VercelPlugin)
yield* addPlugin() yield* catalog.transform((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({}) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({})
}), }),
) )
}) })
+35 -60
View File
@@ -1,66 +1,37 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { addPlugin, fakeSelectorSdk } from "./provider-helper"
const it = testEffect(PluginTestLayer) const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))
const addPlugin = Effect.fn(function* () { const model = new ModelV2.Info({
const plugin = yield* PluginV2.Service ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
const host = yield* PluginHost.make() api: {
yield* plugin.add({ id: XAIPlugin.id, effect: XAIPlugin.effect(host) }) id: ModelV2.ID.make("grok-4"),
type: "aisdk",
package: "@ai-sdk/xai",
},
}) })
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("XAIPlugin", () => { describe("XAIPlugin", () => {
it.effect("creates an xAI SDK only for @ai-sdk/xai", () => it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() yield* addPlugin(plugin, XAIPlugin)
const ignored = yield* plugin.trigger( const ignored = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ { model, package: "@ai-sdk/openai-compatible", options: {} },
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
}),
package: "@ai-sdk/openai-compatible",
options: {},
},
{}, {},
) )
const result = yield* plugin.trigger( const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {})
"aisdk.sdk",
{
model: new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
}),
package: "@ai-sdk/xai",
options: {},
},
{},
)
expect(ignored.sdk).toBeUndefined() expect(ignored.sdk).toBeUndefined()
expect(typeof result.sdk?.responses).toBe("function") expect(typeof result.sdk?.responses).toBe("function")
@@ -70,22 +41,32 @@ describe("XAIPlugin", () => {
it.effect("creates xAI SDKs for custom provider IDs", () => it.effect("creates xAI SDKs for custom provider IDs", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
yield* addPlugin() const providers: string[] = []
const result = yield* plugin.trigger( 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(
"aisdk.sdk", "aisdk.sdk",
{ {
model: new ModelV2.Info({ model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }),
...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")),
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
}),
package: "@ai-sdk/xai", package: "@ai-sdk/xai",
options: {}, options: {},
}, },
{}, {},
) )
expect(result.sdk.responses("grok-4").provider).toBe("xai.responses") expect(providers).toEqual(["xai.responses"])
}), }),
) )
@@ -94,14 +75,11 @@ describe("XAIPlugin", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, XAIPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias") }),
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")),
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
}),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -118,14 +96,11 @@ describe("XAIPlugin", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin(plugin, XAIPlugin)
const result = yield* plugin.trigger( const result = yield* plugin.trigger(
"aisdk.language", "aisdk.language",
{ {
model: new ModelV2.Info({ model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }),
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")),
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
}),
sdk: fakeSelectorSdk(calls), sdk: fakeSelectorSdk(calls),
options: {}, options: {},
}, },
@@ -2,44 +2,34 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make()
yield* plugin.add({ id: ZenmuxPlugin.id, effect: ZenmuxPlugin.effect(host) })
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
describe("ZenmuxPlugin", () => { describe("ZenmuxPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () => it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))), Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"zenmux",
),
),
) )
it.effect("applies the exact legacy Zenmux headers", () => it.effect("applies the exact legacy Zenmux headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { const item = provider("zenmux", {
provider.api = { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
type: "aisdk", })
package: "@ai-sdk/openai-compatible", catalog.provider.update(item.id, (draft) => {
url: "https://zenmux.ai/api/v1", draft.api = item.api
}
}) })
}) })
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))) const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux")))
expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
@@ -48,18 +38,19 @@ describe("ZenmuxPlugin", () => {
it.effect("merges legacy Zenmux headers with existing headers", () => it.effect("merges legacy Zenmux headers with existing headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { const item = provider("zenmux", {
provider.api = { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
type: "aisdk", request: { headers: { Existing: "value" }, body: {} },
package: "@ai-sdk/openai-compatible", })
url: "https://zenmux.ai/api/v1", catalog.provider.update(item.id, (draft) => {
} draft.api = item.api
provider.request.headers.Existing = "value" draft.request = item.request
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
Existing: "value", Existing: "value",
@@ -71,18 +62,22 @@ describe("ZenmuxPlugin", () => {
it.effect("lets configured Zenmux legacy headers override defaults", () => it.effect("lets configured Zenmux legacy headers override defaults", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { const item = provider("zenmux", {
provider.api = { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
type: "aisdk", request: {
package: "@ai-sdk/openai-compatible", headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
url: "https://zenmux.ai/api/v1", body: {},
} },
provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } })
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
draft.request = item.request
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
"HTTP-Referer": "https://example.com/", "HTTP-Referer": "https://example.com/",
@@ -93,13 +88,20 @@ describe("ZenmuxPlugin", () => {
it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () => it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* addPlugin(plugin, ZenmuxPlugin)
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { const item = provider("openrouter", {
provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } request: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {},
},
})
catalog.provider.update(item.id, (draft) => {
draft.request = item.request
}) })
}) })
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({
"HTTP-Referer": "https://example.com/", "HTTP-Referer": "https://example.com/",
-1
View File
@@ -1 +0,0 @@
process.env.OPENCODE_DB = ":memory:"
+7 -6
View File
@@ -16,16 +16,17 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const databaseLayer = Database.layerFromPath(":memory:")
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
const copyLayer = ProjectCopy.layer.pipe( const copyLayer = ProjectCopy.layer.pipe(
Layer.provide(Database.defaultLayer), Layer.provide(databaseLayer),
Layer.provide(ProjectDirectories.defaultLayer), Layer.provide(directoriesLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(eventLayer),
Layer.provide(FSUtil.defaultLayer), Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
) )
const it = testEffect( const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer))
Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer),
)
function abs(input: string) { function abs(input: string) {
return AbsolutePath.make(input) return AbsolutePath.make(input)
@@ -8,7 +8,10 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer)) 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 projectID = Project.ID.make("project-directories") const projectID = Project.ID.make("project-directories")
const directory = AbsolutePath.make("/tmp/project-directories") const directory = AbsolutePath.make("/tmp/project-directories")
+14 -1
View File
@@ -13,7 +13,20 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer)) 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,
),
)
function remoteID(remote: string) { function remoteID(remote: string) {
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
+109 -8
View File
@@ -1,6 +1,9 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public" import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(OpenCode.layer) const it = testEffect(OpenCode.layer)
@@ -38,24 +41,94 @@ describe("public native OpenCode API", () => {
}), }),
) )
it.effect("records model selection without resolving the Location catalog", () => it.effect("switches to an available model and variant", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* writeProvider(tmp.path)
const opencode = yield* OpenCode.Service const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_deferred") const sessionID = Session.ID.make("ses_public_switch_available")
const model = Schema.decodeUnknownSync(Model.Ref)({ const model = ref({ variant: "fast" })
id: "missing",
providerID: "missing",
variant: "unknown",
})
yield* opencode.sessions.create({ yield* opencode.sessions.create({
id: sessionID, id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") }), location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
}) })
yield* opencode.sessions.switchModel({ sessionID, model }) yield* opencode.sessions.switchModel({ sessionID, model })
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model) expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
}), }),
),
),
)
it.effect("rejects missing and Location-disabled models without changing the Session", () =>
Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
).pipe(
Effect.flatMap(([available, disabled]) =>
Effect.gen(function* () {
yield* writeProvider(available.path)
yield* writeProvider(disabled.path, true)
const opencode = yield* OpenCode.Service
const availableID = Session.ID.make("ses_public_switch_exact_available")
const disabledID = Session.ID.make("ses_public_switch_exact_disabled")
yield* opencode.sessions.create({
id: availableID,
location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }),
})
yield* opencode.sessions.create({
id: disabledID,
location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }),
})
yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
const disabledError = yield* opencode.sessions
.switchModel({ sessionID: disabledID, model: ref() })
.pipe(Effect.flip)
const missingError = yield* opencode.sessions
.switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) })
.pipe(Effect.flip)
expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError)
expect(missingError).toBeInstanceOf(Session.ModelUnavailableError)
expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" }))
expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined()
}),
),
),
)
it.effect("rejects an unavailable variant without changing the Session", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* writeProvider(tmp.path)
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_variant")
const selected = ref({ variant: "fast" })
yield* opencode.sessions.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
})
yield* opencode.sessions.switchModel({ sessionID, model: selected })
const error = yield* opencode.sessions
.switchModel({ sessionID, model: ref({ variant: "unknown" }) })
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Session.VariantUnavailableError)
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected)
}),
),
),
) )
it.effect("preserves the typed not-found error for a missing Session", () => it.effect("preserves the typed not-found error for a missing Session", () =>
@@ -74,3 +147,31 @@ describe("public native OpenCode API", () => {
}), }),
) )
}) })
const ref = (input: { id?: string; variant?: string } = {}) =>
Schema.decodeUnknownSync(Model.Ref)({
id: input.id ?? "chat",
providerID: "public-test",
variant: input.variant,
})
const writeProvider = (directory: string, disabled = false) =>
Effect.promise(() =>
fs.writeFile(
path.join(directory, "opencode.json"),
JSON.stringify({
providers: {
"public-test": {
name: "Public test",
api: { type: "native", settings: {} },
models: {
chat: {
disabled,
variants: [{ id: "fast" }],
},
},
},
},
}),
),
)
+4 -2
View File
@@ -6,8 +6,10 @@ import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) const database = Database.layerFromPath(":memory:")
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, questions)) 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 sessionID = SessionV2.ID.make("ses_question_test") const sessionID = SessionV2.ID.make("ses_question_test")
const question: QuestionV2.Info = { const question: QuestionV2.Info = {
+10 -92
View File
@@ -11,7 +11,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Prompt } from "@opencode-ai/core/session/prompt" import { Prompt } from "@opencode-ai/core/session/prompt"
@@ -25,6 +25,8 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projects = Layer.succeed( const projects = Layer.succeed(
ProjectV2.Service, ProjectV2.Service,
ProjectV2.Service.of({ ProjectV2.Service.of({
@@ -33,23 +35,17 @@ const projects = Layer.succeed(
commit: () => Effect.void, commit: () => Effect.void,
}), }),
) )
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(EventV2.defaultLayer), Layer.provide(events),
Layer.provide(Database.defaultLayer), Layer.provide(database),
Layer.provide(SessionStore.defaultLayer), Layer.provide(store),
Layer.provide(projects), Layer.provide(projects),
Layer.provide(SessionExecution.noopLayer), Layer.provide(SessionExecution.noopLayer),
) )
const it = testEffect( const it = testEffect(
Layer.mergeAll( Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions),
Database.defaultLayer,
EventV2.defaultLayer,
projects,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
SessionExecution.noopLayer,
sessions,
),
) )
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const id = SessionV2.ID.create() const id = SessionV2.ID.create()
@@ -340,34 +336,7 @@ describe("SessionV2.create", () => {
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") 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.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")
}), }),
) )
@@ -426,54 +395,3 @@ describe("SessionV2.create", () => {
}), }),
) )
}) })
describe("SessionV2.list", () => {
it.effect("limits project sessions to the requested subpath", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
const core = yield* session.create({ location })
const nested = yield* session.create({ location })
const web = yield* session.create({ location })
yield* Effect.all([
db.update(SessionTable).set({ path: "packages/core" }).where(eq(SessionTable.id, core.id)).run(),
db.update(SessionTable).set({ path: "packages/core/test" }).where(eq(SessionTable.id, nested.id)).run(),
db.update(SessionTable).set({ path: "packages/web" }).where(eq(SessionTable.id, web.id)).run(),
]).pipe(Effect.orDie)
const listed = yield* session.list({
project: ProjectV2.ID.global,
subpath: RelativePath.make("packages/core"),
})
expect(new Set(listed.map((item) => item.id))).toEqual(new Set([core.id, nested.id]))
}),
)
it.effect("treats wildcard characters in subpaths literally", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
const exact = yield* session.create({ location })
const nested = yield* session.create({ location })
const sibling = yield* session.create({ location })
yield* Effect.all([
db.update(SessionTable).set({ path: "packages/core_test" }).where(eq(SessionTable.id, exact.id)).run(),
db.update(SessionTable).set({ path: "packages/core_test/unit" }).where(eq(SessionTable.id, nested.id)).run(),
db.update(SessionTable).set({ path: "packages/coreXtest/unit" }).where(eq(SessionTable.id, sibling.id)).run(),
]).pipe(Effect.orDie)
const listed = yield* session.list({
project: ProjectV2.ID.global,
subpath: RelativePath.make("packages/core_test"),
})
const ids = listed.map((item) => item.id)
expect(ids).toContain(exact.id)
expect(ids).toContain(nested.id)
expect(ids).not.toContain(sibling.id)
}),
)
})
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Logger } from "effect"
import { logFailure } from "@opencode-ai/core/session/logging"
import { SessionSchema } from "@opencode-ai/core/session/schema"
describe("Session logging", () => {
for (const message of ["Failed to drain Session", "Failed to wake Session"] as const) {
test(`renders the cause for ${message}`, async () => {
const entries: Array<ReturnType<typeof Logger.formatStructured.log>> = []
const logger = Logger.formatStructured.pipe(
Logger.map((entry): void => {
entries.push(entry)
}),
)
await logFailure(
message,
SessionSchema.ID.make("session-123"),
Cause.fail({ _tag: "SessionFailure", detail: { code: "nested-code" } }),
).pipe(Effect.provide(Logger.layer([logger])), Effect.runPromise)
expect(entries).toHaveLength(1)
expect(entries[0]?.message).toBe(message)
expect(entries[0]?.annotations).toEqual({ sessionID: "session-123" })
expect(entries[0]?.cause).toContain("SessionFailure")
expect(entries[0]?.cause).toContain("nested-code")
expect(entries[0]?.cause).not.toContain("[Object")
})
}
})
+7 -4
View File
@@ -21,7 +21,10 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer)) 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 sessionID = SessionV2.ID.make("ses_projector_test") const sessionID = SessionV2.ID.make("ses_projector_test")
const created = DateTime.makeUnsafe(0) const created = DateTime.makeUnsafe(0)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
@@ -110,10 +113,10 @@ describe("SessionProjector", () => {
}).pipe( }).pipe(
Effect.provide( Effect.provide(
SessionV2.layer.pipe( SessionV2.layer.pipe(
Layer.provide(EventV2.defaultLayer), Layer.provide(events),
Layer.provide(Database.defaultLayer), Layer.provide(database),
Layer.provide(Project.defaultLayer), Layer.provide(Project.defaultLayer),
Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionStore.layer.pipe(Layer.provide(database))),
Layer.provide(SessionExecution.noopLayer), Layer.provide(SessionExecution.noopLayer),
), ),
), ),

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