mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 06:02:05 -04:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d550aac46 | |||
| b74ff631c6 | |||
| c5a4a8288c | |||
| da1f73873f | |||
| 39d7394ede | |||
| 494123a875 | |||
| fe840d42b8 | |||
| f50e4accf3 | |||
| 9bceb8eb7d | |||
| adebb87191 | |||
| 4d5efba5c9 | |||
| c6ee511485 | |||
| f9f2280452 | |||
| d5980b47e9 | |||
| 41d1279b6f | |||
| 0d32d1f293 | |||
| cf31029350 | |||
| 79b55d4db8 | |||
| 36264ccf90 | |||
| 7b750a8f20 | |||
| e50261e524 | |||
| 639c94a375 | |||
| b13a2d712a | |||
| 1b91b5df34 | |||
| 595cc91ddc | |||
| 57ce1b9ca8 | |||
| 4ecc3ac653 | |||
| c7efbe6fc0 | |||
| cd292a4ecb | |||
| 2bb4311042 | |||
| cdc6d01c5a | |||
| 35b3fc85d0 | |||
| 9dadc2455f | |||
| 06dae383f7 | |||
| 7d204b5b57 | |||
| 49593c1ec4 | |||
| ff837fe949 | |||
| 4c6750d464 | |||
| 69f1ec22e3 | |||
| 823d327401 |
@@ -65,7 +65,7 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
|
||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
|
||||
+6
-9
@@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
|
||||
_Avoid_: System update, system notification, raw text diff
|
||||
|
||||
**Context Epoch**:
|
||||
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
|
||||
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.
|
||||
|
||||
**Baseline System Context**:
|
||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
@@ -75,31 +75,28 @@ 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.
|
||||
- `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.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.
|
||||
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
||||
- **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**.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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** 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** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||
- 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 always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||
- 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.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||
- **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.
|
||||
- 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`.
|
||||
|
||||
@@ -27,9 +27,11 @@ export default Runtime.handler(
|
||||
|
||||
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||
// Preserve the familiar default when available, but let the OS choose a free
|
||||
// port when another local server already owns 4096.
|
||||
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
|
||||
const next = (port: number): ReturnType<typeof bind> =>
|
||||
bind(hostname, port, password).pipe(
|
||||
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
|
||||
)
|
||||
return next(4096)
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
|
||||
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
|
||||
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
|
||||
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "workspace",
|
||||
@@ -900,16 +900,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'build'",
|
||||
"generated": null,
|
||||
"name": "agent",
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
@@ -930,26 +920,6 @@
|
||||
"entityType": "columns",
|
||||
"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",
|
||||
"notNull": false,
|
||||
|
||||
@@ -45,15 +45,17 @@ async function generate() {
|
||||
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
|
||||
await Bun.write(
|
||||
target,
|
||||
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
|
||||
await formatTypescript(
|
||||
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
|
||||
),
|
||||
)
|
||||
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
|
||||
}
|
||||
|
||||
await fs.mkdir(full)
|
||||
await drizzle(temporary, full, "schema")
|
||||
await Bun.write(schema, renderSchema(await generatedSql(full)))
|
||||
await Bun.write(registry, renderRegistry(await typescriptMigrations()))
|
||||
await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full))))
|
||||
await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations())))
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
@@ -76,12 +78,12 @@ async function check() {
|
||||
|
||||
await fs.mkdir(full)
|
||||
await drizzle(temporary, full, "schema")
|
||||
if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) {
|
||||
if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) {
|
||||
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
|
||||
}
|
||||
|
||||
const migrations = await typescriptMigrations()
|
||||
if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
|
||||
if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) {
|
||||
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
|
||||
}
|
||||
} finally {
|
||||
@@ -170,6 +172,18 @@ function escapeTemplate(line: string) {
|
||||
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
|
||||
}
|
||||
|
||||
async function formatTypescript(input: string) {
|
||||
const prettier = await import("prettier")
|
||||
const typescript = await import("prettier/plugins/typescript")
|
||||
const estree = await import("prettier/plugins/estree")
|
||||
return prettier.format(input, {
|
||||
parser: "typescript",
|
||||
plugins: [typescript.default, estree.default],
|
||||
semi: false,
|
||||
printWidth: 120,
|
||||
})
|
||||
}
|
||||
|
||||
function renderRegistry(names: string[]) {
|
||||
return `import type { DatabaseMigration } from "./migration"
|
||||
|
||||
|
||||
@@ -69,10 +69,10 @@ export const layer = Layer.effect(
|
||||
const policy = yield* Policy.Service
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.request.body.apiKey === "string") return true
|
||||
if (connected) return true
|
||||
if (integration?.connections.length) return true
|
||||
return !integration
|
||||
}
|
||||
|
||||
@@ -183,13 +183,8 @@ export const layer = Layer.effect(
|
||||
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
const connections = yield* integrations.connection.list()
|
||||
return (yield* result.provider.all()).filter((provider) =>
|
||||
available(
|
||||
provider,
|
||||
active.get(Integration.ID.make(provider.id)),
|
||||
connections.has(Integration.ID.make(provider.id)),
|
||||
),
|
||||
available(provider, active.get(Integration.ID.make(provider.id))),
|
||||
)
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([OAuth, Key])
|
||||
export const Value = Schema.Union([OAuth, Key])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Credential.Info" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
.annotate({ identifier: "Credential.Value" })
|
||||
export type Value = Schema.Schema.Type<typeof Value>
|
||||
|
||||
export class Stored extends Schema.Class<Stored>("Credential.Stored")({
|
||||
export class Info extends Schema.Class<Info>("Credential.Info")({
|
||||
id: ID,
|
||||
integrationID: IntegrationSchema.ID,
|
||||
label: Schema.String,
|
||||
value: Info,
|
||||
value: Value,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
/** Returns every stored credential. */
|
||||
readonly all: () => Effect.Effect<Stored[]>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
/** Returns stored credentials belonging to one integration. */
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: IntegrationSchema.ID
|
||||
readonly value: Info
|
||||
readonly value: Value
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Stored>
|
||||
}) => Effect.Effect<Info>
|
||||
/** Updates the label or secret value of a stored credential. */
|
||||
readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void>
|
||||
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
|
||||
/** Removes a stored credential. */
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -66,10 +66,10 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decode = Schema.decodeUnknownSync(Value)
|
||||
const stored = (row: typeof CredentialTable.$inferSelect) => {
|
||||
if (!row.integration_id) return
|
||||
return new Stored({
|
||||
return new Info({
|
||||
id: row.id,
|
||||
integrationID: row.integration_id,
|
||||
label: row.label,
|
||||
@@ -106,7 +106,7 @@ export const layer = Layer.effect(
|
||||
return row ? stored(row) : undefined
|
||||
}),
|
||||
create: Effect.fn("Credential.create")(function* (input) {
|
||||
const credential = new Stored({
|
||||
const credential = new Info({
|
||||
id: ID.create(),
|
||||
integrationID: input.integrationID,
|
||||
label: input.label ?? "default",
|
||||
|
||||
@@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
|
||||
id: text().$type<Credential.ID>().primaryKey(),
|
||||
integration_id: text().$type<IntegrationSchema.ID>(),
|
||||
label: text().notNull(),
|
||||
value: text({ mode: "json" }).$type<Credential.Info>().notNull(),
|
||||
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
|
||||
connector_id: text(),
|
||||
method_id: text(),
|
||||
active: integer({ mode: "boolean" }),
|
||||
|
||||
+1
@@ -37,5 +37,6 @@ export const migrations = (
|
||||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
@@ -149,11 +149,8 @@ export default {
|
||||
CREATE TABLE \`session_context_epoch\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`baseline\` text NOT NULL,
|
||||
\`agent\` text DEFAULT 'build' NOT NULL,
|
||||
\`snapshot\` text 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
|
||||
);
|
||||
`)
|
||||
|
||||
@@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util"
|
||||
import { Location } from "./location"
|
||||
import { PositiveInt, RelativePath } from "./schema"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Entry, Match, PathError } from "./filesystem/schema"
|
||||
export { Entry, Match, PathError, Submatch } from "./filesystem/schema"
|
||||
import { Entry, Match } from "./filesystem/schema"
|
||||
export { Entry, Match, Submatch } from "./filesystem/schema"
|
||||
|
||||
export const ReadInput = Schema.Struct({
|
||||
path: RelativePath,
|
||||
@@ -58,10 +58,8 @@ export const Event = {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (
|
||||
input: ReadInput,
|
||||
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, PathError | FSUtil.Error>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[], PathError | FSUtil.Error>
|
||||
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
|
||||
@@ -79,9 +77,9 @@ const baseLayer = Layer.effect(
|
||||
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
||||
const absolute = path.resolve(location.directory, input ?? ".")
|
||||
if (!FSUtil.contains(location.directory, absolute))
|
||||
return yield* new PathError({ path: input ?? ".", reason: "lexical_escape" })
|
||||
const real = yield* fs.realPath(absolute)
|
||||
if (!FSUtil.contains(root, real)) return yield* new PathError({ path: input ?? ".", reason: "symlink_escape" })
|
||||
return yield* Effect.die(new Error("Path escapes the location"))
|
||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||
return { absolute, real, directory: location.directory, root }
|
||||
})
|
||||
return Service.of({
|
||||
@@ -90,18 +88,19 @@ const baseLayer = Layer.effect(
|
||||
grep: search.grep,
|
||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real)
|
||||
if (info.type !== "File") return yield* new PathError({ path: input.path, reason: "not_file" })
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
return {
|
||||
content: yield* fs.readFile(target.real),
|
||||
content: yield* fs.readFile(target.real).pipe(Effect.orDie),
|
||||
mime: FSUtil.mimeType(target.real),
|
||||
}
|
||||
}),
|
||||
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real)
|
||||
if (info.type !== "Directory") return yield* new PathError({ path: input.path ?? ".", reason: "not_directory" })
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
return yield* fs.readDirectoryEntries(target.real).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.flatMap((item) => {
|
||||
@@ -124,6 +123,6 @@ const baseLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
@@ -21,8 +21,3 @@ export class Match extends Schema.Class<Match>("FileSystem.Match")({
|
||||
text: Schema.String,
|
||||
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"]),
|
||||
}) {}
|
||||
|
||||
@@ -232,6 +232,6 @@ export const fffLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
export const locationLayer = Layer.unwrap(
|
||||
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
|
||||
)
|
||||
|
||||
@@ -108,11 +108,11 @@ export type OAuthAuthorization = {
|
||||
} & (
|
||||
| {
|
||||
readonly mode: "auto"
|
||||
readonly callback: Effect.Effect<Credential.Info, unknown>
|
||||
readonly callback: Effect.Effect<Credential.Value, unknown>
|
||||
}
|
||||
| {
|
||||
readonly mode: "code"
|
||||
readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown>
|
||||
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
|
||||
}
|
||||
)
|
||||
|
||||
@@ -214,8 +214,6 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
/** Returns all integrations with their methods and current connections. */
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly connection: {
|
||||
/** Returns active connections for every registered or credential-backed integration. */
|
||||
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
|
||||
/** Returns the active connection for one integration. */
|
||||
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||
/** Runs a key method and stores the resulting credential. */
|
||||
@@ -241,7 +239,7 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
/** Updates a stored credential exposed as a connection. */
|
||||
readonly update: (
|
||||
credentialID: Credential.ID,
|
||||
updates: Partial<Pick<Credential.Stored, "label">>,
|
||||
updates: Partial<Pick<Credential.Info, "label">>,
|
||||
) => Effect.Effect<void>
|
||||
/** Removes a stored credential connection. */
|
||||
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
@@ -353,39 +351,27 @@ export const locationLayer = Layer.effect(
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
|
||||
const connected = saved.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
const detected = entry.methods
|
||||
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
|
||||
const credentials = saved
|
||||
.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
.toReversed()
|
||||
const env = (entry?.methods ?? [])
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||
.map((name) => ({ type: "env" as const, name }))
|
||||
return [...connected, ...detected]
|
||||
return [...credentials, ...env]
|
||||
}
|
||||
|
||||
const activeConnection = (
|
||||
entry: Entry | undefined,
|
||||
saved: readonly Credential.Stored[],
|
||||
): IntegrationConnection.Info | undefined => {
|
||||
const credential = saved.at(-1)
|
||||
if (credential) return { type: "credential", id: credential.id, label: credential.label }
|
||||
if (!entry) return
|
||||
const name = entry.methods
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names)
|
||||
.find((name) => process.env[name])
|
||||
if (name) return { type: "env", name }
|
||||
}
|
||||
|
||||
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
methods: entry.methods,
|
||||
connections: connections(entry, saved),
|
||||
connections,
|
||||
})
|
||||
|
||||
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
@@ -399,7 +385,7 @@ export const locationLayer = Layer.effect(
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) {
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||
const attempt = current.get(attemptID)
|
||||
@@ -450,28 +436,18 @@ export const locationLayer = Layer.effect(
|
||||
get: Effect.fn("Integration.get")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
if (!entry) return undefined
|
||||
return project(entry, yield* credentials.list(id))
|
||||
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
|
||||
}),
|
||||
list: Effect.fn("Integration.list")(function* () {
|
||||
return (yield* Effect.forEach(state.get().integrations.values(), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
return project(entry, yield* credentials.list(entry.ref.id))
|
||||
}),
|
||||
)).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
return Array.from(state.get().integrations.values(), (entry) =>
|
||||
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
|
||||
).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
connection: {
|
||||
list: Effect.fn("Integration.connection.list")(function* () {
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
return new Map(
|
||||
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
|
||||
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
|
||||
return connection ? [[id, connection] as const] : []
|
||||
}),
|
||||
)
|
||||
}),
|
||||
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
return activeConnection(entry, yield* credentials.list(id))
|
||||
return resolveConnections(entry, yield* credentials.list(id))[0]
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const method = state
|
||||
|
||||
@@ -103,8 +103,16 @@ export const layer = Layer.effect(
|
||||
}
|
||||
}[keyof Hooks][] = []
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const locks = KeyedMutex.makeUnsafe<ID>()
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
// One registry-owned scope lets shutdown remove every plugin transform in one batch.
|
||||
yield* Effect.addFinalizer((exit) =>
|
||||
Effect.gen(function* () {
|
||||
hooks = []
|
||||
yield* State.batch(Scope.close(scope, exit))
|
||||
}),
|
||||
)
|
||||
|
||||
const svc = Service.of({
|
||||
add: Effect.fn("Plugin.add")(function* (input) {
|
||||
@@ -112,7 +120,7 @@ export const layer = Layer.effect(
|
||||
yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
|
||||
const childScope = yield* Scope.fork(scope)
|
||||
const result = yield* input.effect.pipe(
|
||||
Scope.provide(childScope),
|
||||
@@ -181,7 +189,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === id)
|
||||
hooks = hooks.filter((item) => item.id !== id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
export * as OpenCode from "./opencode"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { LocationServiceMap } from "../location-layer"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { SessionV2 } from "../session"
|
||||
import * as SessionExecutionLocal from "../session/execution/local"
|
||||
@@ -23,69 +21,22 @@ export interface Interface {
|
||||
/** Intentional public native API for Effect applications embedding OpenCode. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
|
||||
|
||||
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 = SessionV2.layer.pipe(
|
||||
Layer.provide(SessionProjector.layer),
|
||||
Layer.provide(SessionExecutionLocal.layer),
|
||||
Layer.provide(SessionStore.layer),
|
||||
Layer.provide(EventV2.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
const SessionsLayer = Layer.merge(
|
||||
SessionV2.layer.pipe(
|
||||
Layer.provide(SessionProjector.layer),
|
||||
Layer.provide(SessionExecutionLocal.layer),
|
||||
Layer.provide(SessionStore.layer),
|
||||
Layer.provide(EventV2.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
),
|
||||
SessionModelValidationLayer,
|
||||
).pipe(Layer.provide(LocationServicesLayer))
|
||||
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* SessionV2.Service
|
||||
const tools = yield* ApplicationTools.Service
|
||||
const validation = yield* SessionModelValidation
|
||||
return Service.of({
|
||||
tools: { register: tools.register },
|
||||
sessions: {
|
||||
@@ -98,11 +49,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
get: sessions.get,
|
||||
list: sessions.list,
|
||||
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)
|
||||
}),
|
||||
switchModel: sessions.switchModel,
|
||||
interrupt: sessions.interrupt,
|
||||
prompt: (input) =>
|
||||
sessions.prompt({
|
||||
@@ -124,6 +71,6 @@ export const layer = Layer.effect(
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer)))
|
||||
).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer)))
|
||||
|
||||
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as Session from "./session"
|
||||
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { ModelV2 } from "../model"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { SessionV2 } from "../session"
|
||||
import { MessageDecodeError } from "../session/error"
|
||||
import { SessionEvent } from "../session/event"
|
||||
@@ -41,23 +40,6 @@ export type NotFoundError = SessionV2.NotFoundError
|
||||
export const 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 interface CreateInput {
|
||||
@@ -104,9 +86,7 @@ export interface Interface {
|
||||
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
|
||||
readonly switchModel: (
|
||||
input: SwitchModelInput,
|
||||
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
|
||||
readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError>
|
||||
/** 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 messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as SessionV2 from "./session"
|
||||
export * from "./session/schema"
|
||||
|
||||
import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { ProjectV2 } from "./project"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
import { ModelV2 } from "./model"
|
||||
@@ -25,7 +25,6 @@ import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { logFailure } from "./session/logging"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
@@ -126,10 +125,7 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||
readonly switchAgent: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
agent: string
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
model: ModelV2.Ref
|
||||
@@ -171,20 +167,6 @@ export const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
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) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
@@ -266,6 +248,13 @@ export const layer = Layer.effect(
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
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.anchor) {
|
||||
conditions.push(
|
||||
@@ -345,10 +334,6 @@ export const layer = Layer.effect(
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
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 delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
|
||||
@@ -366,7 +351,8 @@ export const layer = Layer.effect(
|
||||
)
|
||||
if (!SessionInput.equivalent(admitted, expected))
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
return yield* returnPrompt(admitted)
|
||||
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -376,8 +362,14 @@ export const layer = Layer.effect(
|
||||
skill: Effect.fn("V2Session.skill")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "switchAgent" })
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: input.agent,
|
||||
})
|
||||
}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
@@ -401,19 +393,7 @@ export const layer = Layer.effect(
|
||||
yield* execution.resume(sessionID)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.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)
|
||||
}),
|
||||
),
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,54 +1,31 @@
|
||||
export * as SessionContextEpoch from "./context-epoch"
|
||||
|
||||
import { and, eq, isNull, lt, or, sql } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { Location } from "../location"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
import { ContextSnapshotDecodeError } from "./error"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionInput } from "./input"
|
||||
import { SessionMessageID } from "./message-id"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable, SessionTable } from "./sql"
|
||||
import { SessionContextEpochTable } from "./sql"
|
||||
|
||||
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 {
|
||||
readonly baseline: string
|
||||
readonly baselineSeq: number
|
||||
readonly revision: number
|
||||
}
|
||||
|
||||
export function initialize(
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.initialize"),
|
||||
)
|
||||
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize"))
|
||||
}
|
||||
|
||||
export function prepare(
|
||||
@@ -56,12 +33,8 @@ export function prepare(
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError | AgentReplacementBlocked> {
|
||||
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.prepare"),
|
||||
)
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
|
||||
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare"))
|
||||
}
|
||||
|
||||
const prepareOnce = Effect.fnUntraced(function* (
|
||||
@@ -69,57 +42,50 @@ const prepareOnce = Effect.fnUntraced(function* (
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
) {
|
||||
const [value, stored] = yield* Effect.all([context, find(db, sessionID)], { concurrency: "unbounded" })
|
||||
const [value, stored, compaction] = yield* Effect.all(
|
||||
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (!stored) {
|
||||
const generation = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
|
||||
return { baseline: generation.baseline, baselineSeq, revision: 0 }
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
|
||||
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
|
||||
)
|
||||
const replacingAgent = stored.agent !== agent
|
||||
const result =
|
||||
stored.replacement_seq === null && !replacingAgent
|
||||
? 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 })
|
||||
}
|
||||
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
|
||||
const result = replacementSeq
|
||||
? yield* SystemContext.replace(value, snapshot)
|
||||
: yield* SystemContext.reconcile(value, snapshot)
|
||||
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
|
||||
yield* fence(db, sessionID, agent, stored.revision)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision }
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
}
|
||||
if (result._tag === "ReplacementReady") {
|
||||
const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
||||
yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 }
|
||||
const baselineSeq = replacementSeq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
||||
yield* replace(db, sessionID, baselineSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.ContextUpdated,
|
||||
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||
{ commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) },
|
||||
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision + 1 }
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
const initializeOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
) {
|
||||
if (yield* exists(db, sessionID)) return
|
||||
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
|
||||
return { baseline: generation.baseline, baselineSeq, revision: 0 }
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
@@ -142,39 +108,6 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic
|
||||
.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* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -189,155 +122,53 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
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)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: generation.baseline,
|
||||
agent,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
revision: 0,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))),
|
||||
)
|
||||
return baselineSeq
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return baselineSeq
|
||||
})
|
||||
|
||||
const replace = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: AgentV2.ID,
|
||||
expectedRevision: number,
|
||||
baselineSeq: number,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* requireAgentSelection(db, sessionID, agent)
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
agent,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
replacement_seq: null,
|
||||
revision: expectedRevision + 1,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextEpochTable.session_id, sessionID),
|
||||
eq(SessionContextEpochTable.revision, expectedRevision),
|
||||
),
|
||||
)
|
||||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
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,
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.from(SessionContextEpochTable)
|
||||
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return (
|
||||
value !== undefined &&
|
||||
value.agent === agent &&
|
||||
(value.selected === null || value.selected === agent) &&
|
||||
value.revision === revision
|
||||
)
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
expectedRevision: number,
|
||||
snapshot: SystemContext.Snapshot,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({ snapshot, revision: expectedRevision + 1 })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextEpochTable.session_id, sessionID),
|
||||
eq(SessionContextEpochTable.revision, expectedRevision),
|
||||
isNull(SessionContextEpochTable.replacement_seq),
|
||||
),
|
||||
)
|
||||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.set({ snapshot })
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
||||
@@ -118,13 +118,6 @@ export namespace PromptLifecycle {
|
||||
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({
|
||||
type: "session.next.context.updated",
|
||||
...options,
|
||||
@@ -475,7 +468,6 @@ const DurableDefinitions = [
|
||||
Prompted,
|
||||
PromptLifecycle.Admitted,
|
||||
PromptLifecycle.Promoted,
|
||||
InterruptRequested,
|
||||
ContextUpdated,
|
||||
Synthetic,
|
||||
Shell.Started,
|
||||
|
||||
@@ -5,12 +5,12 @@ import { SessionRunner } from "./runner/index"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export interface Interface {
|
||||
/** Explicitly drain one Session, making at least one provider attempt. */
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Cause, Effect, Layer } from "effect"
|
||||
import { LocationServiceMap } from "../../location-layer"
|
||||
import { SessionRunCoordinator } from "../run-coordinator"
|
||||
import { SessionRunner } from "../runner"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { logFailure } from "../logging"
|
||||
|
||||
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
||||
export const layer = Layer.effect(
|
||||
@@ -13,15 +12,19 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, void, SessionRunner.RunError>({
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) {
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
|
||||
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe(
|
||||
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe(
|
||||
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({
|
||||
|
||||
@@ -10,9 +10,9 @@ type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
|
||||
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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,7 +138,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
},
|
||||
"session.next.prompt.admitted": () => Effect.void,
|
||||
"session.next.prompt.promoted": () => Effect.void,
|
||||
"session.next.interrupt.requested": () => Effect.void,
|
||||
"session.next.context.updated": (event) =>
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.System({
|
||||
|
||||
@@ -329,19 +329,14 @@ export const layer = Layer.effectDiscard(
|
||||
if (next) yield* applyUsage(db, sessionID, next)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.AgentSwitched, (event) => {
|
||||
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||
return db
|
||||
yield* events.project(SessionEvent.AgentSwitched, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.andThen(run(db, event)),
|
||||
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)),
|
||||
)
|
||||
})
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
)
|
||||
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* db
|
||||
@@ -351,8 +346,6 @@ export const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
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) =>
|
||||
@@ -406,8 +399,6 @@ 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.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
@@ -426,15 +417,9 @@ export const layer = Layer.effectDiscard(
|
||||
yield* events.project(SessionEvent.Reasoning.Started, (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.Compaction.Ended, (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)
|
||||
})
|
||||
})
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
|
||||
event.durable?.version === 1 ? Effect.void : run(db, event),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,106 +1,43 @@
|
||||
export * as SessionRunCoordinator from "./run-coordinator"
|
||||
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
|
||||
import { SessionRunner } from "./runner"
|
||||
import { logFailure } from "./logging"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
|
||||
export type Mode = "run" | "wake"
|
||||
|
||||
/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */
|
||||
type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number }
|
||||
|
||||
/**
|
||||
* Runs at most one drain chain per key while allowing different keys to drain concurrently.
|
||||
*
|
||||
* 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>
|
||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||
export interface Coordinator<Key, E> {
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Registers one coalesced follow-up after newly recorded work. */
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops active execution and waits for its cleanup. */
|
||||
readonly interrupt: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */
|
||||
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
|
||||
type Entry<E> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void, never>
|
||||
pendingWake: boolean
|
||||
stopping: boolean
|
||||
}
|
||||
|
||||
/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */
|
||||
const coalesce = (left: Demand | undefined, right: Demand): Demand => {
|
||||
if (left?._tag === "run" || right._tag === "run") return { _tag: "run" }
|
||||
return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) }
|
||||
}
|
||||
|
||||
const maxSeq = (left: number | undefined, right: number | undefined) => {
|
||||
if (left === undefined) return right
|
||||
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> =>
|
||||
export const make = <Key, E>(options: {
|
||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||
}): Effect.Effect<Coordinator<Key, 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 active = new Map<Key, Entry<E>>()
|
||||
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,
|
||||
const makeEntry = (): Entry<E> => ({
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
pendingWake: false,
|
||||
stopping: false,
|
||||
})
|
||||
|
||||
const start = (key: Key, entry: Entry<A, E>, demand: Demand, successor = false) => {
|
||||
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
|
||||
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(
|
||||
(successor
|
||||
? Effect.yieldNow.pipe(Effect.andThen(drain))
|
||||
: Deferred.await(ready).pipe(Effect.andThen(drain))
|
||||
).pipe(
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))),
|
||||
(successor ? Effect.yieldNow : Deferred.await(ready)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => options.drain(key, force))),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
),
|
||||
@@ -109,176 +46,57 @@ export const make = <Key, A, E>(options: {
|
||||
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
|
||||
}
|
||||
|
||||
const settle = (key: Key, entry: Entry<A, E>, demand: Demand, exit: Exit.Exit<A, E>) => {
|
||||
if (closed) {
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
|
||||
return
|
||||
}
|
||||
if (demand._tag === "run" && entry.explicitWaiter !== undefined) {
|
||||
Deferred.doneUnsafe(entry.explicitWaiter, exit)
|
||||
entry.explicitWaiter = undefined
|
||||
}
|
||||
if (entry.stopping && demand._tag === "wake" && entry.explicitWaiter !== undefined) {
|
||||
Deferred.doneUnsafe(entry.explicitWaiter, exit)
|
||||
entry.explicitWaiter = undefined
|
||||
}
|
||||
if (active.get(key) !== entry) {
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
|
||||
return
|
||||
}
|
||||
if (exit._tag === "Success" && !entry.stopping) {
|
||||
if (entry.pending !== undefined) {
|
||||
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))
|
||||
const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => {
|
||||
if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) {
|
||||
entry.pendingWake = false
|
||||
start(key, entry, false, true)
|
||||
return
|
||||
}
|
||||
|
||||
const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined
|
||||
const successor = entry.pendingWake ? makeEntry() : 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)))
|
||||
else {
|
||||
active.set(key, successor)
|
||||
start(key, successor, false, true)
|
||||
}
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
}
|
||||
|
||||
const wake = (key: Key, seq?: number) =>
|
||||
Effect.sync(() => {
|
||||
if (closed) return
|
||||
if (!isAfterInterrupt(key, seq)) return
|
||||
const run = (key: Key): Effect.Effect<void, E> =>
|
||||
Effect.uninterruptibleMask((restore) => {
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
if (!acceptsWake(entry, seq)) return
|
||||
entry.pending = coalesce(entry.pending, { _tag: "wake", seq })
|
||||
if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
|
||||
return restore(Deferred.await(entry.done))
|
||||
}
|
||||
|
||||
const next = makeEntry()
|
||||
active.set(key, next)
|
||||
start(key, next, true)
|
||||
return restore(Deferred.await(next.done))
|
||||
})
|
||||
|
||||
const wake = (key: Key) =>
|
||||
Effect.sync(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
entry.pendingWake = true
|
||||
return
|
||||
}
|
||||
|
||||
const next = makeEntry({ _tag: "wake", seq })
|
||||
const next = makeEntry()
|
||||
active.set(key, next)
|
||||
start(key, next, next.current)
|
||||
start(key, next, false)
|
||||
})
|
||||
|
||||
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> =>
|
||||
const interrupt = (key: Key): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
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 (
|
||||
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.interruptSeq = seq
|
||||
suppressPendingAtOrBefore(entry, seq)
|
||||
entry.pendingWake = false
|
||||
return Fiber.interrupt(entry.owner)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
return { run, wake, interrupt }
|
||||
})
|
||||
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SessionSchema } from "../schema"
|
||||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { SessionContextEpoch } from "../context-epoch"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
export type RunError =
|
||||
@@ -15,7 +14,6 @@ export type RunError =
|
||||
| MessageDecodeError
|
||||
| ContextSnapshotDecodeError
|
||||
| SystemContext.InitializationBlocked
|
||||
| SessionContextEpoch.AgentReplacementBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
@@ -23,7 +21,7 @@ export interface Interface {
|
||||
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */
|
||||
readonly run: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force?: boolean
|
||||
readonly force: boolean
|
||||
}) => Effect.Effect<void, RunError>
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
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)
|
||||
|
||||
type TurnTransition =
|
||||
// Request preparation observed a concurrent Session change and must restart from durable state.
|
||||
| { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery }
|
||||
// Automatic compaction completed; rebuild the request from compacted history.
|
||||
| { readonly _tag: "ContinueAfterCompaction" }
|
||||
// Overflow compaction completed; rebuild once through the path without overflow recovery.
|
||||
| { readonly _tag: "ContinueAfterOverflowCompaction" }
|
||||
|
||||
@@ -152,20 +152,11 @@ export const layer = Layer.effect(
|
||||
}
|
||||
}
|
||||
|
||||
const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) =>
|
||||
new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion })
|
||||
const continueAfterCompaction = new TurnTransitionError({ _tag: "ContinueAfterCompaction" })
|
||||
const continueAfterOverflowCompaction = new TurnTransitionError({
|
||||
_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) =>
|
||||
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
|
||||
concurrency: "unbounded",
|
||||
@@ -181,13 +172,7 @@ export const layer = Layer.effect(
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
const agent = yield* agents.select(session.agent)
|
||||
const initialized = yield* SessionContextEpoch.initialize(
|
||||
db,
|
||||
loadSystemContext(agent),
|
||||
session.id,
|
||||
session.location,
|
||||
agent.id,
|
||||
).pipe(retryAgentMismatch(promotion))
|
||||
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
let needsContinuation = false
|
||||
if (promotion) {
|
||||
@@ -199,18 +184,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
}
|
||||
const system =
|
||||
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())
|
||||
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
@@ -228,7 +202,7 @@ export const layer = Layer.effect(
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
return yield* Effect.die(continueAfterCompaction)
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
@@ -242,8 +216,6 @@ export const layer = Layer.effect(
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
||||
withPublication(publisher.publish(event, outputPaths))
|
||||
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(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -305,14 +277,7 @@ export const layer = Layer.effect(
|
||||
const llmFailure = failure instanceof LLMError ? failure : undefined
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
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 },
|
||||
}),
|
||||
)
|
||||
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
|
||||
}
|
||||
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
@@ -327,6 +292,8 @@ export const layer = Layer.effect(
|
||||
) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
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)) {
|
||||
const failure = Cause.squash(settled.cause)
|
||||
@@ -357,7 +324,7 @@ export const layer = Layer.effect(
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
|
||||
yield* Effect.yieldNow
|
||||
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -371,7 +338,7 @@ export const layer = Layer.effect(
|
||||
yield* Effect.yieldNow
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
|
||||
return yield* runTurn(sessionID, defect.transition.promotion, step)
|
||||
return yield* runTurn(sessionID, undefined, step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -379,14 +346,14 @@ export const layer = Layer.effect(
|
||||
|
||||
const run = Effect.fn("SessionRunner.run")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force?: boolean
|
||||
readonly force: boolean
|
||||
}) {
|
||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
if (input.force !== true && !hasSteer && !hasQueue) return
|
||||
if (!input.force && !hasSteer && !hasQueue) return
|
||||
yield* failInterruptedTools(input.sessionID)
|
||||
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
let openActivity = input.force === true || hasSteer || hasQueue
|
||||
let openActivity = input.force || hasSteer || hasQueue
|
||||
while (openActivity) {
|
||||
let needsContinuation = true
|
||||
for (let step = 1; needsContinuation; step++) {
|
||||
|
||||
@@ -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 OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
|
||||
import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
@@ -24,6 +24,23 @@ 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>()(
|
||||
"SessionRunnerModel.UnsupportedApiError",
|
||||
{
|
||||
@@ -33,7 +50,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
|
||||
},
|
||||
) {}
|
||||
|
||||
export type Error = ModelNotSelectedError | UnsupportedApiError
|
||||
export type Error = ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError
|
||||
|
||||
export interface Interface {
|
||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
|
||||
@@ -44,7 +61,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
/** Test or embedding seam for supplying a model resolver directly. */
|
||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
|
||||
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => {
|
||||
if (credential?.value.type === "key") return Auth.value(credential.value.key)
|
||||
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
|
||||
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
||||
@@ -70,13 +87,27 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||
})
|
||||
}
|
||||
|
||||
const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
|
||||
const withVariant = (
|
||||
model: ModelV2.Info,
|
||||
variantID: ModelV2.VariantID | undefined,
|
||||
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
|
||||
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
|
||||
const variant = model.variants.find((item) => item.id === id)
|
||||
if (!variant) return model
|
||||
return produce(model, (draft) => {
|
||||
ModelRequest.assign(draft.request, variant)
|
||||
})
|
||||
if (!variant && variantID !== undefined && variantID !== "default")
|
||||
return Effect.fail(
|
||||
new VariantUnavailableError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variant: variantID,
|
||||
}),
|
||||
)
|
||||
return Effect.succeed(
|
||||
variant
|
||||
? produce(model, (draft) => {
|
||||
ModelRequest.assign(draft.request, variant)
|
||||
})
|
||||
: model,
|
||||
)
|
||||
}
|
||||
|
||||
const apiName = (model: ModelV2.Info) =>
|
||||
@@ -85,7 +116,7 @@ const apiName = (model: ModelV2.Info) =>
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
connection?: IntegrationConnection.Info,
|
||||
credential?: Credential.Stored,
|
||||
credential?: Credential.Info,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
const resolved =
|
||||
credential?.value.metadata === undefined
|
||||
@@ -124,8 +155,15 @@ export const fromCatalogModel = (
|
||||
)
|
||||
}
|
||||
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant))
|
||||
export const resolve = (
|
||||
session: SessionSchema.Info,
|
||||
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) =>
|
||||
model.api.type === "aisdk" &&
|
||||
@@ -147,14 +185,22 @@ export const locationLayer = Layer.effect(
|
||||
yield* boot.wait()
|
||||
const defaultModel = session.model ? undefined : yield* catalog.model.default()
|
||||
const selected = session.model
|
||||
? yield* catalog.model.get(session.model.providerID, session.model.id)
|
||||
? (yield* catalog.model.available()).find(
|
||||
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
|
||||
)
|
||||
: defaultModel && supported(defaultModel)
|
||||
? defaultModel
|
||||
: (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 })
|
||||
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
||||
return yield* fromCatalogModel(
|
||||
withVariant(selected, session.model?.variant),
|
||||
return yield* resolve(
|
||||
session,
|
||||
selected,
|
||||
connection,
|
||||
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
|
||||
)
|
||||
|
||||
@@ -65,11 +65,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
>()
|
||||
const timestamp = DateTime.now
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
let assistantActive = false
|
||||
let assistantFailed = false
|
||||
let providerFailed = false
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (assistantMessageID !== undefined) return assistantMessageID
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
assistantActive = true
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
...input,
|
||||
assistantMessageID,
|
||||
@@ -190,6 +193,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
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* (
|
||||
message: string,
|
||||
hostedOnly = false,
|
||||
@@ -375,6 +392,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
}
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
assistantActive = false
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
@@ -388,13 +406,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
return
|
||||
case "provider-error":
|
||||
providerFailed = true
|
||||
yield* flush()
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
error: { type: "unknown", message: event.message },
|
||||
})
|
||||
yield* failAssistant(event.message)
|
||||
return
|
||||
}
|
||||
})
|
||||
@@ -402,10 +414,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
return {
|
||||
publish,
|
||||
flush,
|
||||
failAssistant,
|
||||
failUnsettledTools,
|
||||
hasActiveAssistant: () => assistantActive,
|
||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||
hasProviderError: () => providerFailed,
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,12 +82,21 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
|
||||
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
|
||||
.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))
|
||||
.filter((message) => message !== undefined)
|
||||
.map(Message.tool)
|
||||
return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results]
|
||||
if (meaningful.length === 0) return results
|
||||
return [
|
||||
Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }),
|
||||
...results,
|
||||
]
|
||||
}
|
||||
|
||||
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
|
||||
|
||||
@@ -170,9 +170,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
|
||||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
baseline: text().notNull(),
|
||||
agent: text().$type<AgentV2.ID>().notNull().default(AgentV2.defaultID),
|
||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
|
||||
baseline_seq: integer().notNull(),
|
||||
replacement_seq: integer(),
|
||||
revision: integer().notNull().default(0),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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)
|
||||
})
|
||||
@@ -13,23 +13,61 @@ export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
|
||||
const MAX_LINE_LENGTH = 2_000
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
|
||||
export class BinaryFileError extends Error {
|
||||
constructor(readonly resource: string) {
|
||||
super(`Cannot read binary file: ${resource}`)
|
||||
this.name = "BinaryFileError"
|
||||
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", {
|
||||
resource: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Cannot read binary file: ${this.resource}`
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaIngestLimitError extends Error {
|
||||
constructor(
|
||||
readonly resource: string,
|
||||
readonly maximumBytes: number,
|
||||
) {
|
||||
super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`)
|
||||
this.name = "MediaIngestLimitError"
|
||||
export class MediaIngestLimitError extends Schema.TaggedErrorClass<MediaIngestLimitError>()(
|
||||
"ReadTool.MediaIngestLimitError",
|
||||
{
|
||||
resource: Schema.String,
|
||||
maximumBytes: Schema.Number,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Media exceeds ${this.maximumBytes} byte ingestion limit: ${this.resource}`
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
offset: PositiveInt.pipe(Schema.optional),
|
||||
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
|
||||
@@ -52,13 +90,13 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory">
|
||||
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError>
|
||||
readonly read: (
|
||||
path: AbsolutePath,
|
||||
resource: string,
|
||||
page?: PageInput,
|
||||
) => Effect.Effect<FileSystem.Content | TextPage>
|
||||
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage>
|
||||
) => Effect.Effect<FileSystem.Content | TextPage, ReadError>
|
||||
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
||||
@@ -111,11 +149,21 @@ const binary = (resource: string, bytes: Uint8Array) => {
|
||||
}
|
||||
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) {
|
||||
const info = yield* fs.stat(input).pipe(Effect.orDie)
|
||||
const info = yield* fs.stat(input)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
|
||||
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
|
||||
return type
|
||||
})
|
||||
|
||||
@@ -125,32 +173,30 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
resource: string,
|
||||
page: PageInput = {},
|
||||
) {
|
||||
const real = yield* fs.realPath(input).pipe(Effect.orDie)
|
||||
const real = yield* fs.realPath(input)
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const file = yield* fs.open(real, { flag: "r" }).pipe(Effect.orDie)
|
||||
const info = yield* file.stat.pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
const file = yield* fs.open(real, { flag: "r" })
|
||||
const info = yield* file.stat
|
||||
if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" }))
|
||||
const first = Option.getOrElse(
|
||||
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)).pipe(Effect.orDie),
|
||||
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)),
|
||||
() => new Uint8Array(),
|
||||
)
|
||||
const mime = imageMime(first)
|
||||
if (mime) {
|
||||
if (info.size > MAX_MEDIA_INGEST_BYTES)
|
||||
return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
|
||||
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
||||
const chunks = [first]
|
||||
let total = first.length
|
||||
while (total <= MAX_MEDIA_INGEST_BYTES) {
|
||||
const chunk = yield* file
|
||||
.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
|
||||
.pipe(Effect.orDie)
|
||||
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
|
||||
if (Option.isNone(chunk)) break
|
||||
chunks.push(chunk.value)
|
||||
total += chunk.value.length
|
||||
}
|
||||
if (total > MAX_MEDIA_INGEST_BYTES)
|
||||
return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
|
||||
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
||||
return {
|
||||
uri: pathToFileURL(real).href,
|
||||
name: path.basename(real),
|
||||
@@ -162,19 +208,19 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
mime,
|
||||
}
|
||||
}
|
||||
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || binary(resource, first))
|
||||
return yield* Effect.die(new BinaryFileError(resource))
|
||||
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || extensions.has(path.extname(resource).toLowerCase()))
|
||||
return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||
if (!paged) {
|
||||
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||
const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))]
|
||||
const text = [yield* decodeUtf8(resource, decoder, first)]
|
||||
while (true) {
|
||||
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
|
||||
const chunk = yield* file.readAlloc(64 * 1024)
|
||||
if (Option.isNone(chunk)) break
|
||||
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* decodeChunk(resource, decoder, chunk.value))
|
||||
}
|
||||
text.push(yield* Effect.sync(() => decoder.decode()))
|
||||
text.push(yield* decodeUtf8(resource, decoder))
|
||||
return {
|
||||
uri: pathToFileURL(real).href,
|
||||
name: path.basename(real),
|
||||
@@ -191,34 +237,29 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
let discard = false
|
||||
let line = 1
|
||||
let bytes = 0
|
||||
let found = false
|
||||
let truncated = false
|
||||
let next: number | undefined
|
||||
const append = (input: string) => {
|
||||
if (line < offset) {
|
||||
line++
|
||||
return
|
||||
return true
|
||||
}
|
||||
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
|
||||
truncated = true
|
||||
next ??= line++
|
||||
return
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
found = true
|
||||
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)
|
||||
if (bytes + size > MAX_READ_BYTES) {
|
||||
truncated = true
|
||||
next ??= line++
|
||||
return
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
lines.push(text)
|
||||
bytes += size
|
||||
line++
|
||||
return true
|
||||
}
|
||||
const consume = (chunk: Uint8Array) => {
|
||||
if (chunk.includes(0)) throw new BinaryFileError(resource)
|
||||
let text = decoder.decode(chunk, { stream: true })
|
||||
const consume = (input: string) => {
|
||||
let text = input
|
||||
while (true) {
|
||||
const index = text.indexOf("\n")
|
||||
if (index === -1) {
|
||||
@@ -235,25 +276,44 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
pending = ""
|
||||
discard = false
|
||||
text = text.slice(index + 1)
|
||||
append(current.endsWith("\r") ? current.slice(0, -1) : current)
|
||||
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
yield* Effect.sync(() => consume(first))
|
||||
while (true) {
|
||||
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
|
||||
const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
|
||||
let start = 0
|
||||
while (start < chunk.length) {
|
||||
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
|
||||
yield* Effect.sync(() => consume(chunk.value))
|
||||
done = !(yield* consumeChunk(chunk.value))
|
||||
}
|
||||
const tail = yield* Effect.sync(() => decoder.decode())
|
||||
if (!discard) pending += tail
|
||||
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 (!done) {
|
||||
const tail = yield* decodeUtf8(resource, decoder)
|
||||
if (!discard) pending += tail
|
||||
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
||||
}
|
||||
if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset }))
|
||||
return new TextPage({
|
||||
type: "text-page",
|
||||
content: lines.join("\n"),
|
||||
mime: FSUtil.mimeType(real),
|
||||
offset,
|
||||
truncated,
|
||||
truncated: next !== undefined,
|
||||
...(next === undefined ? {} : { next }),
|
||||
})
|
||||
}),
|
||||
@@ -261,8 +321,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
})
|
||||
|
||||
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
|
||||
const real = yield* fs.realPath(input).pipe(Effect.orDie)
|
||||
const items = yield* fs.readDirectoryEntries(real).pipe(Effect.orDie)
|
||||
const real = yield* fs.realPath(input)
|
||||
const items = yield* fs.readDirectoryEntries(real)
|
||||
const offset = page.offset ?? 1
|
||||
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
||||
const entries = yield* Effect.forEach(
|
||||
|
||||
@@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard(
|
||||
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
|
||||
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
|
||||
return yield* Effect.die(new Error("Path escapes the allowed read root"))
|
||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||
const root = yield* fs.realPath(selected).pipe(Effect.orDie)
|
||||
const real = yield* fs.realPath(absolute)
|
||||
const root = yield* fs.realPath(selected)
|
||||
if (!FSUtil.contains(root, real))
|
||||
return yield* Effect.die(new Error("Path escapes the allowed read root"))
|
||||
const resource = path.relative(root, real).replaceAll("\\", "/") || "."
|
||||
@@ -83,7 +83,7 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
|
||||
}
|
||||
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
|
||||
}).pipe(
|
||||
Effect.mapError((error) => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export * as WebFetchTool from "./webfetch"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Duration, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Parser } from "htmlparser2"
|
||||
import TurndownService from "turndown"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -86,24 +87,11 @@ const execute = (http: HttpClient.HttpClient, url: string, format: Format, userA
|
||||
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
|
||||
|
||||
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
Effect.gen(function* () {
|
||||
const contentLength = response.headers["content-length"]
|
||||
if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
|
||||
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)
|
||||
})
|
||||
collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
() => new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`),
|
||||
)
|
||||
|
||||
const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||
const isImageAttachment = (mime: string) =>
|
||||
@@ -171,12 +159,16 @@ export const layer = Layer.effectDiscard(
|
||||
orElse: () => Effect.fail(new Error("Request timed out")),
|
||||
}),
|
||||
)
|
||||
const content = convert(new TextDecoder().decode(body), contentType, input.format)
|
||||
const content = new TextDecoder().decode(body)
|
||||
const output = yield* Effect.try({
|
||||
try: () => convert(content, contentType, input.format),
|
||||
catch: (error) => error,
|
||||
})
|
||||
return {
|
||||
url: input.url,
|
||||
contentType,
|
||||
format: input.format,
|
||||
output: content,
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PositiveInt } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { checksum } from "../util/encode"
|
||||
|
||||
export const name = "websearch"
|
||||
@@ -164,10 +165,12 @@ const callMcp = <F extends Schema.Struct.Fields>(
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* response.text
|
||||
if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES)
|
||||
return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
|
||||
return yield* parseResponse(body)
|
||||
const body = yield* collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
|
||||
)
|
||||
return yield* parseResponse(body.toString("utf8"))
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
|
||||
@@ -11,7 +11,11 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { required } from "./plugin/provider-helper"
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -21,12 +25,7 @@ const it = testEffect(
|
||||
Catalog.locationLayer.pipe(
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.mock(Credential.Service)({
|
||||
all: () => Effect.succeed([]),
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
Layer.provideMerge(Credential.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -48,38 +47,30 @@ describe("CatalogV2", () => {
|
||||
|
||||
it.effect("derives availability from active credentials without changing provider state", () => {
|
||||
const integrationID = Integration.ID.make("test")
|
||||
const first = {
|
||||
id: Credential.ID.create(),
|
||||
integrationID,
|
||||
label: "First",
|
||||
value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
|
||||
}
|
||||
const second = {
|
||||
id: Credential.ID.create(),
|
||||
integrationID,
|
||||
label: "Second",
|
||||
value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
|
||||
}
|
||||
let active = first
|
||||
const layer = Catalog.locationLayer.pipe(
|
||||
Layer.fresh,
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.mock(Credential.Service)({
|
||||
all: () => Effect.sync(() => [active]),
|
||||
list: () => Effect.sync(() => [active]),
|
||||
}),
|
||||
),
|
||||
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
yield* credentials.create({
|
||||
integrationID,
|
||||
label: "First",
|
||||
value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
active = second
|
||||
yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Second",
|
||||
value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
@@ -1,14 +1,52 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it, required, withEnv } from "../plugin/provider-helper"
|
||||
import { catalogHost, host, integrationHost } from "../plugin/host"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* (config: Config.Interface) {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)),
|
||||
})
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function request(headers: Record<string, string>, variant?: string) {
|
||||
return {
|
||||
@@ -23,8 +61,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("partitions existing model variant bodies without changing config shape", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.opencode
|
||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||
const config = Config.Service.of({
|
||||
@@ -57,12 +93,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
]),
|
||||
})
|
||||
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect(
|
||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
|
||||
).pipe(Effect.provideService(Config.Service, config)),
|
||||
})
|
||||
yield* addPlugin(config)
|
||||
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect(model.variants).toMatchObject([
|
||||
@@ -82,8 +113,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("uses the effective provider package across layered config", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.opencode
|
||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||
const config = Config.Service.of({
|
||||
@@ -116,12 +145,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
]),
|
||||
})
|
||||
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect(
|
||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
|
||||
).pipe(Effect.provideService(Config.Service, config)),
|
||||
})
|
||||
yield* addPlugin(config)
|
||||
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect(model.variants[0]).toMatchObject({
|
||||
@@ -137,7 +161,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
@@ -217,12 +240,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
]),
|
||||
})
|
||||
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect(
|
||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
|
||||
).pipe(Effect.provideService(Config.Service, config)),
|
||||
})
|
||||
yield* addPlugin(config)
|
||||
|
||||
const provider = required(yield* catalog.provider.get(providerID))
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
|
||||
@@ -1,49 +1,35 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
function layer(directory: string) {
|
||||
return Credential.layer.pipe(
|
||||
Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)),
|
||||
)
|
||||
}
|
||||
const it = testEffect(Credential.defaultLayer)
|
||||
|
||||
describe("Credential", () => {
|
||||
it.live("stores, updates, lists, and removes credentials", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const created = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Work",
|
||||
value: new Credential.Key({ type: "key", key: "secret" }),
|
||||
})
|
||||
it.effect("stores, updates, lists, and removes credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const created = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Work",
|
||||
value: new Credential.Key({ type: "key", key: "secret" }),
|
||||
})
|
||||
|
||||
expect(yield* credentials.list(integrationID)).toEqual([created])
|
||||
yield* credentials.update(created.id, { label: "Personal" })
|
||||
expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal")
|
||||
expect(yield* credentials.list(integrationID)).toEqual([created])
|
||||
yield* credentials.update(created.id, { label: "Personal" })
|
||||
expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal")
|
||||
|
||||
const replacement = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Replacement",
|
||||
value: new Credential.Key({ type: "key", key: "replacement" }),
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toEqual([replacement])
|
||||
const replacement = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Replacement",
|
||||
value: new Credential.Key({ type: "key", key: "replacement" }),
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toEqual([replacement])
|
||||
|
||||
yield* credentials.remove(replacement.id)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
}).pipe(Effect.provide(layer(tmp.path))),
|
||||
),
|
||||
),
|
||||
yield* credentials.remove(replacement.id)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -71,9 +71,9 @@ describe("DatabaseMigration", () => {
|
||||
).toEqual({ name: "session_context_epoch" })
|
||||
expect(
|
||||
yield* db.get(
|
||||
sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`,
|
||||
sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name IN ('agent', 'replacement_seq', 'revision')`,
|
||||
),
|
||||
).toEqual({ name: "agent", dflt_value: "'build'" })
|
||||
).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
|
||||
expect(
|
||||
yield* db.all(
|
||||
|
||||
@@ -420,13 +420,12 @@ describe("EventV2", () => {
|
||||
const readStarted = yield* Deferred.make<void>()
|
||||
const continueRead = yield* Deferred.make<void>()
|
||||
let pause = true
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const eventLayer = EventV2.layerWith({
|
||||
beforeAggregateRead: () =>
|
||||
pause
|
||||
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
|
||||
: Effect.void,
|
||||
}).pipe(Layer.provide(database))
|
||||
}).pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
@@ -441,7 +440,7 @@ describe("EventV2", () => {
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
[0, { id: aggregateID, text: "during handoff" }],
|
||||
])
|
||||
}).pipe(Effect.provide(Layer.mergeAll(database, eventLayer)))
|
||||
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import path from "path"
|
||||
|
||||
const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
|
||||
const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer)
|
||||
const { effect: it } = testEffect(live)
|
||||
|
||||
describe("FSUtil", () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("Ripgrep", () => {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
||||
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
|
||||
expect(result.map((item) => item.path)).toEqual([RelativePath.make(path.join("src", "match.ts"))])
|
||||
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -35,7 +35,7 @@ describe("Ripgrep", () => {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
|
||||
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.entry.path).toBe(RelativePath.make(path.join("src", "match.ts")))
|
||||
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
|
||||
expect(result[0]?.submatches[0]?.text).toBe("needle")
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tmpdir } from "./tmpdir"
|
||||
|
||||
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
|
||||
return {
|
||||
@@ -10,3 +12,15 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
|
||||
vcs: input.vcs,
|
||||
} satisfies Location.Interface
|
||||
}
|
||||
|
||||
export const tempLocationLayer = Layer.unwrap(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.map((tmp) => {
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
|
||||
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,45 +4,12 @@ import * as TestClock from "effect/testing/TestClock"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { it } from "./lib/effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const layer = Integration.locationLayer.pipe(
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mock(Credential.Service)({
|
||||
create: () => Effect.die("unexpected credential creation"),
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
const it = testEffect(
|
||||
Integration.locationLayer.pipe(Layer.provideMerge(Credential.defaultLayer), Layer.provideMerge(EventV2.defaultLayer)),
|
||||
)
|
||||
|
||||
function connectionLayer(
|
||||
created: Array<{
|
||||
integrationID: Integration.ID
|
||||
label?: string
|
||||
value: Credential.Info
|
||||
}>,
|
||||
) {
|
||||
return Integration.locationLayer.pipe(
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mock(Credential.Service)({
|
||||
create: (input) =>
|
||||
Effect.sync(() => {
|
||||
created.push(input)
|
||||
return new Credential.Stored({
|
||||
id: Credential.ID.create(),
|
||||
integrationID: input.integrationID,
|
||||
label: input.label ?? "default",
|
||||
value: input.value,
|
||||
})
|
||||
}),
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe("Integration", () => {
|
||||
it.effect("registers integrations through the editor", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -59,7 +26,7 @@ describe("Integration", () => {
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* integrations.get(openai)).toBeUndefined()
|
||||
}).pipe(Effect.provide(layer)),
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reveals the previous registration when an override closes", () =>
|
||||
@@ -80,7 +47,7 @@ describe("Integration", () => {
|
||||
yield* Scope.close(second, Exit.void)
|
||||
expect((yield* integrations.get(id))?.name).toBe("OpenAI")
|
||||
expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id])
|
||||
}).pipe(Effect.provide(layer)),
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers and overrides methods independently", () =>
|
||||
@@ -128,17 +95,13 @@ describe("Integration", () => {
|
||||
yield* Scope.close(second, Exit.void)
|
||||
expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" })
|
||||
expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })])
|
||||
}).pipe(Effect.provide(layer)),
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("connects with a key and stores the credential", () => {
|
||||
const created: Array<{
|
||||
integrationID: Integration.ID
|
||||
label?: string
|
||||
value: Credential.Info
|
||||
}> = []
|
||||
return Effect.gen(function* () {
|
||||
it.effect("connects with a key and stores the credential", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const events = yield* EventV2.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
yield* integrations.transform((editor) =>
|
||||
@@ -158,25 +121,21 @@ describe("Integration", () => {
|
||||
label: "Work",
|
||||
})
|
||||
|
||||
expect(created).toEqual([
|
||||
{
|
||||
expect(yield* credentials.list(integrationID)).toEqual([
|
||||
expect.objectContaining({
|
||||
integrationID,
|
||||
label: "Work",
|
||||
value: new Credential.Key({ type: "key", key: "secret" }),
|
||||
},
|
||||
}),
|
||||
])
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
}).pipe(Effect.provide(connectionLayer(created)))
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("completes code OAuth once and stores the credential", () => {
|
||||
const created: Array<{
|
||||
integrationID: Integration.ID
|
||||
label?: string
|
||||
value: Credential.Info
|
||||
}> = []
|
||||
return Effect.gen(function* () {
|
||||
it.effect("completes code OAuth once and stores the credential", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const methodID = Integration.MethodID.make("chatgpt")
|
||||
yield* integrations.transform((editor) =>
|
||||
@@ -212,29 +171,27 @@ describe("Integration", () => {
|
||||
expect(attempt.mode).toBe("code")
|
||||
yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" })
|
||||
|
||||
expect(created[0]).toEqual({
|
||||
integrationID,
|
||||
label: "Personal",
|
||||
value: new Credential.OAuth({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
metadata: { code: "1234" },
|
||||
expect((yield* credentials.list(integrationID))[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
integrationID,
|
||||
label: "Personal",
|
||||
value: new Credential.OAuth({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
metadata: { code: "1234" },
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}).pipe(Effect.provide(connectionLayer(created)))
|
||||
})
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => {
|
||||
const created: Array<{
|
||||
integrationID: Integration.ID
|
||||
label?: string
|
||||
value: Credential.Info
|
||||
}> = []
|
||||
return Effect.gen(function* () {
|
||||
it.effect("keeps code attempts open when the code is missing and closes them on cancel", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const methodID = Integration.MethodID.make("chatgpt")
|
||||
let closed = false
|
||||
@@ -261,18 +218,14 @@ describe("Integration", () => {
|
||||
expect(closed).toBe(false)
|
||||
yield* integrations.attempt.cancel(attempt.attemptID)
|
||||
expect(closed).toBe(true)
|
||||
expect(created).toEqual([])
|
||||
}).pipe(Effect.provide(connectionLayer(created)))
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("completes auto OAuth in the background", () => {
|
||||
const created: Array<{
|
||||
integrationID: Integration.ID
|
||||
label?: string
|
||||
value: Credential.Info
|
||||
}> = []
|
||||
return Effect.gen(function* () {
|
||||
it.effect("completes auto OAuth in the background", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
yield* integrations.transform((editor) =>
|
||||
@@ -297,18 +250,14 @@ describe("Integration", () => {
|
||||
status: "complete",
|
||||
time: attempt.time,
|
||||
})
|
||||
expect(created).toHaveLength(1)
|
||||
}).pipe(Effect.provide(connectionLayer(created)))
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("expires abandoned OAuth attempts", () => {
|
||||
const created: Array<{
|
||||
integrationID: Integration.ID
|
||||
label?: string
|
||||
value: Credential.Info
|
||||
}> = []
|
||||
return Effect.gen(function* () {
|
||||
it.effect("expires abandoned OAuth attempts", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
let closed = false
|
||||
@@ -337,34 +286,12 @@ describe("Integration", () => {
|
||||
time: attempt.time,
|
||||
})
|
||||
expect(closed).toBe(true)
|
||||
expect(created).toEqual([])
|
||||
}).pipe(Effect.provide(connectionLayer(created)))
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects credential and env connections", () => {
|
||||
const integrationID = Integration.ID.make("acme")
|
||||
const rows = [
|
||||
{
|
||||
id: Credential.ID.create(),
|
||||
integrationID,
|
||||
label: "Work",
|
||||
value: new Credential.Key({ type: "key", key: "a" }),
|
||||
},
|
||||
{
|
||||
id: Credential.ID.create(),
|
||||
integrationID,
|
||||
label: "Personal",
|
||||
value: new Credential.Key({ type: "key", key: "b" }),
|
||||
},
|
||||
]
|
||||
const projectionLayer = Integration.locationLayer.pipe(
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mock(Credential.Service)({
|
||||
list: () => Effect.succeed(rows.map((row) => new Credential.Stored(row))),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.INTEGRATION_TEST_ACME_KEY
|
||||
@@ -375,6 +302,7 @@ describe("Integration", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* integrations.transform((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
@@ -384,23 +312,33 @@ describe("Integration", () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
const work = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Work",
|
||||
value: new Credential.Key({ type: "key", key: "a" }),
|
||||
})
|
||||
const personal = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Personal",
|
||||
value: new Credential.Key({ type: "key", key: "b" }),
|
||||
})
|
||||
|
||||
// Stored credentials and detected env vars appear as connections.
|
||||
expect((yield* integrations.get(integrationID))?.connections).toEqual([
|
||||
{ type: "credential", id: rows[0]!.id, label: "Work" },
|
||||
{
|
||||
type: "credential",
|
||||
id: rows[1]!.id,
|
||||
id: personal.id,
|
||||
label: "Personal",
|
||||
},
|
||||
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
|
||||
])
|
||||
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
|
||||
type: "credential",
|
||||
id: rows[1]!.id,
|
||||
id: personal.id,
|
||||
label: "Personal",
|
||||
})
|
||||
}).pipe(Effect.provide(projectionLayer)),
|
||||
expect(work.id).not.toBe(personal.id)
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -31,13 +31,6 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
|
||||
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", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -67,39 +60,13 @@ describe("FileSystem", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("fails for missing paths", () =>
|
||||
it.live("rejects lexical escapes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* (yield* FileSystem.Service)
|
||||
.read({ path: RelativePath.make("missing.txt") })
|
||||
const result = yield* (yield* FileSystem.Service)
|
||||
.read({ path: RelativePath.make("../outside.txt") })
|
||||
.pipe(Effect.exit)
|
||||
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))
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect"
|
||||
import { DateTime, Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect"
|
||||
import { Tool } from "@opencode-ai/core/public"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
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 { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions } from "./lib/tool"
|
||||
@@ -36,8 +40,7 @@ const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Project.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
Credential.defaultLayer,
|
||||
Credential.layer.pipe(Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh))),
|
||||
Credential.defaultLayer.pipe(Layer.fresh),
|
||||
Npm.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
@@ -137,6 +140,56 @@ 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", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -21,34 +21,39 @@ import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
|
||||
const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events))
|
||||
const project = Project.layer.pipe(
|
||||
Layer.provide(database),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(directories),
|
||||
Layer.provide(ProjectDirectories.defaultLayer),
|
||||
)
|
||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(database),
|
||||
Layer.provide(events),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(project),
|
||||
Layer.provide(store),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const layer = MoveSession.layer.pipe(
|
||||
Layer.provide(database),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(events),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(project),
|
||||
Layer.provide(sessions),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions),
|
||||
Layer.mergeAll(
|
||||
layer,
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
ProjectDirectories.defaultLayer,
|
||||
project,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
SessionExecution.noopLayer,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
|
||||
function abs(input: string) {
|
||||
|
||||
@@ -18,29 +18,25 @@ import { eq } from "drizzle-orm"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const current = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
)
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const saved = PermissionSaved.layer.pipe(Layer.provide(database))
|
||||
const layer = PermissionV2.locationLayer.pipe(
|
||||
Layer.provideMerge(database),
|
||||
Layer.provideMerge(store),
|
||||
Layer.provideMerge(events),
|
||||
Layer.provideMerge(Database.defaultLayer),
|
||||
Layer.provideMerge(SessionStore.defaultLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(current),
|
||||
Layer.provideMerge(sessions),
|
||||
Layer.provideMerge(SessionExecution.noopLayer),
|
||||
Layer.provideMerge(saved),
|
||||
Layer.provideMerge(PermissionSaved.defaultLayer),
|
||||
)
|
||||
const it = testEffect(layer)
|
||||
|
||||
|
||||
@@ -46,6 +46,40 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches plugin state rebuilds when the registry layer finalizes", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
const values = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (value: string) => draft.values.push(value) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
const layerScope = yield* Scope.fork(yield* Scope.Scope)
|
||||
const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service)
|
||||
|
||||
yield* State.batch(
|
||||
Effect.forEach(
|
||||
["first", "second"],
|
||||
(id) =>
|
||||
plugin.add({
|
||||
id: PluginV2.ID.make(id),
|
||||
effect: values
|
||||
.transform((editor) => {
|
||||
editor.add(id)
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
}),
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
finalized = 0
|
||||
|
||||
yield* Scope.close(layerScope, Exit.void)
|
||||
expect(values.get().values).toEqual([])
|
||||
expect(finalized).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serializes same-ID additions and leaves one removable attachment", () =>
|
||||
Effect.gen(function* () {
|
||||
const values = state()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
|
||||
export const PluginTestLayer = Layer.mergeAll(
|
||||
AgentV2.locationLayer,
|
||||
CommandV2.locationLayer,
|
||||
Catalog.locationLayer,
|
||||
FileSystem.locationLayer,
|
||||
PluginV2.locationLayer,
|
||||
Reference.locationLayer,
|
||||
SkillV2.locationLayer,
|
||||
).pipe(
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
Credential.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Global.defaultLayer,
|
||||
Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
),
|
||||
RepositoryCache.defaultLayer,
|
||||
SkillDiscovery.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
tempLocationLayer,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -24,11 +24,7 @@ const locationLayer = Layer.succeed(
|
||||
)
|
||||
const plugins = PluginV2.layer.pipe(Layer.provide(events))
|
||||
const policy = Policy.layer.pipe(Layer.provide(locationLayer))
|
||||
const connections = Credential.layer.pipe(
|
||||
Layer.fresh,
|
||||
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
|
||||
Layer.provide(events),
|
||||
)
|
||||
const connections = Credential.defaultLayer.pipe(Layer.fresh)
|
||||
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
|
||||
const catalog = Catalog.layer.pipe(
|
||||
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
|
||||
|
||||
@@ -3,17 +3,35 @@ import { createAlibaba } from "@ai-sdk/alibaba"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
|
||||
import { addPlugin, it, model } from "./provider-helper"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: AlibabaPlugin.id, effect: AlibabaPlugin.effect(host) })
|
||||
})
|
||||
|
||||
describe("AlibabaPlugin", () => {
|
||||
it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AlibabaPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
|
||||
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/alibaba",
|
||||
options: { name: "alibaba" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -23,10 +41,17 @@ describe("AlibabaPlugin", () => {
|
||||
it.effect("ignores non-Alibaba SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AlibabaPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
|
||||
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "alibaba" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeUndefined()
|
||||
@@ -36,11 +61,14 @@ describe("AlibabaPlugin", () => {
|
||||
it.effect("matches the old bundled Alibaba SDK provider naming", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AlibabaPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-alibaba", "qwen"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")),
|
||||
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/alibaba",
|
||||
options: { name: "custom-alibaba", apiKey: "test" },
|
||||
},
|
||||
@@ -56,8 +84,11 @@ describe("AlibabaPlugin", () => {
|
||||
it.effect("uses the old default languageModel(api.id) behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AlibabaPlugin)
|
||||
const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } })
|
||||
yield* addPlugin()
|
||||
const item = new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" },
|
||||
})
|
||||
const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {})
|
||||
const language = result.sdk?.languageModel(item.api.id)
|
||||
expect(language?.modelId).toBe("qwen-plus")
|
||||
|
||||
@@ -1,10 +1,61 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: AmazonBedrockPlugin.id, effect: AmazonBedrockPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
fx,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
|
||||
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
|
||||
@@ -28,11 +79,10 @@ function openAIUrl(language: unknown, path: string, modelId: string) {
|
||||
describe("AmazonBedrockPlugin", () => {
|
||||
it.effect("moves endpoint option to api URL", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const bedrock = provider("amazon-bedrock", {
|
||||
const bedrock = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock),
|
||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
|
||||
request: {
|
||||
headers: {},
|
||||
@@ -44,6 +94,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
item.request = bedrock.request
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock))
|
||||
expect(result.api).toEqual({
|
||||
type: "aisdk",
|
||||
@@ -58,11 +109,14 @@ describe("AmazonBedrockPlugin", () => {
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
@@ -83,11 +137,14 @@ describe("AmazonBedrockPlugin", () => {
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
@@ -117,11 +174,18 @@ describe("AmazonBedrockPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
},
|
||||
@@ -137,11 +201,14 @@ describe("AmazonBedrockPlugin", () => {
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock", region: "eu-west-1" },
|
||||
},
|
||||
@@ -156,11 +223,14 @@ describe("AmazonBedrockPlugin", () => {
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
},
|
||||
@@ -175,11 +245,14 @@ describe("AmazonBedrockPlugin", () => {
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
},
|
||||
@@ -195,11 +268,14 @@ describe("AmazonBedrockPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const headers: Array<string | null> = []
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
@@ -224,11 +300,14 @@ describe("AmazonBedrockPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const headers: Array<string | null> = []
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
@@ -252,12 +331,17 @@ describe("AmazonBedrockPlugin", () => {
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "openai.gpt-5.5", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("openai.gpt-5.5"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||
options: {
|
||||
@@ -281,12 +365,17 @@ describe("AmazonBedrockPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("amazon-bedrock", "openai.gpt-5.5", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("openai.gpt-5.5"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||
},
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" },
|
||||
@@ -296,8 +385,13 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("amazon-bedrock", "openai.gpt-oss-safeguard-120b", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||
},
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { region: "us-east-1" },
|
||||
@@ -311,12 +405,17 @@ describe("AmazonBedrockPlugin", () => {
|
||||
it.effect("ignores other Bedrock provider subpaths", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/anthropic" },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/amazon-bedrock/anthropic",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock/anthropic",
|
||||
options: { name: "amazon-bedrock" },
|
||||
@@ -340,11 +439,18 @@ describe("AmazonBedrockPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const headers: Array<string | null> = []
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
@@ -371,11 +477,14 @@ describe("AmazonBedrockPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
},
|
||||
@@ -384,7 +493,10 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
},
|
||||
@@ -393,7 +505,14 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
},
|
||||
@@ -402,7 +521,10 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "ap-northeast-1" },
|
||||
},
|
||||
@@ -411,7 +533,10 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "ap-southeast-2" },
|
||||
},
|
||||
@@ -432,11 +557,14 @@ describe("AmazonBedrockPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
},
|
||||
@@ -517,12 +645,15 @@ describe("AmazonBedrockPlugin", () => {
|
||||
expected: "au.anthropic.claude-sonnet-4-5",
|
||||
},
|
||||
]
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
for (const item of cases) {
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("amazon-bedrock", item.modelID),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)),
|
||||
api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: item.region },
|
||||
},
|
||||
@@ -537,11 +668,14 @@ describe("AmazonBedrockPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("openai", "anthropic.claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
},
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, it, model, provider, required } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: AnthropicPlugin.id, effect: AnthropicPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
describe("AnthropicPlugin", () => {
|
||||
it.effect("applies legacy beta headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, AnthropicPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("anthropic", {
|
||||
const item = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.anthropic),
|
||||
api: { type: "aisdk", package: "@ai-sdk/anthropic" },
|
||||
request: { headers: { Existing: "1" }, body: {} },
|
||||
})
|
||||
@@ -22,6 +37,7 @@ describe("AnthropicPlugin", () => {
|
||||
draft.request = item.request
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe(
|
||||
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
||||
)
|
||||
@@ -31,10 +47,9 @@ describe("AnthropicPlugin", () => {
|
||||
|
||||
it.effect("ignores non-Anthropic providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, AnthropicPlugin)
|
||||
yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {}))
|
||||
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"],
|
||||
).toBeUndefined()
|
||||
@@ -44,54 +59,40 @@ describe("AnthropicPlugin", () => {
|
||||
it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providers: string[] = []
|
||||
yield* addPlugin(plugin, AnthropicPlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("anthropic-sdk-inspector"),
|
||||
effect: Effect.succeed({
|
||||
"aisdk.sdk": (evt) =>
|
||||
Effect.sync(() => {
|
||||
providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider)
|
||||
}),
|
||||
}),
|
||||
})
|
||||
yield* plugin.trigger(
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-anthropic", "claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" },
|
||||
}),
|
||||
package: "@ai-sdk/anthropic",
|
||||
options: { name: "custom-anthropic", apiKey: "test" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(providers).toEqual(["custom-anthropic"])
|
||||
expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("custom-anthropic")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providers: string[] = []
|
||||
yield* addPlugin(plugin, AnthropicPlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("anthropic-sdk-inspector"),
|
||||
effect: Effect.succeed({
|
||||
"aisdk.sdk": (evt) =>
|
||||
Effect.sync(() => {
|
||||
providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider)
|
||||
}),
|
||||
}),
|
||||
})
|
||||
yield* plugin.trigger(
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("anthropic", "claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" },
|
||||
}),
|
||||
package: "@ai-sdk/anthropic",
|
||||
options: { name: "anthropic", apiKey: "test" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(providers).toEqual(["anthropic"])
|
||||
expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,23 +1,73 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: AzureCognitiveServicesPlugin.id, effect: AzureCognitiveServicesPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
fx,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("AzureCognitiveServicesPlugin", () => {
|
||||
it.effect("maps the resource env var to the Azure SDK baseURL", () =>
|
||||
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
|
||||
item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
|
||||
expect(result.api).toEqual({
|
||||
type: "aisdk",
|
||||
@@ -33,14 +83,16 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
it.effect("leaves baseURL unset without resource env and ignores other providers", () =>
|
||||
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const azure = provider("azure-cognitive-services", {
|
||||
const azure = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")),
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
||||
})
|
||||
const openai = provider("openai")
|
||||
const openai = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
})
|
||||
@@ -48,6 +100,7 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
item.api = openai.api
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
|
||||
const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai))
|
||||
expect(azure.request.body.baseURL).toBeUndefined()
|
||||
@@ -62,11 +115,14 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("azure-cognitive-services", "deployment"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { useCompletionUrls: true },
|
||||
},
|
||||
@@ -80,15 +136,29 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
const ignored = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(calls).toEqual(["responses:deployment"])
|
||||
@@ -101,11 +171,17 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
const sdk = fakeSelectorSdk(calls)
|
||||
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("azure-cognitive-services", "messages-deployment"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(
|
||||
ProviderV2.ID.make("azure-cognitive-services"),
|
||||
ModelV2.ID.make("messages-deployment"),
|
||||
),
|
||||
api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel },
|
||||
options: {},
|
||||
},
|
||||
@@ -114,7 +190,10 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("azure-cognitive-services", "chat-deployment"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")),
|
||||
api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { chat: sdk.chat, languageModel: sdk.languageModel },
|
||||
options: {},
|
||||
},
|
||||
@@ -123,7 +202,13 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("azure-cognitive-services", "language-deployment"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(
|
||||
ProviderV2.ID.make("azure-cognitive-services"),
|
||||
ModelV2.ID.make("language-deployment"),
|
||||
),
|
||||
api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: sdk.languageModel },
|
||||
options: {},
|
||||
},
|
||||
|
||||
@@ -1,23 +1,73 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: AzurePlugin.id, effect: AzurePlugin.effect(host) })
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
fx,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("AzurePlugin", () => {
|
||||
it.effect("resolves resourceName from env", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.azure, (item) => {
|
||||
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin(plugin, AzurePlugin)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
||||
}),
|
||||
),
|
||||
@@ -26,10 +76,10 @@ describe("AzurePlugin", () => {
|
||||
it.effect("keeps explicit resourceName over env and ignores other providers", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
const azure = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.azure),
|
||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
request: { headers: {}, body: { resourceName: "from-config" } },
|
||||
})
|
||||
@@ -39,7 +89,7 @@ describe("AzurePlugin", () => {
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||
})
|
||||
yield* addPlugin(plugin, AzurePlugin)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config")
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined()
|
||||
}),
|
||||
@@ -49,10 +99,10 @@ describe("AzurePlugin", () => {
|
||||
it.effect("falls back to env when configured resourceName is blank", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
const azure = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.azure),
|
||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
request: { headers: {}, body: { resourceName: "" } },
|
||||
})
|
||||
@@ -61,7 +111,7 @@ describe("AzurePlugin", () => {
|
||||
item.request = azure.request
|
||||
})
|
||||
})
|
||||
yield* addPlugin(plugin, AzurePlugin)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
||||
}),
|
||||
),
|
||||
@@ -70,10 +120,10 @@ describe("AzurePlugin", () => {
|
||||
it.effect("falls back to env when configured resourceName is whitespace", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
const azure = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.azure),
|
||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
request: { headers: {}, body: { resourceName: " " } },
|
||||
})
|
||||
@@ -82,7 +132,7 @@ describe("AzurePlugin", () => {
|
||||
item.request = azure.request
|
||||
})
|
||||
})
|
||||
yield* addPlugin(plugin, AzurePlugin)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
||||
}),
|
||||
),
|
||||
@@ -92,11 +142,14 @@ describe("AzurePlugin", () => {
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AzurePlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("azure", "deployment"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/azure",
|
||||
options: { name: "azure", baseURL: "https://proxy.example.com/openai" },
|
||||
},
|
||||
@@ -111,11 +164,18 @@ describe("AzurePlugin", () => {
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, AzurePlugin)
|
||||
yield* addPlugin()
|
||||
const exit = yield* plugin
|
||||
.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/azure",
|
||||
options: { name: "azure" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
@@ -128,10 +188,17 @@ describe("AzurePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, AzurePlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { useCompletionUrls: true },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(calls).toEqual(["chat:deployment"])
|
||||
@@ -142,10 +209,17 @@ describe("AzurePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, AzurePlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { useCompletionUrls: true },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(calls).toEqual(["chat:deployment"])
|
||||
@@ -156,11 +230,13 @@ describe("AzurePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, AzurePlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("azure", "deployment", {
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
request: { headers: {}, body: { useCompletionUrls: true } },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
@@ -176,15 +252,29 @@ describe("AzurePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, AzurePlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
const ignored = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(calls).toEqual(["responses:deployment"])
|
||||
@@ -200,11 +290,14 @@ describe("AzurePlugin", () => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" }
|
||||
}
|
||||
yield* addPlugin(plugin, AzurePlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("azure", "messages-deployment"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")),
|
||||
api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") },
|
||||
options: {},
|
||||
},
|
||||
@@ -212,7 +305,14 @@ describe("AzurePlugin", () => {
|
||||
)
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")),
|
||||
api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: make("languageModel") },
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"])
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, it, model, required } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const cerebrasOptions: Record<string, unknown>[] = []
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: CerebrasPlugin.id, effect: CerebrasPlugin.effect(host) })
|
||||
})
|
||||
|
||||
void mock.module("@ai-sdk/cerebras", () => ({
|
||||
createCerebras: (options: Record<string, unknown>) => {
|
||||
@@ -21,16 +31,15 @@ void mock.module("@ai-sdk/cerebras", () => ({
|
||||
describe("CerebrasPlugin", () => {
|
||||
it.effect("applies the legacy integration header", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, CerebrasPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
|
||||
item.api = { type: "aisdk", package: "@ai-sdk/cerebras" }
|
||||
item.request.headers.Existing = "1"
|
||||
})
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.request.headers).toEqual({
|
||||
Existing: "1",
|
||||
"X-Cerebras-3rd-Party-Integration": "opencode",
|
||||
})
|
||||
@@ -39,11 +48,10 @@ describe("CerebrasPlugin", () => {
|
||||
|
||||
it.effect("ignores non-Cerebras providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, CerebrasPlugin)
|
||||
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -51,11 +59,21 @@ describe("CerebrasPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
cerebrasOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CerebrasPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(
|
||||
ProviderV2.ID.make("custom-cerebras"),
|
||||
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
),
|
||||
api: {
|
||||
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/cerebras",
|
||||
options: { name: "custom-cerebras", apiKey: "test" },
|
||||
},
|
||||
@@ -70,11 +88,21 @@ describe("CerebrasPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
cerebrasOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CerebrasPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(
|
||||
ProviderV2.ID.make("custom-cerebras"),
|
||||
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
),
|
||||
api: {
|
||||
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/cerebras",
|
||||
options: { name: "configured-cerebras", apiKey: "test" },
|
||||
},
|
||||
@@ -88,11 +116,21 @@ describe("CerebrasPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
cerebrasOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CerebrasPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(
|
||||
ProviderV2.ID.make("custom-cerebras"),
|
||||
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
),
|
||||
api: {
|
||||
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/groq",
|
||||
options: { name: "custom-cerebras", apiKey: "test" },
|
||||
},
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
|
||||
import { addPlugin, it, model, withEnv } from "./provider-helper"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: CloudflareAIGatewayPlugin.id, effect: CloudflareAIGatewayPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
fx,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const aiGatewayCalls: Record<string, unknown>[] = []
|
||||
const unifiedCalls: string[] = []
|
||||
@@ -78,11 +111,14 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
},
|
||||
@@ -98,12 +134,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: {
|
||||
name: "cloudflare-ai-gateway",
|
||||
@@ -142,12 +181,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: {
|
||||
name: "cloudflare-ai-gateway",
|
||||
@@ -171,12 +213,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: {
|
||||
name: "cloudflare-ai-gateway",
|
||||
@@ -208,12 +253,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: {
|
||||
name: "cloudflare-ai-gateway",
|
||||
@@ -239,12 +287,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
},
|
||||
@@ -261,12 +312,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
},
|
||||
@@ -284,12 +338,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
},
|
||||
@@ -313,12 +370,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" },
|
||||
},
|
||||
@@ -336,12 +396,22 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(
|
||||
ProviderV2.ID.make("cloudflare-ai-gateway"),
|
||||
ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
|
||||
),
|
||||
api: {
|
||||
id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
},
|
||||
@@ -364,12 +434,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
},
|
||||
|
||||
@@ -3,9 +3,59 @@ import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: CloudflareWorkersAIPlugin.id, effect: CloudflareWorkersAIPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
|
||||
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
|
||||
@@ -37,12 +87,15 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
provider.api = { type: "aisdk", package: "test-provider" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))
|
||||
const sdk = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "@cf/model", { api: provider.api }),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
api: { id: ModelV2.ID.make("@cf/model"), ...provider.api },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
|
||||
},
|
||||
@@ -61,14 +114,13 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
@@ -82,12 +134,18 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("@cf/model"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://proxy.example/v1",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
|
||||
@@ -102,7 +160,6 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
it.effect("uses env account ID over configured account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
@@ -110,7 +167,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
provider.request.body.accountId = "configured-acct"
|
||||
}),
|
||||
)
|
||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
@@ -124,12 +181,18 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("@cf/model"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://proxy.example/v1",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
@@ -153,12 +216,14 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("@cf/model"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
@@ -183,11 +248,14 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "alias", { api: { id: ModelV2.ID.make("@cf/api-model") } }),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
@@ -202,12 +270,18 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("@cf/model"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/anthropic",
|
||||
url: "https://proxy.example/v1",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/anthropic",
|
||||
options: { name: "cloudflare-workers-ai" },
|
||||
|
||||
@@ -2,10 +2,34 @@ import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
|
||||
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const cohereOptions: Record<string, any>[] = []
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: CoherePlugin.id, effect: CoherePlugin.effect(host) })
|
||||
})
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
void mock.module("@ai-sdk/cohere", () => ({
|
||||
createCohere: (options: Record<string, any>) => {
|
||||
@@ -24,18 +48,32 @@ describe("CoherePlugin", () => {
|
||||
it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CoherePlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
const ignored = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("cohere", "command"), package: "@ai-sdk/openai-compatible", options: { name: "cohere" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
|
||||
api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cohere" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(ignored.sdk).toBeUndefined()
|
||||
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("cohere", "command"), package: "@ai-sdk/cohere", options: { name: "cohere" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
|
||||
api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/cohere",
|
||||
options: { name: "cohere" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -45,11 +83,14 @@ describe("CoherePlugin", () => {
|
||||
it.effect("uses the model provider ID as the bundled SDK name", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, CoherePlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-cohere", "command-r-plus"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")),
|
||||
api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/cohere",
|
||||
options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" },
|
||||
},
|
||||
@@ -70,10 +111,17 @@ describe("CoherePlugin", () => {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
const sdk = fakeSelectorSdk(calls)
|
||||
yield* addPlugin(plugin, CoherePlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk,
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { addPlugin, it, model } from "./provider-helper"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const itAISDK = testEffect(
|
||||
Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))),
|
||||
)
|
||||
const deepinfraOptions: Record<string, any>[] = []
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const deepinfraOptions: Record<string, unknown>[] = []
|
||||
const deepinfraLanguageModels: string[] = []
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: DeepInfraPlugin.id, effect: DeepInfraPlugin.effect(host) })
|
||||
})
|
||||
|
||||
void mock.module("@ai-sdk/deepinfra", () => ({
|
||||
createDeepInfra: (options: Record<string, any>) => {
|
||||
createDeepInfra: (options: Record<string, unknown>) => {
|
||||
const captured = { ...options }
|
||||
deepinfraOptions.push(captured)
|
||||
return {
|
||||
@@ -36,10 +41,17 @@ describe("DeepInfraPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetDeepInfraMock()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, DeepInfraPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
}),
|
||||
package: "@ai-sdk/deepinfra",
|
||||
options: { name: "deepinfra" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -50,11 +62,14 @@ describe("DeepInfraPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetDeepInfraMock()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, DeepInfraPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-deepinfra", "model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
}),
|
||||
package: "@ai-sdk/deepinfra",
|
||||
options: { name: "custom-deepinfra", apiKey: "test" },
|
||||
},
|
||||
@@ -69,11 +84,14 @@ describe("DeepInfraPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetDeepInfraMock()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, DeepInfraPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("deepinfra", "model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
}),
|
||||
package: "@ai-sdk/deepinfra",
|
||||
options: { name: "deepinfra", apiKey: "test" },
|
||||
},
|
||||
@@ -88,7 +106,7 @@ describe("DeepInfraPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
resetDeepInfraMock()
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, DeepInfraPlugin)
|
||||
yield* addPlugin()
|
||||
const packages = [
|
||||
"unmatched-package",
|
||||
"@ai-sdk/deepinfra-compatible",
|
||||
@@ -98,7 +116,14 @@ describe("DeepInfraPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const ignored = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
}),
|
||||
package: item,
|
||||
options: { name: "deepinfra" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(ignored.sdk).toBeUndefined()
|
||||
@@ -106,7 +131,14 @@ describe("DeepInfraPlugin", () => {
|
||||
)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
}),
|
||||
package: "@ai-sdk/deepinfra",
|
||||
options: { name: "deepinfra" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -114,17 +146,36 @@ describe("DeepInfraPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
itAISDK.effect("uses the default languageModel selection for DeepInfra models", () =>
|
||||
it.effect("uses the default languageModel selection for DeepInfra models", () =>
|
||||
Effect.gen(function* () {
|
||||
resetDeepInfraMock()
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(plugin, DeepInfraPlugin)
|
||||
const language = yield* aisdk.language(
|
||||
model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
}),
|
||||
yield* addPlugin()
|
||||
const sdkEvent = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(
|
||||
ProviderV2.ID.make("deepinfra"),
|
||||
ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"),
|
||||
),
|
||||
api: {
|
||||
id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/deepinfra",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/deepinfra",
|
||||
options: { name: "deepinfra" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options },
|
||||
{},
|
||||
)
|
||||
const language = result.language ?? result.sdk.languageModel(result.model.api.id)
|
||||
expect(language.provider).toBe("deepinfra.chat")
|
||||
expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"])
|
||||
}),
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Layer, Option } from "effect"
|
||||
import { Cause, Effect, Layer } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
import { fixtureProvider, it, model, npmLayer } from "./provider-helper"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
const fixtureProviderPath = fileURLToPath(fixtureProvider)
|
||||
const itWithAISDK = testEffect(
|
||||
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
|
||||
)
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginTestLayer)))
|
||||
|
||||
function npmEntrypointLayer(entrypoint?: string) {
|
||||
return Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
function npmEntrypoint(entrypoint?: string) {
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
}
|
||||
|
||||
function dynamicPlugin(layer = npmLayer) {
|
||||
return {
|
||||
const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({
|
||||
id: DynamicProviderPlugin.id,
|
||||
effect: Effect.gen(function* () {
|
||||
yield* DynamicProviderPlugin.effect(host({ npm: yield* Npm.Service }))
|
||||
}).pipe(Effect.provide(layer)),
|
||||
}
|
||||
}
|
||||
effect: DynamicProviderPlugin.effect(npm ? { ...host, npm } : host),
|
||||
})
|
||||
})
|
||||
|
||||
function tempEntrypoint(source: string) {
|
||||
return Effect.acquireRelease(
|
||||
@@ -55,11 +52,14 @@ describe("DynamicProviderPlugin", () => {
|
||||
it.effect("creates an SDK from a provider factory export", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(dynamicPlugin())
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom", "test-model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
|
||||
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
|
||||
}),
|
||||
package: fixtureProvider,
|
||||
options: { name: "custom", marker: "dynamic" },
|
||||
},
|
||||
@@ -74,11 +74,14 @@ describe("DynamicProviderPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const sdk = { marker: "existing" }
|
||||
yield* plugin.add(dynamicPlugin())
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom", "test-model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
|
||||
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
|
||||
}),
|
||||
package: fixtureProvider,
|
||||
options: { name: "custom", marker: "dynamic" },
|
||||
},
|
||||
@@ -91,11 +94,14 @@ describe("DynamicProviderPlugin", () => {
|
||||
it.effect("injects the provider ID as the SDK factory name", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(dynamicPlugin())
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-provider", "test-model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
|
||||
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
|
||||
}),
|
||||
package: fixtureProvider,
|
||||
options: { name: "custom-provider", marker: "dynamic" },
|
||||
},
|
||||
@@ -108,11 +114,14 @@ describe("DynamicProviderPlugin", () => {
|
||||
it.effect("loads npm packages through their resolved import entrypoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(fixtureProviderPath)))
|
||||
yield* addPlugin(npmEntrypoint(fixtureProviderPath))
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("npm-provider", "test-model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
|
||||
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" },
|
||||
}),
|
||||
package: "fixture-provider",
|
||||
options: { name: "npm-provider", marker: "npm" },
|
||||
},
|
||||
@@ -126,9 +135,14 @@ describe("DynamicProviderPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* plugin.add(dynamicPlugin(npmEntrypointLayer()))
|
||||
yield* addPlugin(npmEntrypoint())
|
||||
const exit = yield* aisdk
|
||||
.language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } }))
|
||||
.language(
|
||||
new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
|
||||
@@ -139,10 +153,13 @@ describe("DynamicProviderPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* plugin.add(dynamicPlugin())
|
||||
yield* addPlugin()
|
||||
const exit = yield* aisdk
|
||||
.language(
|
||||
model("bad-import", "alias", { api: { type: "aisdk", package: "file:///missing/provider-factory.js" } }),
|
||||
new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" },
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
@@ -155,9 +172,14 @@ describe("DynamicProviderPlugin", () => {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
|
||||
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(tmp.entrypoint)))
|
||||
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
|
||||
const exit = yield* aisdk
|
||||
.language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } }))
|
||||
.language(
|
||||
new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
|
||||
@@ -168,9 +190,10 @@ describe("DynamicProviderPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* plugin.add(dynamicPlugin())
|
||||
yield* addPlugin()
|
||||
const language = yield* aisdk.language(
|
||||
model("custom", "alias", {
|
||||
new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
|
||||
import { addPlugin, it, model } from "./provider-helper"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const gatewayCalls: Record<string, unknown>[] = []
|
||||
const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"]
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: GatewayPlugin.id, effect: GatewayPlugin.effect(host) })
|
||||
})
|
||||
|
||||
mock.module("@ai-sdk/gateway", () => ({
|
||||
createGateway(options: Record<string, unknown>) {
|
||||
@@ -27,10 +38,17 @@ describe("GatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
gatewayCalls.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GatewayPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/gateway",
|
||||
options: { name: "gateway" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -42,12 +60,19 @@ describe("GatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
gatewayCalls.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("vercel", "anthropic/claude-sonnet-4"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("anthropic/claude-sonnet-4"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/gateway",
|
||||
options: { name: "vercel", apiKey: "test-key" },
|
||||
},
|
||||
@@ -63,19 +88,33 @@ describe("GatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
gatewayCalls.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GatewayPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
for (const modelID of vercelGatewayModels) {
|
||||
const ignored = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("vercel", modelID), package: "@ai-sdk/vercel", options: { name: "vercel" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
|
||||
api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/vercel",
|
||||
options: { name: "vercel" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(ignored.sdk).toBeUndefined()
|
||||
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("vercel", modelID), package: "@ai-sdk/gateway", options: { name: "vercel" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
|
||||
api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/gateway",
|
||||
options: { name: "vercel" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
|
||||
@@ -3,19 +3,51 @@ import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, fakeSelectorSdk, it, model, required } from "./provider-helper"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: GithubCopilotPlugin.id, effect: GithubCopilotPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("GithubCopilotPlugin", () => {
|
||||
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
||||
yield* addPlugin()
|
||||
const ignored = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("github-copilot", "gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "github-copilot" },
|
||||
},
|
||||
@@ -24,7 +56,10 @@ describe("GithubCopilotPlugin", () => {
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("github-copilot", "gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/github-copilot",
|
||||
options: { name: "github-copilot" },
|
||||
},
|
||||
@@ -39,11 +74,14 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("github-copilot", "claude-sonnet-4"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
},
|
||||
@@ -57,11 +95,14 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("github-copilot", "alias", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
},
|
||||
@@ -75,30 +116,65 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")),
|
||||
api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")),
|
||||
api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")),
|
||||
api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")),
|
||||
api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(calls).toEqual([
|
||||
@@ -115,11 +191,14 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("github-copilot", "default", { api: { id: ModelV2.ID.make("gpt-5") } }),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
@@ -128,7 +207,10 @@ describe("GithubCopilotPlugin", () => {
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("github-copilot", "small", { api: { id: ModelV2.ID.make("gpt-5-mini") } }),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")),
|
||||
api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
@@ -137,7 +219,10 @@ describe("GithubCopilotPlugin", () => {
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("github-copilot", "sonnet", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
@@ -149,13 +234,12 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
|
||||
.enabled,
|
||||
@@ -165,13 +249,12 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
|
||||
.enabled,
|
||||
@@ -183,10 +266,17 @@ describe("GithubCopilotPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, it, model, required, withEnv } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const gitlabSDKOptions: Record<string, unknown>[] = []
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: GitLabPlugin.id, effect: GitLabPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
void mock.module("gitlab-ai-provider", () => ({
|
||||
VERSION: "test-version",
|
||||
@@ -32,10 +63,17 @@ describe("GitLabPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GitLabPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "gitlab-ai-provider",
|
||||
options: { name: "gitlab" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(gitlabSDKOptions).toHaveLength(1)
|
||||
@@ -65,10 +103,17 @@ describe("GitLabPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GitLabPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "gitlab-ai-provider",
|
||||
options: { name: "gitlab" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example")
|
||||
@@ -86,11 +131,14 @@ describe("GitLabPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GitLabPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("gitlab", "claude"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "gitlab-ai-provider",
|
||||
options: {
|
||||
name: "gitlab",
|
||||
@@ -127,10 +175,17 @@ describe("GitLabPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GitLabPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai",
|
||||
options: { name: "gitlab" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeUndefined()
|
||||
@@ -142,11 +197,13 @@ describe("GitLabPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin(plugin, GitLabPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("gitlab", "duo-workflow-custom", {
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
|
||||
api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" },
|
||||
request: {
|
||||
headers: {},
|
||||
body: { workflowRef: "ref", workflowDefinition: "definition" },
|
||||
@@ -178,11 +235,14 @@ describe("GitLabPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin(plugin, GitLabPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("gitlab", "duo-workflow-exact"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")),
|
||||
api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: {
|
||||
workflowChat: (id: string, options: unknown) => {
|
||||
calls.push([id, options])
|
||||
@@ -205,11 +265,13 @@ describe("GitLabPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin(plugin, GitLabPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("gitlab", "duo-workflow-custom", {
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
|
||||
api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" },
|
||||
request: {
|
||||
headers: {},
|
||||
body: { featureFlags: { request_flag: true } },
|
||||
@@ -234,11 +296,13 @@ describe("GitLabPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin(plugin, GitLabPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("gitlab", "claude", {
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||
request: { headers: { h: "v" }, body: {} },
|
||||
}),
|
||||
sdk: {
|
||||
|
||||
@@ -1,10 +1,50 @@
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: definition.id, effect: definition.effect(host) })
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function selector(calls: string[]) {
|
||||
return (id: string) => {
|
||||
calls.push(`languageModel:${id}`)
|
||||
return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
}
|
||||
|
||||
describe("GoogleVertexAnthropicPlugin", () => {
|
||||
it.effect("resolves legacy project and location env on provider update", () =>
|
||||
@@ -19,17 +59,19 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
}),
|
||||
)
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))
|
||||
expect(provider.request.body.project).toBe("cloud-project")
|
||||
expect(provider.request.body.location).toBe("cloud-location")
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
expect(
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project,
|
||||
).toBe("cloud-project")
|
||||
expect(
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location,
|
||||
).toBe("cloud-location")
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -37,9 +79,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
it.effect("keeps configured project and location over env fallback", () =>
|
||||
withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
@@ -47,9 +87,13 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
provider.request.body.location = "configured-location"
|
||||
}),
|
||||
)
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))
|
||||
expect(provider.request.body.project).toBe("configured-project")
|
||||
expect(provider.request.body.location).toBe("configured-location")
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe(
|
||||
"configured-project",
|
||||
)
|
||||
expect(
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location,
|
||||
).toBe("configured-location")
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -67,11 +111,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex-anthropic", "claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(
|
||||
ProviderV2.ID.make("google-vertex-anthropic"),
|
||||
ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex-anthropic" },
|
||||
},
|
||||
@@ -90,11 +140,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex-anthropic", "claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(
|
||||
ProviderV2.ID.make("google-vertex-anthropic"),
|
||||
ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex-anthropic" },
|
||||
},
|
||||
@@ -110,11 +166,14 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", "claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "eu" },
|
||||
},
|
||||
@@ -129,11 +188,14 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
it.effect("keeps configured baseURL for google-vertex Anthropic models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", "claude-sonnet-4-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" },
|
||||
},
|
||||
@@ -146,12 +208,15 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
it.effect("selects google-vertex Anthropic language models through V2 plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
||||
yield* addPlugin(GoogleVertexPlugin)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const sdkResult = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", " claude-sonnet-4-5 "),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
|
||||
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "us" },
|
||||
},
|
||||
@@ -160,7 +225,10 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
const languageResult = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("google-vertex", " claude-sonnet-4-5 "),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
|
||||
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: sdkResult.sdk,
|
||||
options: {},
|
||||
},
|
||||
@@ -178,12 +246,18 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("google-vertex-anthropic", " claude-sonnet-4-5 "),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(
|
||||
ProviderV2.ID.make("google-vertex-anthropic"),
|
||||
ModelV2.ID.make(" claude-sonnet-4-5 "),
|
||||
),
|
||||
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: selector(calls) },
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
@@ -196,12 +270,15 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("google-vertex", "claude-sonnet-4-5"),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: selector(calls) },
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
|
||||
@@ -1,13 +1,63 @@
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const vertexOptions: Record<string, any>[] = []
|
||||
const googleAuthOptions: Record<string, any>[] = []
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: GoogleVertexPlugin.id, effect: GoogleVertexPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
void mock.module("@ai-sdk/google-vertex", () => ({
|
||||
createVertex: (options: Record<string, any>) => {
|
||||
@@ -37,9 +87,7 @@ void mock.module("google-auth-library", () => ({
|
||||
describe("GoogleVertexPlugin", () => {
|
||||
it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
|
||||
provider.api = {
|
||||
@@ -49,6 +97,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode))
|
||||
expect(provider.request.body).toEqual({})
|
||||
@@ -67,9 +116,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.api = {
|
||||
@@ -79,6 +126,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||
expect(provider.request.body.project).toBe("google-cloud-project")
|
||||
expect(provider.request.body.location).toBe("google-vertex-location")
|
||||
@@ -107,7 +155,6 @@ describe("GoogleVertexPlugin", () => {
|
||||
vertexOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.api = {
|
||||
@@ -117,12 +164,18 @@ describe("GoogleVertexPlugin", () => {
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", "gemini", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("gemini"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/google-vertex",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: { name: "google-vertex" },
|
||||
@@ -154,9 +207,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.api = {
|
||||
@@ -168,6 +219,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
provider.request.body.location = "global"
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||
expect(provider.request.body.project).toBe("config-project")
|
||||
expect(provider.request.body.location).toBe("global")
|
||||
@@ -182,9 +234,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
|
||||
it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.api = {
|
||||
@@ -196,6 +246,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
provider.request.body.location = "eu"
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||
expect(provider.api).toEqual({
|
||||
type: "aisdk",
|
||||
@@ -217,15 +268,14 @@ describe("GoogleVertexPlugin", () => {
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" }
|
||||
provider.request.body.project = "config-project"
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||
expect(provider.request.body.project).toBe("config-project")
|
||||
expect(provider.request.body.location).toBe("us-central1")
|
||||
@@ -243,12 +293,17 @@ describe("GoogleVertexPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
vertexOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", "gemini", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("gemini"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/google-vertex",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: { name: "google-vertex" },
|
||||
@@ -268,21 +323,17 @@ describe("GoogleVertexPlugin", () => {
|
||||
googleAuthOptions.length = 0
|
||||
const fetchCalls: { input: Parameters<typeof fetch>[0]; init?: RequestInit }[] = []
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("capture-openai-compatible"),
|
||||
effect: Effect.succeed({
|
||||
"aisdk.sdk": (evt) =>
|
||||
Effect.promise(async () => {
|
||||
if (evt.model.providerID !== "google-vertex") return
|
||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
expect(typeof evt.options.fetch).toBe("function")
|
||||
await evt.options.fetch("https://vertex.example", {
|
||||
headers: { "x-test": "1" },
|
||||
})
|
||||
}),
|
||||
yield* addPlugin()
|
||||
yield* plugin.hook("aisdk.sdk", (evt) =>
|
||||
Effect.promise(async () => {
|
||||
if (evt.model.providerID !== "google-vertex") return
|
||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
expect(typeof evt.options.fetch).toBe("function")
|
||||
await evt.options.fetch("https://vertex.example", {
|
||||
headers: { "x-test": "1" },
|
||||
})
|
||||
}),
|
||||
})
|
||||
)
|
||||
const originalFetch = fetch
|
||||
;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async (
|
||||
input: Parameters<typeof fetch>[0],
|
||||
@@ -297,8 +348,13 @@ describe("GoogleVertexPlugin", () => {
|
||||
plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", "gemini", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("gemini"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "google-vertex" },
|
||||
@@ -322,11 +378,14 @@ describe("GoogleVertexPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
||||
yield* addPlugin()
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("google-vertex", " gemini-2.5-pro "),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")),
|
||||
api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
},
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { addPlugin, it, model } from "./provider-helper"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const itWithAISDK = testEffect(
|
||||
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
|
||||
)
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: GooglePlugin.id, effect: GooglePlugin.effect(host) })
|
||||
})
|
||||
|
||||
describe("GooglePlugin", () => {
|
||||
it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GooglePlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-google", "gemini"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")),
|
||||
api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" },
|
||||
}),
|
||||
package: "@ai-sdk/google",
|
||||
options: { name: "custom-google", apiKey: "test" },
|
||||
},
|
||||
@@ -34,34 +41,49 @@ describe("GooglePlugin", () => {
|
||||
it.effect("ignores non-Google SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GooglePlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")),
|
||||
api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" },
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: { name: "google" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
itWithAISDK.effect("uses default languageModel loading with provider ID parity", () =>
|
||||
it.effect("uses default languageModel loading with provider ID parity", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(plugin, GooglePlugin)
|
||||
const language = yield* aisdk.language(
|
||||
model("custom-google", "alias", {
|
||||
api: {
|
||||
id: ModelV2.ID.make("gemini-api"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/google",
|
||||
},
|
||||
request: {
|
||||
headers: {},
|
||||
body: { apiKey: "test" },
|
||||
},
|
||||
}),
|
||||
yield* addPlugin()
|
||||
const sdkEvent = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" },
|
||||
}),
|
||||
package: "@ai-sdk/google",
|
||||
options: { name: "custom-google", apiKey: "test" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: sdkEvent.model,
|
||||
sdk: sdkEvent.sdk,
|
||||
options: sdkEvent.options,
|
||||
},
|
||||
{},
|
||||
)
|
||||
const language = result.language ?? result.sdk.languageModel(result.model.api.id)
|
||||
expect(language.modelId).toBe("gemini-api")
|
||||
expect(language.provider).toBe("custom-google")
|
||||
}),
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { createGroq } from "@ai-sdk/groq"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
|
||||
import { addPlugin, it, model } from "./provider-helper"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const aisdkIt = testEffect(
|
||||
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
|
||||
)
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: GroqPlugin.id, effect: GroqPlugin.effect(host) })
|
||||
})
|
||||
|
||||
describe("GroqPlugin", () => {
|
||||
it.effect("creates a Groq SDK for @ai-sdk/groq", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GroqPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
|
||||
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||
}),
|
||||
package: "@ai-sdk/groq",
|
||||
options: { name: "groq" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -30,10 +41,17 @@ describe("GroqPlugin", () => {
|
||||
it.effect("ignores non-Groq SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GroqPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
|
||||
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "groq" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeUndefined()
|
||||
@@ -43,10 +61,17 @@ describe("GroqPlugin", () => {
|
||||
it.effect("only matches the bundled @ai-sdk/groq package exactly", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GroqPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
|
||||
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||
}),
|
||||
package: "@ai-sdk/groq/compat",
|
||||
options: { name: "groq" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeUndefined()
|
||||
@@ -56,11 +81,14 @@ describe("GroqPlugin", () => {
|
||||
it.effect("matches the old bundled Groq SDK provider naming", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, GroqPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-groq", "llama"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")),
|
||||
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||
}),
|
||||
package: "@ai-sdk/groq",
|
||||
options: { name: "custom-groq", apiKey: "test" },
|
||||
},
|
||||
@@ -75,26 +103,32 @@ describe("GroqPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
aisdkIt.effect("uses the default languageModel(api.id) behavior", () =>
|
||||
it.effect("uses the default languageModel(api.id) behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(plugin, GroqPlugin)
|
||||
const result = yield* aisdk.language(
|
||||
model("groq", "alias", {
|
||||
api: {
|
||||
id: ModelV2.ID.make("llama-api"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/groq",
|
||||
},
|
||||
request: {
|
||||
headers: {},
|
||||
body: { apiKey: "test" },
|
||||
},
|
||||
}),
|
||||
yield* addPlugin()
|
||||
const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters<typeof createGroq>[0] & {
|
||||
name: string
|
||||
})
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("llama-api"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/groq",
|
||||
},
|
||||
}),
|
||||
sdk,
|
||||
options: { name: "groq", apiKey: "test" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.modelId).toBe("llama-api")
|
||||
expect(result.provider).toBe("groq.chat")
|
||||
const language = result.language ?? sdk.languageModel(result.model.api.id)
|
||||
expect(language.modelId).toBe("llama-api")
|
||||
expect(language.provider).toBe("groq.chat")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { aisdkHost, catalogHost, host, integrationHost } from "./host"
|
||||
|
||||
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
|
||||
export function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
|
||||
export const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
export const catalogLayer = Layer.succeed(
|
||||
Catalog.Service,
|
||||
Catalog.Service.of({
|
||||
transform: (_transform) => Effect.die("unexpected catalog.transform"),
|
||||
rebuild: () => Effect.die("unexpected catalog.rebuild"),
|
||||
provider: {
|
||||
get: () => Effect.die("unexpected provider.get"),
|
||||
all: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed([]),
|
||||
},
|
||||
model: {
|
||||
get: () => Effect.die("unexpected model.get"),
|
||||
all: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed([]),
|
||||
default: () => Effect.succeed(undefined),
|
||||
small: () => Effect.succeed(undefined),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const integrations = Integration.locationLayer.pipe(
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mock(Credential.Service)({
|
||||
create: () => Effect.die("unexpected credential creation"),
|
||||
all: () => Effect.succeed([]),
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const it = testEffect(
|
||||
Catalog.locationLayer.pipe(
|
||||
Layer.provideMerge(integrations),
|
||||
Layer.provideMerge(
|
||||
Layer.mock(Credential.Service)({
|
||||
all: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
Layer.provideMerge(npmLayer),
|
||||
Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))),
|
||||
),
|
||||
)
|
||||
|
||||
export function addPlugin(plugin: PluginV2.Interface, definition: Plugin<any>) {
|
||||
return Effect.gen(function* () {
|
||||
const catalog = yield* Effect.serviceOption(Catalog.Service)
|
||||
const integration = yield* Effect.serviceOption(Integration.Service)
|
||||
const npm = yield* Effect.serviceOption(Npm.Service)
|
||||
const effect =
|
||||
typeof definition.effect === "function"
|
||||
? definition.effect(
|
||||
host({
|
||||
aisdk: aisdkHost(plugin),
|
||||
...(Option.isSome(catalog) ? { catalog: catalogHost(catalog.value) } : {}),
|
||||
...(Option.isSome(integration) ? { integration: integrationHost(integration.value) } : {}),
|
||||
...(Option.isSome(npm) ? { npm: npm.value } : {}),
|
||||
}),
|
||||
)
|
||||
: definition.effect
|
||||
yield* plugin.add({ id: definition.id, effect })
|
||||
})
|
||||
}
|
||||
|
||||
type ProviderInput = Partial<Omit<ProviderV2.Info, "api" | "request">> & {
|
||||
api?: ProviderV2.Api
|
||||
request?: ProviderV2.Request
|
||||
}
|
||||
|
||||
type ModelInput = Partial<Omit<ModelV2.Info, "api" | "request">> & {
|
||||
api?: (ProviderV2.Api & { id?: ModelV2.ID }) | { id: ModelV2.ID }
|
||||
request?: ModelV2.Info["request"]
|
||||
}
|
||||
|
||||
export function provider(providerID: string, options?: ProviderInput) {
|
||||
return new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.make(providerID)),
|
||||
api: options?.api ?? {
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
...options,
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
...options?.request,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function model(providerID: string, modelID: string, options?: ModelInput) {
|
||||
return new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
...options,
|
||||
api:
|
||||
options?.api && "type" in options.api
|
||||
? { id: ModelV2.ID.make(modelID), ...options.api }
|
||||
: {
|
||||
id: ModelV2.ID.make(modelID),
|
||||
...options?.api,
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
...options?.request,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
return previous
|
||||
}),
|
||||
() => fx(),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
export function expectPluginRegistered(ids: string[], id: string) {
|
||||
expect(ids).toContain(PluginV2.ID.make(id))
|
||||
}
|
||||
@@ -2,96 +2,98 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: KiloPlugin.id, effect: KiloPlugin.effect(host) })
|
||||
})
|
||||
|
||||
describe("KiloPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() =>
|
||||
expectPluginRegistered(
|
||||
ProviderPlugins.map((item) => item.id),
|
||||
"kilo",
|
||||
),
|
||||
),
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to kilo", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, KiloPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const kilo = provider("kilo", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.kilo.ai/api/gateway",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(kilo.id, (draft) => {
|
||||
draft.api = kilo.api
|
||||
draft.request = kilo.request
|
||||
})
|
||||
catalog.provider.update(provider("openrouter").id, () => {})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the exact legacy Kilo header casing and set", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, KiloPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("kilo", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.kilo.ai/api/gateway",
|
||||
}
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("kilo")))
|
||||
expect(result.request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect(result.request.headers).not.toHaveProperty("http-referer")
|
||||
expect(result.request.headers).not.toHaveProperty("x-title")
|
||||
expect(result.request.headers).not.toHaveProperty("X-Source")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty(
|
||||
"http-referer",
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("x-title")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("X-Source")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, KiloPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const kilo = provider("kilo", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.kilo.ai/api/gateway",
|
||||
}
|
||||
})
|
||||
catalog.provider.update(kilo.id, (draft) => {
|
||||
draft.api = kilo.api
|
||||
})
|
||||
const custom = provider("custom-kilo", {
|
||||
api: { type: "aisdk", package: "kilo" },
|
||||
})
|
||||
catalog.provider.update(custom.id, (draft) => {
|
||||
draft.api = custom.api
|
||||
catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "kilo" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,36 +3,28 @@ import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { expectPluginRegistered, it, provider, required } from "./provider-helper"
|
||||
import { catalogHost, host, integrationHost } from "./host"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: LLMGatewayPlugin.id, effect: LLMGatewayPlugin.effect(host) })
|
||||
})
|
||||
|
||||
describe("LLMGatewayPlugin", () => {
|
||||
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
|
||||
const integrations = yield* Integration.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add({
|
||||
...LLMGatewayPlugin,
|
||||
effect: LLMGatewayPlugin.effect(
|
||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() =>
|
||||
expectPluginRegistered(
|
||||
ProviderPlugins.map((item) => item.id),
|
||||
"llmgateway",
|
||||
),
|
||||
),
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.transform((editor) => {
|
||||
@@ -40,43 +32,48 @@ describe("LLMGatewayPlugin", () => {
|
||||
editor.update(Integration.ID.make("openrouter"), () => {})
|
||||
})
|
||||
yield* catalog.transform((catalog) => {
|
||||
const llmgateway = provider("llmgateway", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(llmgateway.id, (draft) => {
|
||||
draft.api = llmgateway.api
|
||||
draft.request = llmgateway.request
|
||||
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.llmgateway.io/v1",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
yield* add(plugin)
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-Source": "opencode",
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not apply legacy headers to a disabled llmgateway provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* add(plugin)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.transform((editor) => {
|
||||
editor.update(Integration.ID.make("llmgateway"), () => {})
|
||||
})
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("llmgateway", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
|
||||
provider.disabled = true
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.llmgateway.io/v1",
|
||||
}
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
|
||||
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: MistralPlugin.id, effect: MistralPlugin.effect(host) })
|
||||
})
|
||||
|
||||
describe("MistralPlugin", () => {
|
||||
it.effect("creates a Mistral SDK for @ai-sdk/mistral", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, MistralPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
|
||||
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/mistral",
|
||||
options: { name: "mistral" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -22,11 +41,14 @@ describe("MistralPlugin", () => {
|
||||
it.effect("ignores non-Mistral SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, MistralPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("mistral", "mistral-large"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
|
||||
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "mistral" },
|
||||
},
|
||||
@@ -40,19 +62,22 @@ describe("MistralPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providers: string[] = []
|
||||
yield* addPlugin(plugin, MistralPlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("mistral-sdk-inspector"),
|
||||
effect: Effect.succeed({
|
||||
"aisdk.sdk": (evt) =>
|
||||
Effect.sync(() => {
|
||||
providers.push(evt.sdk.languageModel("mistral-large").provider)
|
||||
}),
|
||||
yield* addPlugin()
|
||||
yield* plugin.hook("aisdk.sdk", (event) =>
|
||||
Effect.sync(() => {
|
||||
providers.push(event.sdk.languageModel("mistral-large").provider)
|
||||
}),
|
||||
})
|
||||
)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
|
||||
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/mistral",
|
||||
options: { name: "mistral" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -64,20 +89,19 @@ describe("MistralPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providers: string[] = []
|
||||
yield* addPlugin(plugin, MistralPlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("mistral-sdk-inspector"),
|
||||
effect: Effect.succeed({
|
||||
"aisdk.sdk": (evt) =>
|
||||
Effect.sync(() => {
|
||||
providers.push(evt.sdk.languageModel("mistral-large").provider)
|
||||
}),
|
||||
yield* addPlugin()
|
||||
yield* plugin.hook("aisdk.sdk", (event) =>
|
||||
Effect.sync(() => {
|
||||
providers.push(event.sdk.languageModel("mistral-large").provider)
|
||||
}),
|
||||
})
|
||||
)
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-mistral", "mistral-large"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")),
|
||||
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/mistral",
|
||||
options: { name: "custom-mistral" },
|
||||
},
|
||||
@@ -91,11 +115,23 @@ describe("MistralPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
const sdk = fakeSelectorSdk(calls)
|
||||
yield* addPlugin(plugin, MistralPlugin)
|
||||
const sdk = {
|
||||
languageModel: (id: string) => {
|
||||
calls.push(`languageModel:${id}`)
|
||||
return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
},
|
||||
}
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk,
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
const language = result.language ?? sdk.languageModel(result.model.api.id)
|
||||
|
||||
@@ -2,64 +2,66 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: NvidiaPlugin.id, effect: NvidiaPlugin.effect(host) })
|
||||
})
|
||||
|
||||
describe("NvidiaPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() =>
|
||||
expectPluginRegistered(
|
||||
ProviderPlugins.map((item) => item.id),
|
||||
"nvidia",
|
||||
),
|
||||
),
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))),
|
||||
)
|
||||
|
||||
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, NvidiaPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const nvidia = provider("nvidia", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(nvidia.id, (draft) => {
|
||||
draft.api = nvidia.api
|
||||
draft.request = nvidia.request
|
||||
})
|
||||
catalog.provider.update(provider("openrouter").id, () => {})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds billing origin for custom NVIDIA endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, NvidiaPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("nvidia", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
request: { headers: {}, body: {} },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
@@ -69,24 +71,23 @@ describe("NvidiaPlugin", () => {
|
||||
|
||||
it.effect("preserves an explicit NVIDIA billing origin header", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, NvidiaPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("nvidia", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
request: {
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
provider.request = {
|
||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||
body: { baseURL: "https://integrate.api.nvidia.com/v1" },
|
||||
},
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
}
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible"
|
||||
import { addPlugin, it, model } from "./provider-helper"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: OpenAICompatiblePlugin.id, effect: OpenAICompatiblePlugin.effect(host) })
|
||||
})
|
||||
|
||||
describe("OpenAICompatiblePlugin", () => {
|
||||
it.effect("preserves explicit includeUsage false and defaults it to true", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, OpenAICompatiblePlugin)
|
||||
yield* addPlugin()
|
||||
const defaulted = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "custom" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
const disabled = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom", "model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "custom", includeUsage: false },
|
||||
},
|
||||
@@ -31,11 +53,14 @@ describe("OpenAICompatiblePlugin", () => {
|
||||
it.effect("defaults includeUsage for OpenAI-compatible package matches", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, OpenAICompatiblePlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom", "model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "file:///tmp/@ai-sdk/openai-compatible-provider.js",
|
||||
options: { name: "custom" },
|
||||
},
|
||||
@@ -49,20 +74,19 @@ describe("OpenAICompatiblePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const observed: string[] = []
|
||||
yield* addPlugin(plugin, OpenAICompatiblePlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("inspector"),
|
||||
effect: Effect.succeed({
|
||||
"aisdk.sdk": (evt) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(evt.sdk.languageModel("model").provider)
|
||||
}),
|
||||
yield* addPlugin()
|
||||
yield* plugin.hook("aisdk.sdk", (event) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(event.sdk.languageModel("model").provider)
|
||||
}),
|
||||
})
|
||||
)
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-provider", "model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "custom-provider", baseURL: "https://example.com/v1" },
|
||||
},
|
||||
@@ -85,11 +109,14 @@ describe("OpenAICompatiblePlugin", () => {
|
||||
}),
|
||||
}),
|
||||
})
|
||||
yield* addPlugin(plugin, OpenAICompatiblePlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-workers-ai" },
|
||||
},
|
||||
|
||||
@@ -1,28 +1,50 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { fakeSelectorSdk, it, model, provider, required } from "./provider-helper"
|
||||
import { host, integrationHost } from "./host"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
function add(plugin: PluginV2.Interface, integrations: Integration.Interface) {
|
||||
return plugin.add({
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
const integrations = yield* Integration.Service
|
||||
yield* plugin.add({
|
||||
id: OpenAIPlugin.id,
|
||||
effect: OpenAIPlugin.effect(host({ integration: integrationHost(integrations) })).pipe(
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
effect: OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)),
|
||||
})
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* add(plugin, yield* Integration.Service)
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("chatgpt-browser"),
|
||||
@@ -41,11 +63,14 @@ describe("OpenAIPlugin", () => {
|
||||
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* add(plugin, yield* Integration.Service)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-openai", "gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai",
|
||||
options: { name: "custom-openai", apiKey: "test" },
|
||||
},
|
||||
@@ -58,10 +83,17 @@ describe("OpenAIPlugin", () => {
|
||||
it.effect("ignores non-OpenAI SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* add(plugin, yield* Integration.Service)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "openai" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeUndefined()
|
||||
@@ -72,11 +104,12 @@ describe("OpenAIPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* add(plugin, yield* Integration.Service)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("openai", "alias", {
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
@@ -93,10 +126,17 @@ describe("OpenAIPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* add(plugin, yield* Integration.Service)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(calls).toEqual([])
|
||||
@@ -106,17 +146,19 @@ describe("OpenAIPlugin", () => {
|
||||
|
||||
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* add(plugin, yield* Integration.Service)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } })
|
||||
const item = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true)
|
||||
expect(
|
||||
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
|
||||
@@ -126,14 +168,18 @@ describe("OpenAIPlugin", () => {
|
||||
|
||||
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* add(plugin, yield* Integration.Service)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("custom-openai")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const item = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest")))
|
||||
.enabled,
|
||||
|
||||
@@ -1,45 +1,72 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { it, model, provider, required, withEnv } from "./provider-helper"
|
||||
import { catalogHost, host, integrationHost } from "./host"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: OpencodePlugin.id, effect: OpencodePlugin.effect(host) })
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
|
||||
const pluginWithIntegrations = (catalog: Catalog.Interface, integrations: Integration.Interface) => ({
|
||||
...OpencodePlugin,
|
||||
effect: OpencodePlugin.effect(host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) })),
|
||||
})
|
||||
|
||||
describe("OpencodePlugin", () => {
|
||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
const provider = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
})
|
||||
const model = new ModelV2.Info({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
|
||||
}),
|
||||
@@ -49,17 +76,23 @@ describe("OpencodePlugin", () => {
|
||||
it.effect("keeps free models without credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const free = model("opencode", "free", { cost: cost(0) })
|
||||
catalog.model.update(item.id, free.id, (draft) => {
|
||||
draft.cost = [...free.cost]
|
||||
const provider = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
})
|
||||
const model = new ModelV2.Info({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
|
||||
api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" },
|
||||
cost: cost(0),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
|
||||
}),
|
||||
@@ -69,17 +102,23 @@ describe("OpencodePlugin", () => {
|
||||
it.effect("treats output-only cost as free without credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
|
||||
catalog.model.update(item.id, outputOnly.id, (draft) => {
|
||||
draft.cost = [...outputOnly.cost]
|
||||
const provider = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
})
|
||||
const model = new ModelV2.Info({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
|
||||
api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" },
|
||||
cost: cost(0, 1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
|
||||
true,
|
||||
@@ -91,17 +130,23 @@ describe("OpencodePlugin", () => {
|
||||
it.effect("uses OPENCODE_API_KEY as credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: "secret" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
const provider = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
})
|
||||
const model = new ModelV2.Info({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
@@ -111,10 +156,8 @@ describe("OpencodePlugin", () => {
|
||||
it.effect("uses configured provider env vars as credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
yield* plugin.add(pluginWithIntegrations(catalog, integrations))
|
||||
yield* integrations.transform((editor) => {
|
||||
editor.method.update({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
@@ -122,13 +165,21 @@ describe("OpencodePlugin", () => {
|
||||
})
|
||||
})
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
const provider = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
})
|
||||
const model = new ModelV2.Info({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
@@ -138,24 +189,29 @@ describe("OpencodePlugin", () => {
|
||||
it.effect("uses configured apiKey as credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("opencode", {
|
||||
const provider = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
request: {
|
||||
headers: {},
|
||||
body: { apiKey: "configured" },
|
||||
},
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.request = item.request
|
||||
const model = new ModelV2.Info({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||
cost: cost(1),
|
||||
})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
catalog.provider.update(provider.id, (draft) => {
|
||||
draft.request = provider.request
|
||||
})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
@@ -165,17 +221,23 @@ describe("OpencodePlugin", () => {
|
||||
it.effect("ignores non-opencode providers and models", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("openai")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("openai", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
const provider = new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
})
|
||||
const model = new ModelV2.Info({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
@@ -206,8 +268,6 @@ describe("OpencodePlugin", () => {
|
||||
const selected = yield* catalog.model.small(providerID)
|
||||
|
||||
expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
|
||||
}).pipe(
|
||||
Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))),
|
||||
),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,56 +3,59 @@ import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, expectPluginRegistered, it, model, provider, required } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: OpenRouterPlugin.id, effect: OpenRouterPlugin.effect(host) })
|
||||
})
|
||||
|
||||
describe("OpenRouterPlugin", () => {
|
||||
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
|
||||
Effect.sync(() =>
|
||||
expectPluginRegistered(
|
||||
ProviderPlugins.map((item) => item.id),
|
||||
"openrouter",
|
||||
),
|
||||
),
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to openrouter", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, OpenRouterPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const openrouter = provider("openrouter", {
|
||||
api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(openrouter.id, (item) => {
|
||||
item.api = openrouter.api
|
||||
item.request = openrouter.request
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates an SDK only for the OpenRouter package", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, OpenRouterPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
const ignored = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("openrouter", "openai/gpt-5"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "openrouter" },
|
||||
},
|
||||
@@ -62,7 +65,14 @@ describe("OpenRouterPlugin", () => {
|
||||
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")),
|
||||
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@openrouter/ai-sdk-provider",
|
||||
options: { name: "custom" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -71,52 +81,37 @@ describe("OpenRouterPlugin", () => {
|
||||
|
||||
it.effect("filters OpenRouter's gpt-5 chat alias", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, OpenRouterPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const openrouter = provider("openrouter", {
|
||||
api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
||||
})
|
||||
catalog.provider.update(openrouter.id, (item) => {
|
||||
item.api = openrouter.api
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||
for (const item of [
|
||||
model("openrouter", "openai/gpt-5-chat"),
|
||||
model("openrouter", "openai/gpt-5"),
|
||||
model("openai", "openai/gpt-5-chat"),
|
||||
]) {
|
||||
catalog.model.update(item.providerID, item.id, () => {})
|
||||
}
|
||||
catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(
|
||||
required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat")))
|
||||
.enabled,
|
||||
).toBe(false)
|
||||
expect(
|
||||
required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled,
|
||||
).toBe(true)
|
||||
expect(
|
||||
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled,
|
||||
).toBe(true)
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(
|
||||
false,
|
||||
)
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")))?.enabled).toBe(true)
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, OpenRouterPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
required(
|
||||
yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")),
|
||||
).enabled,
|
||||
(yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")))
|
||||
?.enabled,
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,18 +1,50 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
|
||||
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: PerplexityPlugin.id, effect: PerplexityPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("PerplexityPlugin", () => {
|
||||
it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, PerplexityPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
|
||||
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/perplexity",
|
||||
options: { name: "perplexity" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -22,11 +54,14 @@ describe("PerplexityPlugin", () => {
|
||||
it.effect("ignores packages that are not the bundled Perplexity package", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, PerplexityPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("perplexity", "sonar"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
|
||||
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/perplexity-compatible",
|
||||
options: { name: "perplexity" },
|
||||
},
|
||||
@@ -39,50 +74,40 @@ describe("PerplexityPlugin", () => {
|
||||
it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providers: string[] = []
|
||||
yield* addPlugin(plugin, PerplexityPlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("perplexity-sdk-inspector"),
|
||||
effect: Effect.succeed({
|
||||
"aisdk.sdk": (evt) =>
|
||||
Effect.sync(() => {
|
||||
providers.push(evt.sdk.languageModel("sonar").provider)
|
||||
}),
|
||||
}),
|
||||
})
|
||||
yield* plugin.trigger(
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
|
||||
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/perplexity",
|
||||
options: { name: "perplexity" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(providers).toEqual(["perplexity"])
|
||||
expect(result.sdk.languageModel("sonar").provider).toBe("perplexity")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates bundled Perplexity SDKs for custom provider IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providers: string[] = []
|
||||
yield* addPlugin(plugin, PerplexityPlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("custom-perplexity-sdk-inspector"),
|
||||
effect: Effect.succeed({
|
||||
"aisdk.sdk": (evt) =>
|
||||
Effect.sync(() => {
|
||||
providers.push(evt.sdk.languageModel("sonar").provider)
|
||||
}),
|
||||
}),
|
||||
})
|
||||
yield* plugin.trigger(
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-perplexity", "sonar"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")),
|
||||
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/perplexity",
|
||||
options: { name: "custom-perplexity" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(providers).toEqual(["perplexity"])
|
||||
expect(result.sdk.languageModel("sonar").provider).toBe("perplexity")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -90,11 +115,14 @@ describe("PerplexityPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, PerplexityPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("perplexity", "alias", { api: { id: ModelV2.ID.make("sonar") } }),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
|
||||
@@ -1,16 +1,54 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core"
|
||||
import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper"
|
||||
import { host } from "./host"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const pluginWithNpm = {
|
||||
id: SapAICorePlugin.id,
|
||||
effect: Effect.gen(function* () {
|
||||
yield* SapAICorePlugin.effect(host({ npm: yield* Npm.Service }))
|
||||
}).pipe(Effect.provide(npmLayer)),
|
||||
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const npm = Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: SapAICorePlugin.id, effect: SapAICorePlugin.effect({ ...host, npm }) })
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function model(providerID: string) {
|
||||
return new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")),
|
||||
api: { id: ModelV2.ID.make("sap-model"), type: "aisdk", package: fixtureProvider },
|
||||
})
|
||||
}
|
||||
|
||||
describe("SapAICorePlugin", () => {
|
||||
@@ -20,11 +58,11 @@ describe("SapAICorePlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(pluginWithNpm)
|
||||
yield* addPlugin()
|
||||
const sdk = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("sap-ai-core", "sap-model"),
|
||||
model: model("sap-ai-core"),
|
||||
package: fixtureProvider,
|
||||
options: { name: "sap-ai-core", serviceKey: "service-key" },
|
||||
},
|
||||
@@ -46,11 +84,11 @@ describe("SapAICorePlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(pluginWithNpm)
|
||||
yield* addPlugin()
|
||||
const sdk = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("sap-ai-core", "sap-model"),
|
||||
model: model("sap-ai-core"),
|
||||
package: fixtureProvider,
|
||||
options: { name: "sap-ai-core", serviceKey: "option-service-key" },
|
||||
},
|
||||
@@ -68,10 +106,10 @@ describe("SapAICorePlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(pluginWithNpm)
|
||||
yield* addPlugin()
|
||||
const sdk = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } },
|
||||
{ model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core" } },
|
||||
{},
|
||||
)
|
||||
expect(process.env.AICORE_SERVICE_KEY).toBeUndefined()
|
||||
@@ -83,17 +121,13 @@ describe("SapAICorePlugin", () => {
|
||||
it.effect("uses the callable SDK for language selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(pluginWithNpm)
|
||||
yield* addPlugin()
|
||||
const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), {
|
||||
languageModel() {
|
||||
throw new Error("SAP AI Core should call the SDK directly")
|
||||
},
|
||||
})
|
||||
const language = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("sap-ai-core", "sap-model"), sdk, options: {} },
|
||||
{},
|
||||
)
|
||||
const language = yield* plugin.trigger("aisdk.language", { model: model("sap-ai-core"), sdk, options: {} }, {})
|
||||
expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" })
|
||||
}),
|
||||
)
|
||||
@@ -104,11 +138,11 @@ describe("SapAICorePlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(pluginWithNpm)
|
||||
yield* addPlugin()
|
||||
const sdk = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("openai", "sap-model"),
|
||||
model: model("openai"),
|
||||
package: fixtureProvider,
|
||||
options: { name: "openai", serviceKey: "service-key" },
|
||||
},
|
||||
@@ -117,7 +151,7 @@ describe("SapAICorePlugin", () => {
|
||||
const language = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("openai", "sap-model"),
|
||||
model: model("openai"),
|
||||
sdk: () => {
|
||||
throw new Error("SAP AI Core should ignore other providers")
|
||||
},
|
||||
|
||||
@@ -1,18 +1,48 @@
|
||||
import { describe, expect, it as bun_it } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { addPlugin, expectPluginRegistered, it, model, withEnv } from "./provider-helper"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: SnowflakeCortexPlugin.id, effect: SnowflakeCortexPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("SnowflakeCortexPlugin", () => {
|
||||
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
|
||||
Effect.sync(() => {
|
||||
expectPluginRegistered(
|
||||
ProviderPlugins.map((item) => item.id),
|
||||
"snowflake-cortex",
|
||||
)
|
||||
const ids = ProviderPlugins.map((p) => p.id as string)
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex"))
|
||||
const ids = ProviderPlugins.map((p) => p.id)
|
||||
expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible"))
|
||||
}),
|
||||
)
|
||||
@@ -20,10 +50,17 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
it.effect("ignores non-snowflake-cortex providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")),
|
||||
api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai",
|
||||
options: { name: "openai" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeUndefined()
|
||||
@@ -34,11 +71,14 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||
},
|
||||
@@ -53,11 +93,14 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
name: "snowflake-cortex",
|
||||
@@ -76,11 +119,14 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||
},
|
||||
@@ -95,11 +141,14 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
name: "snowflake-cortex",
|
||||
@@ -118,27 +167,20 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const captured: Record<string, unknown>[] = []
|
||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("inspector"),
|
||||
effect: Effect.succeed({
|
||||
"aisdk.sdk": (evt) =>
|
||||
Effect.sync(() => {
|
||||
captured.push({ ...evt.options })
|
||||
}),
|
||||
}),
|
||||
})
|
||||
yield* plugin.trigger(
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(captured[0]?.includeUsage).toBe(true)
|
||||
expect(result.options.includeUsage).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai"
|
||||
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: TogetherAIPlugin.id, effect: TogetherAIPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("TogetherAIPlugin", () => {
|
||||
it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, TogetherAIPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/togetherai",
|
||||
options: { name: "togetherai" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -21,12 +54,15 @@ describe("TogetherAIPlugin", () => {
|
||||
it.effect("matches the old bundled provider package exactly", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, TogetherAIPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
const ignored = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("togetherai", "model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "file:///tmp/@ai-sdk/togetherai-provider.js",
|
||||
options: { name: "togetherai" },
|
||||
},
|
||||
@@ -36,7 +72,14 @@ describe("TogetherAIPlugin", () => {
|
||||
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/togetherai",
|
||||
options: { name: "togetherai" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -46,29 +89,22 @@ describe("TogetherAIPlugin", () => {
|
||||
it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const observed: string[] = []
|
||||
yield* addPlugin(plugin, TogetherAIPlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("inspector"),
|
||||
effect: Effect.succeed({
|
||||
"aisdk.sdk": (evt) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(evt.sdk.languageModel("model").provider)
|
||||
}),
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
yield* plugin.trigger(
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-togetherai", "model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/togetherai",
|
||||
options: { name: "custom-togetherai" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
expect(observed).toEqual(["togetherai.chat"])
|
||||
expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -76,12 +112,22 @@ describe("TogetherAIPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, TogetherAIPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(
|
||||
ProviderV2.ID.make("togetherai"),
|
||||
ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
||||
),
|
||||
api: {
|
||||
id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
},
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
|
||||
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: VenicePlugin.id, effect: VenicePlugin.effect(host) })
|
||||
})
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("VenicePlugin", () => {
|
||||
it.effect("creates a Venice SDK for venice-ai-sdk-provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, VenicePlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "venice-ai-sdk-provider",
|
||||
options: { name: "venice" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
@@ -21,39 +54,35 @@ describe("VenicePlugin", () => {
|
||||
it.effect("uses the model provider ID as the bundled Venice SDK name", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const observed: string[] = []
|
||||
yield* addPlugin(plugin, VenicePlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("inspector"),
|
||||
effect: Effect.succeed({
|
||||
"aisdk.sdk": (evt) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(evt.sdk.languageModel("model").provider)
|
||||
}),
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("custom-venice", "model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "venice-ai-sdk-provider",
|
||||
options: { name: "custom-venice", apiKey: "test" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
expect(observed).toEqual(["custom-venice.chat"])
|
||||
expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("only handles the bundled venice-ai-sdk-provider package", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, VenicePlugin)
|
||||
yield* addPlugin()
|
||||
const similar = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("venice", "model"),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "file:///tmp/venice-ai-sdk-provider.js",
|
||||
options: { name: "venice" },
|
||||
},
|
||||
@@ -61,7 +90,14 @@ describe("VenicePlugin", () => {
|
||||
)
|
||||
const other = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("venice", "model"), package: "@ai-sdk/openai-compatible", options: { name: "venice" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "venice" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(similar.sdk).toBeUndefined()
|
||||
@@ -73,10 +109,17 @@ describe("VenicePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin(plugin, VenicePlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, it, model, provider, required } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: VercelPlugin.id, effect: VercelPlugin.effect(host) })
|
||||
})
|
||||
|
||||
describe("VercelPlugin", () => {
|
||||
it.effect("applies legacy lower-case referer headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, VercelPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("vercel", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/vercel" },
|
||||
request: { headers: { Existing: "1" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/vercel" }
|
||||
provider.request.headers.Existing = "1"
|
||||
})
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).toEqual({
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).toEqual({
|
||||
Existing: "1",
|
||||
"http-referer": "https://opencode.ai/",
|
||||
"x-title": "opencode",
|
||||
@@ -32,31 +38,34 @@ describe("VercelPlugin", () => {
|
||||
|
||||
it.effect("does not add legacy upper-case referer headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, VercelPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
})
|
||||
})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty(
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/vercel" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty(
|
||||
"HTTP-Referer",
|
||||
)
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty(
|
||||
"X-Title",
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty("X-Title")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, VercelPlugin)
|
||||
yield* addPlugin()
|
||||
const event = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")),
|
||||
api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" },
|
||||
}),
|
||||
package: "@ai-sdk/vercel",
|
||||
options: { name: "custom-vercel" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(event.sdk).toBeDefined()
|
||||
@@ -66,11 +75,10 @@ describe("VercelPlugin", () => {
|
||||
|
||||
it.effect("ignores non-Vercel providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, VercelPlugin)
|
||||
yield* catalog.transform((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({})
|
||||
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {}))
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,37 +1,66 @@
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { addPlugin, fakeSelectorSdk } from "./provider-helper"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const model = new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("grok-4"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/xai",
|
||||
},
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: XAIPlugin.id, effect: XAIPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("XAIPlugin", () => {
|
||||
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* addPlugin(plugin, XAIPlugin)
|
||||
yield* addPlugin()
|
||||
|
||||
const ignored = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model, package: "@ai-sdk/openai-compatible", options: {} },
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
|
||||
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {})
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
|
||||
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||
}),
|
||||
package: "@ai-sdk/xai",
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
expect(ignored.sdk).toBeUndefined()
|
||||
expect(typeof result.sdk?.responses).toBe("function")
|
||||
@@ -41,32 +70,22 @@ describe("XAIPlugin", () => {
|
||||
it.effect("creates xAI SDKs for custom provider IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providers: string[] = []
|
||||
yield* addPlugin()
|
||||
|
||||
yield* addPlugin(plugin, XAIPlugin)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("xai-sdk-name-observer"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (!evt.sdk) return
|
||||
providers.push(evt.sdk.responses("grok-4").provider)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugin.trigger(
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")),
|
||||
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||
}),
|
||||
package: "@ai-sdk/xai",
|
||||
options: {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
expect(providers).toEqual(["xai.responses"])
|
||||
expect(result.sdk.responses("grok-4").provider).toBe("xai.responses")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -75,11 +94,14 @@ describe("XAIPlugin", () => {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
|
||||
yield* addPlugin(plugin, XAIPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias") }),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
@@ -96,11 +118,14 @@ describe("XAIPlugin", () => {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
|
||||
yield* addPlugin(plugin, XAIPlugin)
|
||||
yield* addPlugin()
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }),
|
||||
model: new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")),
|
||||
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
|
||||
@@ -2,34 +2,44 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make()
|
||||
yield* plugin.add({ id: ZenmuxPlugin.id, effect: ZenmuxPlugin.effect(host) })
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
describe("ZenmuxPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() =>
|
||||
expectPluginRegistered(
|
||||
ProviderPlugins.map((item) => item.id),
|
||||
"zenmux",
|
||||
),
|
||||
),
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))),
|
||||
)
|
||||
|
||||
it.effect("applies the exact legacy Zenmux headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, ZenmuxPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://zenmux.ai/api/v1",
|
||||
}
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux")))
|
||||
expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
|
||||
expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
|
||||
@@ -38,19 +48,18 @@ describe("ZenmuxPlugin", () => {
|
||||
|
||||
it.effect("merges legacy Zenmux headers with existing headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, ZenmuxPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://zenmux.ai/api/v1",
|
||||
}
|
||||
provider.request.headers.Existing = "value"
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
|
||||
Existing: "value",
|
||||
@@ -62,22 +71,18 @@ describe("ZenmuxPlugin", () => {
|
||||
|
||||
it.effect("lets configured Zenmux legacy headers override defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, ZenmuxPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
request: {
|
||||
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
|
||||
body: {},
|
||||
},
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://zenmux.ai/api/v1",
|
||||
}
|
||||
provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
|
||||
"HTTP-Referer": "https://example.com/",
|
||||
@@ -88,20 +93,13 @@ describe("ZenmuxPlugin", () => {
|
||||
|
||||
it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(plugin, ZenmuxPlugin)
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = provider("openrouter", {
|
||||
request: {
|
||||
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
|
||||
body: {},
|
||||
},
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.request = item.request
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||
provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({
|
||||
"HTTP-Referer": "https://example.com/",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
@@ -16,17 +16,16 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const databaseLayer = Database.layerFromPath(":memory:")
|
||||
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
|
||||
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
|
||||
const copyLayer = ProjectCopy.layer.pipe(
|
||||
Layer.provide(databaseLayer),
|
||||
Layer.provide(directoriesLayer),
|
||||
Layer.provide(eventLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectDirectories.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer))
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer),
|
||||
)
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
|
||||
@@ -8,10 +8,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
|
||||
const it = testEffect(Layer.mergeAll(database, events, directories))
|
||||
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer))
|
||||
|
||||
const projectID = Project.ID.make("project-directories")
|
||||
const directory = AbsolutePath.make("/tmp/project-directories")
|
||||
|
||||
@@ -13,20 +13,7 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const databaseLayer = Database.layerFromPath(":memory:")
|
||||
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
ProjectV2.layer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(directoriesLayer),
|
||||
Layer.provide(databaseLayer),
|
||||
),
|
||||
databaseLayer,
|
||||
directoriesLayer,
|
||||
),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer))
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(OpenCode.layer)
|
||||
@@ -41,94 +38,24 @@ describe("public native OpenCode API", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
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* () {
|
||||
yield* writeProvider(tmp.path)
|
||||
const opencode = yield* OpenCode.Service
|
||||
const sessionID = Session.ID.make("ses_public_switch_available")
|
||||
const model = ref({ variant: "fast" })
|
||||
yield* opencode.sessions.create({
|
||||
id: sessionID,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
|
||||
})
|
||||
it.effect("records model selection without resolving the Location catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.Service
|
||||
const sessionID = Session.ID.make("ses_public_switch_deferred")
|
||||
const model = Schema.decodeUnknownSync(Model.Ref)({
|
||||
id: "missing",
|
||||
providerID: "missing",
|
||||
variant: "unknown",
|
||||
})
|
||||
yield* opencode.sessions.create({
|
||||
id: sessionID,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") }),
|
||||
})
|
||||
|
||||
yield* opencode.sessions.switchModel({ sessionID, model })
|
||||
yield* opencode.sessions.switchModel({ sessionID, 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)
|
||||
}),
|
||||
),
|
||||
),
|
||||
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the typed not-found error for a missing Session", () =>
|
||||
@@ -147,31 +74,3 @@ 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" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,10 +6,8 @@ import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const questions = QuestionV2.layer.pipe(Layer.provide(events))
|
||||
const it = testEffect(Layer.mergeAll(database, events, questions))
|
||||
const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, questions))
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_question_test")
|
||||
const question: QuestionV2.Info = {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
@@ -25,8 +25,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
@@ -35,17 +33,23 @@ const projects = Layer.succeed(
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
|
||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(projects),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions),
|
||||
Layer.mergeAll(
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
projects,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
SessionExecution.noopLayer,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const id = SessionV2.ID.create()
|
||||
@@ -336,7 +340,34 @@ describe("SessionV2.create", () => {
|
||||
|
||||
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
|
||||
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
|
||||
expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("switches the selected agent through the durable Session event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "plan" })
|
||||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||
expect(
|
||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects an agent switch for a missing Session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const missing = SessionV2.ID.make("ses_missing_agent_switch")
|
||||
|
||||
expect(
|
||||
yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.map((error) => error._tag),
|
||||
),
|
||||
).toBe("Session.NotFoundError")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -395,3 +426,54 @@ 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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")
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -21,10 +21,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
|
||||
const it = testEffect(Layer.mergeAll(database, events, projector))
|
||||
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer))
|
||||
const sessionID = SessionV2.ID.make("ses_projector_test")
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
|
||||
@@ -113,10 +110,10 @@ describe("SessionProjector", () => {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
SessionV2.layer.pipe(
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(SessionStore.layer.pipe(Layer.provide(database))),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
),
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user