mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
43 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 | |||
| 82d9cab48d | |||
| fb43c15f88 | |||
| ca006a2d20 |
@@ -65,7 +65,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
|
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none
|
||||||
env:
|
env:
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
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
|
_Avoid_: System update, system notification, raw text diff
|
||||||
|
|
||||||
**Context Epoch**:
|
**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**:
|
**Baseline System Context**:
|
||||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||||
@@ -75,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.
|
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
|
||||||
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
|
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
|
||||||
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
|
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
|
||||||
- `SystemContext.replace(...)` 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.
|
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
||||||
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
|
|
||||||
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||||
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
||||||
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
||||||
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
||||||
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
|
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
|
||||||
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
|
|
||||||
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
||||||
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
||||||
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
||||||
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
||||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
||||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
||||||
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||||
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||||
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
||||||
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
|
|
||||||
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||||
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
|
- 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 always starts a new **Context Epoch** while preserving chronological conversation 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.
|
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=",
|
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
|
||||||
"aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=",
|
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
|
||||||
"aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=",
|
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
|
||||||
"x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y="
|
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ export default Runtime.handler(
|
|||||||
|
|
||||||
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
||||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||||
// Preserve the familiar default when available, but let the OS choose a free
|
const next = (port: number): ReturnType<typeof bind> =>
|
||||||
// port when another local server already owns 4096.
|
bind(hostname, port, password).pipe(
|
||||||
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
|
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
|
||||||
|
)
|
||||||
|
return next(4096)
|
||||||
}
|
}
|
||||||
|
|
||||||
function bind(hostname: string, port: number, password: string) {
|
function bind(hostname: string, port: number, password: string) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./test/preload.ts"]
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
|
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
|
||||||
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
|
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
"name": "workspace",
|
"name": "workspace",
|
||||||
@@ -900,16 +900,6 @@
|
|||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "session_context_epoch"
|
"table": "session_context_epoch"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": "'build'",
|
|
||||||
"generated": null,
|
|
||||||
"name": "agent",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_context_epoch"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -930,26 +920,6 @@
|
|||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "session_context_epoch"
|
"table": "session_context_epoch"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "replacement_seq",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_context_epoch"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": "0",
|
|
||||||
"generated": null,
|
|
||||||
"name": "revision",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_context_epoch"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
|
|||||||
@@ -45,15 +45,17 @@ async function generate() {
|
|||||||
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
|
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
target,
|
target,
|
||||||
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.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.mkdir(full)
|
await fs.mkdir(full)
|
||||||
await drizzle(temporary, full, "schema")
|
await drizzle(temporary, full, "schema")
|
||||||
await Bun.write(schema, renderSchema(await generatedSql(full)))
|
await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full))))
|
||||||
await Bun.write(registry, renderRegistry(await typescriptMigrations()))
|
await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations())))
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(temporary, { recursive: true, force: true })
|
await fs.rm(temporary, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
@@ -76,12 +78,12 @@ async function check() {
|
|||||||
|
|
||||||
await fs.mkdir(full)
|
await fs.mkdir(full)
|
||||||
await drizzle(temporary, full, "schema")
|
await drizzle(temporary, full, "schema")
|
||||||
if ((await Bun.file(schema).text()) !== 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.")
|
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
|
||||||
}
|
}
|
||||||
|
|
||||||
const migrations = await typescriptMigrations()
|
const migrations = await typescriptMigrations()
|
||||||
if ((await Bun.file(registry).text()) !== 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.")
|
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -170,6 +172,18 @@ function escapeTemplate(line: string) {
|
|||||||
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
|
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function formatTypescript(input: string) {
|
||||||
|
const prettier = await import("prettier")
|
||||||
|
const typescript = await import("prettier/plugins/typescript")
|
||||||
|
const estree = await import("prettier/plugins/estree")
|
||||||
|
return prettier.format(input, {
|
||||||
|
parser: "typescript",
|
||||||
|
plugins: [typescript.default, estree.default],
|
||||||
|
semi: false,
|
||||||
|
printWidth: 120,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function renderRegistry(names: string[]) {
|
function renderRegistry(names: string[]) {
|
||||||
return `import type { DatabaseMigration } from "./migration"
|
return `import type { DatabaseMigration } from "./migration"
|
||||||
|
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ export const layer = Layer.effect(
|
|||||||
const policy = yield* Policy.Service
|
const policy = yield* Policy.Service
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
|
||||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
|
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
||||||
if (provider.disabled) return false
|
if (provider.disabled) return false
|
||||||
if (typeof provider.request.body.apiKey === "string") return true
|
if (typeof provider.request.body.apiKey === "string") return true
|
||||||
if (connected) return true
|
if (integration?.connections.length) return true
|
||||||
return !integration
|
return !integration
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,13 +183,8 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||||
const connections = yield* integrations.connection.list()
|
|
||||||
return (yield* result.provider.all()).filter((provider) =>
|
return (yield* result.provider.all()).filter((provider) =>
|
||||||
available(
|
available(provider, active.get(Integration.ID.make(provider.id))),
|
||||||
provider,
|
|
||||||
active.get(Integration.ID.make(provider.id)),
|
|
||||||
connections.has(Integration.ID.make(provider.id)),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
|
|||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const Info = Schema.Union([OAuth, Key])
|
export const Value = Schema.Union([OAuth, Key])
|
||||||
.pipe(Schema.toTaggedUnion("type"))
|
.pipe(Schema.toTaggedUnion("type"))
|
||||||
.annotate({ identifier: "Credential.Info" })
|
.annotate({ identifier: "Credential.Value" })
|
||||||
export type Info = Schema.Schema.Type<typeof Info>
|
export type Value = Schema.Schema.Type<typeof Value>
|
||||||
|
|
||||||
export class Stored extends Schema.Class<Stored>("Credential.Stored")({
|
export class Info extends Schema.Class<Info>("Credential.Info")({
|
||||||
id: ID,
|
id: ID,
|
||||||
integrationID: IntegrationSchema.ID,
|
integrationID: IntegrationSchema.ID,
|
||||||
label: Schema.String,
|
label: Schema.String,
|
||||||
value: Info,
|
value: Value,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/** Returns every stored credential. */
|
/** Returns every stored credential. */
|
||||||
readonly all: () => Effect.Effect<Stored[]>
|
readonly all: () => Effect.Effect<Info[]>
|
||||||
/** Returns stored credentials belonging to one integration. */
|
/** Returns stored credentials belonging to one integration. */
|
||||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
|
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
|
||||||
/** Returns one stored credential by ID. */
|
/** Returns one stored credential by ID. */
|
||||||
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
|
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||||
/** Replaces any credential for an integration and returns the new record. */
|
/** Replaces any credential for an integration and returns the new record. */
|
||||||
readonly create: (input: {
|
readonly create: (input: {
|
||||||
readonly integrationID: IntegrationSchema.ID
|
readonly integrationID: IntegrationSchema.ID
|
||||||
readonly value: Info
|
readonly value: Value
|
||||||
readonly label?: string
|
readonly label?: string
|
||||||
}) => Effect.Effect<Stored>
|
}) => Effect.Effect<Info>
|
||||||
/** Updates the label or secret value of a stored credential. */
|
/** Updates the label or secret value of a stored credential. */
|
||||||
readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void>
|
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
|
||||||
/** Removes a stored credential. */
|
/** Removes a stored credential. */
|
||||||
readonly remove: (id: ID) => Effect.Effect<void>
|
readonly remove: (id: ID) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
@@ -66,10 +66,10 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const decode = Schema.decodeUnknownSync(Info)
|
const decode = Schema.decodeUnknownSync(Value)
|
||||||
const stored = (row: typeof CredentialTable.$inferSelect) => {
|
const stored = (row: typeof CredentialTable.$inferSelect) => {
|
||||||
if (!row.integration_id) return
|
if (!row.integration_id) return
|
||||||
return new Stored({
|
return new Info({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
integrationID: row.integration_id,
|
integrationID: row.integration_id,
|
||||||
label: row.label,
|
label: row.label,
|
||||||
@@ -106,7 +106,7 @@ export const layer = Layer.effect(
|
|||||||
return row ? stored(row) : undefined
|
return row ? stored(row) : undefined
|
||||||
}),
|
}),
|
||||||
create: Effect.fn("Credential.create")(function* (input) {
|
create: Effect.fn("Credential.create")(function* (input) {
|
||||||
const credential = new Stored({
|
const credential = new Info({
|
||||||
id: ID.create(),
|
id: ID.create(),
|
||||||
integrationID: input.integrationID,
|
integrationID: input.integrationID,
|
||||||
label: input.label ?? "default",
|
label: input.label ?? "default",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
|
|||||||
id: text().$type<Credential.ID>().primaryKey(),
|
id: text().$type<Credential.ID>().primaryKey(),
|
||||||
integration_id: text().$type<IntegrationSchema.ID>(),
|
integration_id: text().$type<IntegrationSchema.ID>(),
|
||||||
label: text().notNull(),
|
label: text().notNull(),
|
||||||
value: text({ mode: "json" }).$type<Credential.Info>().notNull(),
|
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
|
||||||
connector_id: text(),
|
connector_id: text(),
|
||||||
method_id: text(),
|
method_id: text(),
|
||||||
active: integer({ mode: "boolean" }),
|
active: integer({ mode: "boolean" }),
|
||||||
|
|||||||
+1
@@ -37,5 +37,6 @@ export const migrations = (
|
|||||||
import("./migration/20260611035744_credential"),
|
import("./migration/20260611035744_credential"),
|
||||||
import("./migration/20260611192811_lush_chimera"),
|
import("./migration/20260611192811_lush_chimera"),
|
||||||
import("./migration/20260612174303_project_dir_strategy"),
|
import("./migration/20260612174303_project_dir_strategy"),
|
||||||
|
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|||||||
@@ -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\` (
|
CREATE TABLE \`session_context_epoch\` (
|
||||||
\`session_id\` text PRIMARY KEY,
|
\`session_id\` text PRIMARY KEY,
|
||||||
\`baseline\` text NOT NULL,
|
\`baseline\` text NOT NULL,
|
||||||
\`agent\` text DEFAULT 'build' NOT NULL,
|
|
||||||
\`snapshot\` text NOT NULL,
|
\`snapshot\` text NOT NULL,
|
||||||
\`baseline_seq\` integer NOT NULL,
|
\`baseline_seq\` integer NOT NULL,
|
||||||
\`replacement_seq\` integer,
|
|
||||||
\`revision\` integer DEFAULT 0 NOT NULL,
|
|
||||||
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
|||||||
+130
-177
@@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm"
|
|||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
|
import { externalID, type ExternalID, withStatics } from "./schema"
|
||||||
import { Identifier } from "./util/identifier"
|
import { Identifier } from "./util/identifier"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { LayerNode } from "./effect/layer-node"
|
||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
@@ -19,16 +19,9 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
|
|||||||
)
|
)
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
|
||||||
/**
|
|
||||||
* Durable aggregate continuation position for embedded replay streams.
|
|
||||||
* TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead.
|
|
||||||
*/
|
|
||||||
export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor"))
|
|
||||||
export type Cursor = typeof Cursor.Type
|
|
||||||
|
|
||||||
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
|
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
|
||||||
readonly type: Type
|
readonly type: Type
|
||||||
readonly sync?: {
|
readonly durable?: {
|
||||||
readonly version: number
|
readonly version: number
|
||||||
readonly aggregate: string
|
readonly aggregate: string
|
||||||
}
|
}
|
||||||
@@ -41,20 +34,16 @@ export type Payload<D extends Definition = Definition> = {
|
|||||||
readonly id: ID
|
readonly id: ID
|
||||||
readonly type: D["type"]
|
readonly type: D["type"]
|
||||||
readonly data: Data<D>
|
readonly data: Data<D>
|
||||||
/** Durable aggregate order, populated while synchronized events are projected. */
|
readonly durable?: {
|
||||||
readonly seq?: number
|
readonly aggregateID: string
|
||||||
readonly version?: number
|
readonly seq: number
|
||||||
|
readonly version: number
|
||||||
|
}
|
||||||
readonly location?: Location.Ref
|
readonly location?: Location.Ref
|
||||||
readonly metadata?: Record<string, unknown>
|
readonly metadata?: Record<string, unknown>
|
||||||
/** Internal replay marker for projectors that own non-replicated operational state. */
|
|
||||||
readonly replay?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||||
type AnyProjector = (event: Payload) => Effect.Effect<void>
|
|
||||||
export type CommitGuard = (event: Payload) => Effect.Effect<void>
|
|
||||||
export type Listener = (event: Payload) => Effect.Effect<void>
|
|
||||||
export type Sync = (event: Payload) => Effect.Effect<void>
|
|
||||||
export type Unsubscribe = Effect.Effect<void>
|
export type Unsubscribe = Effect.Effect<void>
|
||||||
|
|
||||||
export type SerializedEvent = {
|
export type SerializedEvent = {
|
||||||
@@ -65,13 +54,8 @@ export type SerializedEvent = {
|
|||||||
readonly data: Record<string, unknown>
|
readonly data: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CursorEvent<E extends Payload = Payload> = {
|
export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()(
|
||||||
readonly cursor: Cursor
|
"EventV2.InvalidDurableEvent",
|
||||||
readonly event: E
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
|
|
||||||
"EventV2.InvalidSyncEvent",
|
|
||||||
{
|
{
|
||||||
type: Schema.String,
|
type: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
@@ -83,19 +67,11 @@ export function versionedType(type: string, version: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const registry = new Map<string, Definition>()
|
export const registry = new Map<string, Definition>()
|
||||||
type SyncDefinition = Definition & {
|
const durableRegistry = new Map<string, Definition>()
|
||||||
readonly sync: NonNullable<Definition["sync"]>
|
|
||||||
readonly encode: (data: unknown) => unknown
|
|
||||||
readonly decode: (data: unknown) => unknown
|
|
||||||
}
|
|
||||||
const syncRegistry = new Map<string, SyncDefinition>()
|
|
||||||
|
|
||||||
// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services.
|
|
||||||
const syncCodec = (definition: Definition) => definition.data as Schema.Codec<unknown, unknown, never, never>
|
|
||||||
|
|
||||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||||
readonly type: Type
|
readonly type: Type
|
||||||
readonly sync?: {
|
readonly durable?: {
|
||||||
readonly version: number
|
readonly version: number
|
||||||
readonly aggregate: string
|
readonly aggregate: string
|
||||||
}
|
}
|
||||||
@@ -106,28 +82,25 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
|
|||||||
id: ID,
|
id: ID,
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
type: Schema.Literal(input.type),
|
type: Schema.Literal(input.type),
|
||||||
version: Schema.optional(Schema.Number),
|
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
|
||||||
location: Schema.optional(Location.Ref),
|
location: Schema.optional(Location.Ref),
|
||||||
data: Data,
|
data: Data,
|
||||||
}).annotate({ identifier: input.type })
|
}).annotate({ identifier: input.type })
|
||||||
|
|
||||||
const definition = Object.assign(Payload, {
|
const definition = Object.assign(Payload, {
|
||||||
type: input.type,
|
type: input.type,
|
||||||
...(input.sync === undefined ? {} : { sync: input.sync }),
|
...(input.durable === undefined ? {} : { durable: input.durable }),
|
||||||
data: Data,
|
data: Data,
|
||||||
})
|
})
|
||||||
const existing = registry.get(input.type)
|
const existing = registry.get(input.type)
|
||||||
if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) {
|
if (
|
||||||
|
input.durable === undefined ||
|
||||||
|
existing?.durable === undefined ||
|
||||||
|
input.durable.version >= existing.durable.version
|
||||||
|
) {
|
||||||
registry.set(input.type, definition)
|
registry.set(input.type, definition)
|
||||||
}
|
}
|
||||||
if (input.sync)
|
if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition)
|
||||||
syncRegistry.set(
|
|
||||||
versionedType(input.type, input.sync.version),
|
|
||||||
Object.assign(definition, {
|
|
||||||
encode: Schema.encodeUnknownSync(syncCodec(definition)),
|
|
||||||
decode: Schema.decodeUnknownSync(syncCodec(definition)),
|
|
||||||
}) as SyncDefinition,
|
|
||||||
)
|
|
||||||
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
||||||
Definition<Type, Schema.Struct<Fields>>
|
Definition<Type, Schema.Struct<Fields>>
|
||||||
}
|
}
|
||||||
@@ -140,7 +113,7 @@ export interface PublishOptions {
|
|||||||
readonly id?: ID
|
readonly id?: ID
|
||||||
readonly metadata?: Record<string, unknown>
|
readonly metadata?: Record<string, unknown>
|
||||||
readonly location?: Location.Ref
|
readonly location?: Location.Ref
|
||||||
/** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */
|
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
|
||||||
readonly commit?: (seq: number) => Effect.Effect<void>
|
readonly commit?: (seq: number) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,14 +125,10 @@ export interface Interface {
|
|||||||
) => Effect.Effect<Payload<D>>
|
) => Effect.Effect<Payload<D>>
|
||||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||||
readonly all: () => Stream.Stream<Payload>
|
readonly all: () => Stream.Stream<Payload>
|
||||||
readonly aggregateEvents: (input: {
|
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
|
||||||
readonly aggregateID: string
|
/** @deprecated Use `all()` and consume the returned stream. */
|
||||||
readonly after?: Cursor
|
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||||
}) => Stream.Stream<CursorEvent>
|
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||||
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
|
||||||
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
|
|
||||||
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
|
|
||||||
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
|
|
||||||
readonly replay: (
|
readonly replay: (
|
||||||
event: SerializedEvent,
|
event: SerializedEvent,
|
||||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||||
@@ -182,37 +151,37 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
Layer.effect(
|
Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const all = yield* PubSub.unbounded<Payload>()
|
const pubsub = {
|
||||||
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
|
all: yield* PubSub.unbounded<Payload>(),
|
||||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
|
||||||
const projectors = new Map<string, AnyProjector[]>()
|
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
||||||
const commitGuards = new Array<CommitGuard>()
|
}
|
||||||
const listeners = new Array<Listener>()
|
const projectors = new Map<string, Subscriber[]>()
|
||||||
const syncHandlers = new Array<Sync>()
|
const listeners = new Array<Subscriber>()
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
const getOrCreate = (definition: Definition) =>
|
const getOrCreate = (definition: Definition) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existing = typed.get(definition.type)
|
const existing = pubsub.typed.get(definition.type)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const pubsub = yield* PubSub.unbounded<Payload>()
|
const created = yield* PubSub.unbounded<Payload>()
|
||||||
typed.set(definition.type, pubsub)
|
pubsub.typed.set(definition.type, created)
|
||||||
return pubsub
|
return created
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* PubSub.shutdown(all)
|
yield* PubSub.shutdown(pubsub.all)
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
synchronized.values(),
|
pubsub.durable.values(),
|
||||||
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
|
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
|
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function commitSyncEvent(
|
function commitDurableEvent(
|
||||||
event: Payload,
|
event: Payload,
|
||||||
input?: {
|
input?: {
|
||||||
readonly seq: number
|
readonly seq: number
|
||||||
@@ -224,28 +193,20 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
) {
|
) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const definition = registry.get(event.type)
|
const definition = registry.get(event.type)
|
||||||
const sync = definition?.sync
|
const durable = definition?.durable
|
||||||
if (sync) {
|
if (durable) {
|
||||||
if (event.version !== sync.version) {
|
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||||
yield* Effect.die(
|
|
||||||
new InvalidSyncEventError({
|
|
||||||
type: event.type,
|
|
||||||
message: `Expected event version ${sync.version}, got ${event.version}`,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
|
|
||||||
if (typeof aggregateID !== "string") {
|
if (typeof aggregateID !== "string") {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Expected string aggregate field ${sync.aggregate}`,
|
message: `Expected string aggregate field ${durable.aggregate}`,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
if (input && input.aggregateID !== aggregateID) {
|
if (input && input.aggregateID !== aggregateID) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||||
}),
|
}),
|
||||||
@@ -265,12 +226,12 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const latest = row?.seq ?? -1
|
const latest = row?.seq ?? -1
|
||||||
const encoded = syncRegistry
|
const encoded = Schema.encodeUnknownSync(
|
||||||
.get(versionedType(definition.type, sync.version))!
|
definition.data as Schema.Codec<unknown, unknown, never, never>,
|
||||||
.encode(event.data) as Record<string, unknown>
|
)(event.data) as Record<string, unknown>
|
||||||
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||||
}),
|
}),
|
||||||
@@ -285,7 +246,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (
|
if (
|
||||||
stored?.id === event.id &&
|
stored?.id === event.id &&
|
||||||
stored.type === versionedType(definition.type, sync.version) &&
|
stored.type === versionedType(definition.type, durable.version) &&
|
||||||
isDeepStrictEqual(stored.data, encoded)
|
isDeepStrictEqual(stored.data, encoded)
|
||||||
) {
|
) {
|
||||||
if (input.ownerID && row?.ownerID == null) {
|
if (input.ownerID && row?.ownerID == null) {
|
||||||
@@ -299,7 +260,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
||||||
}),
|
}),
|
||||||
@@ -311,7 +272,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
const seq = input?.seq ?? latest + 1
|
const seq = input?.seq ?? latest + 1
|
||||||
if (input && seq !== latest + 1) {
|
if (input && seq !== latest + 1) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||||
}),
|
}),
|
||||||
@@ -325,16 +286,17 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (stored)
|
if (stored)
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
for (const guard of commitGuards) {
|
const committed = {
|
||||||
yield* guard(event)
|
...event,
|
||||||
}
|
durable: { aggregateID, seq, version: durable.version },
|
||||||
|
} as Payload
|
||||||
for (const projector of list) {
|
for (const projector of list) {
|
||||||
yield* projector({ ...event, seq } as Payload)
|
yield* projector(committed)
|
||||||
}
|
}
|
||||||
if (commit) yield* commit(seq)
|
if (commit) yield* commit(seq)
|
||||||
yield* db
|
yield* db
|
||||||
@@ -356,7 +318,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
id: event.id,
|
id: event.id,
|
||||||
aggregate_id: aggregateID,
|
aggregate_id: aggregateID,
|
||||||
seq,
|
seq,
|
||||||
type: versionedType(definition.type, sync.version),
|
type: versionedType(definition.type, durable.version),
|
||||||
data: encoded,
|
data: encoded,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -369,8 +331,8 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (committed) {
|
if (committed) {
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
synchronized.get(committed.aggregateID) ?? [],
|
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||||
(pubsub) => PubSub.publish(pubsub, undefined),
|
(wake) => PubSub.publish(wake, undefined),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -384,19 +346,25 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
|
|
||||||
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
|
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const durable = registry.get(event.type)?.sync !== undefined
|
const definition = registry.get(event.type)
|
||||||
if (!durable && commit)
|
if (!definition?.durable && commit)
|
||||||
return yield* Effect.die(
|
return yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: "Local commit hooks require a synchronized event",
|
message: "Local commit hooks require a durable event",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (durable) {
|
if (definition?.durable) {
|
||||||
const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
|
const committed = yield* commitDurableEvent(event as Payload, undefined, commit)
|
||||||
if (committed) {
|
if (committed) {
|
||||||
event = { ...event, seq: committed.seq }
|
event = {
|
||||||
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
|
...event,
|
||||||
|
durable: {
|
||||||
|
aggregateID: committed.aggregateID,
|
||||||
|
seq: committed.seq,
|
||||||
|
version: definition.durable.version,
|
||||||
|
},
|
||||||
|
}
|
||||||
yield* notify(event as Payload, true)
|
yield* notify(event as Payload, true)
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
@@ -406,12 +374,11 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) =>
|
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
|
||||||
Effect.suspend(() => observer(event)).pipe(
|
Effect.suspend(() => observer(event)).pipe(
|
||||||
Effect.catchCauseIf(
|
Effect.catchCauseIf(
|
||||||
(cause) => !Cause.hasInterrupts(cause),
|
(cause) => !Cause.hasInterrupts(cause),
|
||||||
(cause) =>
|
(cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }),
|
||||||
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -419,12 +386,12 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
listeners,
|
listeners,
|
||||||
(listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)),
|
(listener) => (isolateListeners ? observe(event, listener) : listener(event)),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
const pubsub = typed.get(event.type)
|
const typed = pubsub.typed.get(event.type)
|
||||||
if (pubsub) yield* PubSub.publish(pubsub, event)
|
if (typed) yield* PubSub.publish(typed, event)
|
||||||
yield* PubSub.publish(all, event)
|
yield* PubSub.publish(pubsub.all, event)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,7 +408,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
id: options?.id ?? ID.create(),
|
id: options?.id ?? ID.create(),
|
||||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||||
type: definition.type,
|
type: definition.type,
|
||||||
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
|
|
||||||
...(location ? { location } : {}),
|
...(location ? { location } : {}),
|
||||||
data,
|
data,
|
||||||
} as Payload<D>,
|
} as Payload<D>,
|
||||||
@@ -455,27 +421,37 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||||
) {
|
) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const definition = syncRegistry.get(event.type)
|
const definition = durableRegistry.get(event.type)
|
||||||
if (!definition) {
|
if (!definition?.durable) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
|
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
type: definition.type,
|
type: definition.type,
|
||||||
version: definition.sync.version,
|
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(
|
||||||
data: definition.decode(event.data),
|
event.data,
|
||||||
replay: true,
|
),
|
||||||
} as Payload
|
} as Payload
|
||||||
const committed = yield* commitSyncEvent(payload, {
|
const committed = yield* commitDurableEvent(payload, {
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
aggregateID: event.aggregateID,
|
aggregateID: event.aggregateID,
|
||||||
ownerID: options?.ownerID,
|
ownerID: options?.ownerID,
|
||||||
strictOwner: options?.strictOwner,
|
strictOwner: options?.strictOwner,
|
||||||
})
|
})
|
||||||
if (committed && options?.publish) {
|
if (committed && options?.publish) {
|
||||||
yield* notify({ ...payload, seq: committed.seq }, true)
|
yield* notify(
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
durable: {
|
||||||
|
aggregateID: committed.aggregateID,
|
||||||
|
seq: committed.seq,
|
||||||
|
version: definition.durable.version,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -490,7 +466,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
if (!source) return undefined
|
if (!source) return undefined
|
||||||
if (events.some((event) => event.aggregateID !== source)) {
|
if (events.some((event) => event.aggregateID !== source)) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: events[0]?.type ?? "unknown",
|
type: events[0]?.type ?? "unknown",
|
||||||
message: "Replay events must belong to the same aggregate",
|
message: "Replay events must belong to the same aggregate",
|
||||||
}),
|
}),
|
||||||
@@ -501,7 +477,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
const seq = start + index
|
const seq = start + index
|
||||||
if (event.seq !== seq) {
|
if (event.seq !== seq) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
|
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
|
||||||
}),
|
}),
|
||||||
@@ -540,22 +516,18 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
Stream.map((event) => event as Payload<D>),
|
Stream.map((event) => event as Payload<D>),
|
||||||
)
|
)
|
||||||
|
|
||||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
||||||
|
|
||||||
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
|
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||||
const definition = syncRegistry.get(event.type)
|
const definition = durableRegistry.get(event.type)
|
||||||
if (!definition) {
|
if (!definition?.durable) {
|
||||||
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
|
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
cursor: Cursor.make(event.seq),
|
id: event.id,
|
||||||
event: {
|
type: definition.type,
|
||||||
id: event.id,
|
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||||
type: definition.type,
|
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
|
||||||
version: definition.sync.version,
|
|
||||||
seq: event.seq,
|
|
||||||
data: definition.decode(event.data),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,43 +555,40 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const subscribeSynchronized = (aggregateID: string) =>
|
const subscribeDurable = (aggregateID: string) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pubsub = yield* PubSub.sliding<void>(1)
|
const wake = yield* PubSub.sliding<void>(1)
|
||||||
const subscription = yield* PubSub.subscribe(pubsub)
|
const subscription = yield* PubSub.subscribe(wake)
|
||||||
yield* Effect.acquireRelease(
|
yield* Effect.acquireRelease(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const pubsubs = synchronized.get(aggregateID) ?? new Set()
|
const wakes = pubsub.durable.get(aggregateID) ?? new Set()
|
||||||
pubsubs.add(pubsub)
|
wakes.add(wake)
|
||||||
synchronized.set(aggregateID, pubsubs)
|
pubsub.durable.set(aggregateID, wakes)
|
||||||
}),
|
}),
|
||||||
() =>
|
() =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const pubsubs = synchronized.get(aggregateID)
|
const wakes = pubsub.durable.get(aggregateID)
|
||||||
pubsubs?.delete(pubsub)
|
wakes?.delete(wake)
|
||||||
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
|
if (wakes?.size === 0) pubsub.durable.delete(aggregateID)
|
||||||
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
|
}).pipe(Effect.andThen(PubSub.shutdown(wake))),
|
||||||
)
|
)
|
||||||
return subscription
|
return subscription
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamEvents = (input: {
|
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
|
||||||
readonly aggregateID: string
|
|
||||||
readonly after?: Cursor
|
|
||||||
}): Stream.Stream<CursorEvent> =>
|
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const synchronized = yield* subscribeSynchronized(input.aggregateID)
|
const wakes = yield* subscribeDurable(input.aggregateID)
|
||||||
let cursor = input.after ?? -1
|
let sequence = input.after ?? -1
|
||||||
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
|
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
|
||||||
Effect.tap((events) =>
|
Effect.tap((events) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
cursor = events.at(-1)?.cursor ?? cursor
|
sequence = events.at(-1)?.durable?.seq ?? sequence
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const historical = yield* read
|
const historical = yield* read
|
||||||
const live = Stream.fromSubscription(synchronized).pipe(
|
const live = Stream.fromSubscription(wakes).pipe(
|
||||||
Stream.mapEffect(() => read),
|
Stream.mapEffect(() => read),
|
||||||
Stream.flattenIterable,
|
Stream.flattenIterable,
|
||||||
)
|
)
|
||||||
@@ -627,7 +596,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
|
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
listeners.push(listener)
|
listeners.push(listener)
|
||||||
return Effect.sync(() => {
|
return Effect.sync(() => {
|
||||||
@@ -636,21 +605,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
|
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
|
||||||
Effect.sync(() => {
|
|
||||||
syncHandlers.push(handler)
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = syncHandlers.indexOf(handler)
|
|
||||||
if (index >= 0) syncHandlers.splice(index, 1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
commitGuards.push(guard)
|
|
||||||
})
|
|
||||||
|
|
||||||
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
|
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const list = projectors.get(definition.type) ?? []
|
const list = projectors.get(definition.type) ?? []
|
||||||
list.push((event) => projector(event as Payload<D>))
|
list.push((event) => projector(event as Payload<D>))
|
||||||
@@ -661,10 +616,8 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
publish,
|
publish,
|
||||||
subscribe,
|
subscribe,
|
||||||
all: streamAll,
|
all: streamAll,
|
||||||
aggregateEvents: streamEvents,
|
durable,
|
||||||
sync,
|
|
||||||
listen,
|
listen,
|
||||||
beforeCommit,
|
|
||||||
project,
|
project,
|
||||||
replay,
|
replay,
|
||||||
replayAll,
|
replayAll,
|
||||||
|
|||||||
@@ -123,6 +123,6 @@ const baseLayer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|||||||
@@ -232,6 +232,6 @@ export const fffLayer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.unwrap(
|
export const locationLayer = Layer.unwrap(
|
||||||
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
|
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -108,11 +108,11 @@ export type OAuthAuthorization = {
|
|||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
readonly mode: "auto"
|
readonly mode: "auto"
|
||||||
readonly callback: Effect.Effect<Credential.Info, unknown>
|
readonly callback: Effect.Effect<Credential.Value, unknown>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly mode: "code"
|
readonly mode: "code"
|
||||||
readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown>
|
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -214,8 +214,6 @@ export interface Interface extends State.Transformable<Draft> {
|
|||||||
/** Returns all integrations with their methods and current connections. */
|
/** Returns all integrations with their methods and current connections. */
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
readonly connection: {
|
readonly connection: {
|
||||||
/** Returns active connections for every registered or credential-backed integration. */
|
|
||||||
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
|
|
||||||
/** Returns the active connection for one integration. */
|
/** Returns the active connection for one integration. */
|
||||||
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||||
/** Runs a key method and stores the resulting credential. */
|
/** Runs a key method and stores the resulting credential. */
|
||||||
@@ -241,7 +239,7 @@ export interface Interface extends State.Transformable<Draft> {
|
|||||||
/** Updates a stored credential exposed as a connection. */
|
/** Updates a stored credential exposed as a connection. */
|
||||||
readonly update: (
|
readonly update: (
|
||||||
credentialID: Credential.ID,
|
credentialID: Credential.ID,
|
||||||
updates: Partial<Pick<Credential.Stored, "label">>,
|
updates: Partial<Pick<Credential.Info, "label">>,
|
||||||
) => Effect.Effect<void>
|
) => Effect.Effect<void>
|
||||||
/** Removes a stored credential connection. */
|
/** Removes a stored credential connection. */
|
||||||
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||||
@@ -353,39 +351,27 @@ export const locationLayer = Layer.effect(
|
|||||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||||
})
|
})
|
||||||
|
|
||||||
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
|
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
|
||||||
const connected = saved.map((credential) => ({
|
const credentials = saved
|
||||||
type: "credential" as const,
|
.map((credential) => ({
|
||||||
id: credential.id,
|
type: "credential" as const,
|
||||||
label: credential.label,
|
id: credential.id,
|
||||||
}))
|
label: credential.label,
|
||||||
const detected = entry.methods
|
}))
|
||||||
|
.toReversed()
|
||||||
|
const env = (entry?.methods ?? [])
|
||||||
.filter((method) => method.type === "env")
|
.filter((method) => method.type === "env")
|
||||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||||
.map((name) => ({ type: "env" as const, name }))
|
.map((name) => ({ type: "env" as const, name }))
|
||||||
return [...connected, ...detected]
|
return [...credentials, ...env]
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeConnection = (
|
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
||||||
entry: Entry | undefined,
|
|
||||||
saved: readonly Credential.Stored[],
|
|
||||||
): IntegrationConnection.Info | undefined => {
|
|
||||||
const credential = saved.at(-1)
|
|
||||||
if (credential) return { type: "credential", id: credential.id, label: credential.label }
|
|
||||||
if (!entry) return
|
|
||||||
const name = entry.methods
|
|
||||||
.filter((method) => method.type === "env")
|
|
||||||
.flatMap((method) => method.names)
|
|
||||||
.find((name) => process.env[name])
|
|
||||||
if (name) return { type: "env", name }
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
|
|
||||||
new Info({
|
new Info({
|
||||||
id: entry.ref.id,
|
id: entry.ref.id,
|
||||||
name: entry.ref.name,
|
name: entry.ref.name,
|
||||||
methods: entry.methods,
|
methods: entry.methods,
|
||||||
connections: connections(entry, saved),
|
connections,
|
||||||
})
|
})
|
||||||
|
|
||||||
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
@@ -399,7 +385,7 @@ export const locationLayer = Layer.effect(
|
|||||||
return error instanceof Error ? error.message : String(error)
|
return error instanceof Error ? error.message : String(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) {
|
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
|
||||||
const now = yield* Clock.currentTimeMillis
|
const now = yield* Clock.currentTimeMillis
|
||||||
const result = yield* SynchronizedRef.modify(attempts, (current) => {
|
const result = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||||
const attempt = current.get(attemptID)
|
const attempt = current.get(attemptID)
|
||||||
@@ -450,28 +436,18 @@ export const locationLayer = Layer.effect(
|
|||||||
get: Effect.fn("Integration.get")(function* (id) {
|
get: Effect.fn("Integration.get")(function* (id) {
|
||||||
const entry = state.get().integrations.get(id)
|
const entry = state.get().integrations.get(id)
|
||||||
if (!entry) return undefined
|
if (!entry) return undefined
|
||||||
return project(entry, yield* credentials.list(id))
|
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("Integration.list")(function* () {
|
list: Effect.fn("Integration.list")(function* () {
|
||||||
return (yield* Effect.forEach(state.get().integrations.values(), (entry) =>
|
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||||
Effect.gen(function* () {
|
return Array.from(state.get().integrations.values(), (entry) =>
|
||||||
return project(entry, yield* credentials.list(entry.ref.id))
|
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
|
||||||
}),
|
).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
)).toSorted((a, b) => a.name.localeCompare(b.name))
|
|
||||||
}),
|
}),
|
||||||
connection: {
|
connection: {
|
||||||
list: Effect.fn("Integration.connection.list")(function* () {
|
|
||||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
|
||||||
return new Map(
|
|
||||||
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
|
|
||||||
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
|
|
||||||
return connection ? [[id, connection] as const] : []
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
||||||
const entry = state.get().integrations.get(id)
|
const entry = state.get().integrations.get(id)
|
||||||
return activeConnection(entry, yield* credentials.list(id))
|
return resolveConnections(entry, yield* credentials.list(id))[0]
|
||||||
}),
|
}),
|
||||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||||
const method = state
|
const method = state
|
||||||
|
|||||||
@@ -103,8 +103,16 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
}[keyof Hooks][] = []
|
}[keyof Hooks][] = []
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const scope = yield* Scope.Scope
|
|
||||||
const locks = KeyedMutex.makeUnsafe<ID>()
|
const locks = KeyedMutex.makeUnsafe<ID>()
|
||||||
|
const scope = yield* Scope.make()
|
||||||
|
|
||||||
|
// One registry-owned scope lets shutdown remove every plugin transform in one batch.
|
||||||
|
yield* Effect.addFinalizer((exit) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
hooks = []
|
||||||
|
yield* State.batch(Scope.close(scope, exit))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const svc = Service.of({
|
const svc = Service.of({
|
||||||
add: Effect.fn("Plugin.add")(function* (input) {
|
add: Effect.fn("Plugin.add")(function* (input) {
|
||||||
@@ -112,7 +120,7 @@ export const layer = Layer.effect(
|
|||||||
yield* locks.withLock(id)(
|
yield* locks.withLock(id)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existing = hooks.find((item) => item.id === id)
|
const existing = hooks.find((item) => item.id === id)
|
||||||
if (existing) yield* 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 childScope = yield* Scope.fork(scope)
|
||||||
const result = yield* input.effect.pipe(
|
const result = yield* input.effect.pipe(
|
||||||
Scope.provide(childScope),
|
Scope.provide(childScope),
|
||||||
@@ -181,7 +189,7 @@ export const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existing = hooks.find((item) => item.id === id)
|
const existing = hooks.find((item) => item.id === id)
|
||||||
hooks = hooks.filter((item) => item.id !== id)
|
hooks = hooks.filter((item) => item.id !== id)
|
||||||
if (existing) yield* 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"
|
export * as OpenCode from "./opencode"
|
||||||
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { Catalog } from "../catalog"
|
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { LocationServiceMap } from "../location-layer"
|
import { LocationServiceMap } from "../location-layer"
|
||||||
import { PluginBoot } from "../plugin/boot"
|
|
||||||
import { ProjectV2 } from "../project"
|
import { ProjectV2 } from "../project"
|
||||||
import { SessionV2 } from "../session"
|
import { SessionV2 } from "../session"
|
||||||
import * as SessionExecutionLocal from "../session/execution/local"
|
import * as SessionExecutionLocal from "../session/execution/local"
|
||||||
@@ -23,69 +21,22 @@ export interface Interface {
|
|||||||
/** Intentional public native API for Effect applications embedding OpenCode. */
|
/** Intentional public native API for Effect applications embedding OpenCode. */
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
|
||||||
|
|
||||||
class SessionModelValidation extends Context.Service<
|
const SessionsLayer = SessionV2.layer.pipe(
|
||||||
SessionModelValidation,
|
Layer.provide(SessionProjector.layer),
|
||||||
{
|
Layer.provide(SessionExecutionLocal.layer),
|
||||||
readonly validate: (
|
Layer.provide(SessionStore.layer),
|
||||||
input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
|
Layer.provide(EventV2.layer),
|
||||||
) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
|
Layer.provide(Database.defaultLayer),
|
||||||
}
|
Layer.provide(ProjectV2.defaultLayer),
|
||||||
>()("@opencode/public/OpenCode/SessionModelValidation") {}
|
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
|
||||||
|
Layer.orDie,
|
||||||
const ApplicationToolsLayer = ApplicationTools.layer
|
|
||||||
const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer))
|
|
||||||
const SessionModelValidationLayer = Layer.effect(
|
|
||||||
SessionModelValidation,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const locations = yield* LocationServiceMap
|
|
||||||
return SessionModelValidation.of({
|
|
||||||
validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) {
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
yield* (yield* PluginBoot.Service).wait()
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const model = (yield* catalog.model.available()).find(
|
|
||||||
(model) => model.providerID === input.model.providerID && model.id === input.model.id,
|
|
||||||
)
|
|
||||||
if (!model)
|
|
||||||
return yield* new Session.ModelUnavailableError({
|
|
||||||
providerID: input.model.providerID,
|
|
||||||
modelID: input.model.id,
|
|
||||||
})
|
|
||||||
if (
|
|
||||||
input.model.variant !== undefined &&
|
|
||||||
input.model.variant !== "default" &&
|
|
||||||
!model.variants.some((variant) => variant.id === input.model.variant)
|
|
||||||
)
|
|
||||||
return yield* new Session.VariantUnavailableError({
|
|
||||||
providerID: input.model.providerID,
|
|
||||||
modelID: input.model.id,
|
|
||||||
variant: input.model.variant,
|
|
||||||
})
|
|
||||||
}).pipe(Effect.provide(locations.get(input.location)))
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const SessionsLayer = Layer.merge(
|
|
||||||
SessionV2.layer.pipe(
|
|
||||||
Layer.provide(SessionProjector.layer),
|
|
||||||
Layer.provide(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.
|
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sessions = yield* SessionV2.Service
|
const sessions = yield* SessionV2.Service
|
||||||
const tools = yield* ApplicationTools.Service
|
const tools = yield* ApplicationTools.Service
|
||||||
const validation = yield* SessionModelValidation
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
tools: { register: tools.register },
|
tools: { register: tools.register },
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -98,11 +49,7 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
get: sessions.get,
|
get: sessions.get,
|
||||||
list: sessions.list,
|
list: sessions.list,
|
||||||
switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) {
|
switchModel: sessions.switchModel,
|
||||||
const session = yield* sessions.get(input.sessionID)
|
|
||||||
yield* validation.validate({ ...input, location: session.location })
|
|
||||||
yield* sessions.switchModel(input)
|
|
||||||
}),
|
|
||||||
interrupt: sessions.interrupt,
|
interrupt: sessions.interrupt,
|
||||||
prompt: (input) =>
|
prompt: (input) =>
|
||||||
sessions.prompt({
|
sessions.prompt({
|
||||||
@@ -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.
|
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
export * as Session from "./session"
|
export * as Session from "./session"
|
||||||
|
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { EventV2 } from "../event"
|
|
||||||
import { ModelV2 } from "../model"
|
|
||||||
import { SessionV2 } from "../session"
|
import { SessionV2 } from "../session"
|
||||||
import { MessageDecodeError } from "../session/error"
|
import { MessageDecodeError } from "../session/error"
|
||||||
import { SessionEvent } from "../session/event"
|
import { SessionEvent } from "../session/event"
|
||||||
@@ -34,9 +32,7 @@ export type Delivery = SessionInput.Delivery
|
|||||||
export const ListInput = SessionV2.ListInput
|
export const ListInput = SessionV2.ListInput
|
||||||
export type ListInput = SessionV2.ListInput
|
export type ListInput = SessionV2.ListInput
|
||||||
|
|
||||||
export const EventCursor = EventV2.Cursor
|
export type Event = SessionEvent.DurableEvent
|
||||||
export type EventCursor = EventV2.Cursor
|
|
||||||
export type Event = EventV2.CursorEvent<SessionEvent.DurableEvent>
|
|
||||||
|
|
||||||
export const NotFoundError = SessionV2.NotFoundError
|
export const NotFoundError = SessionV2.NotFoundError
|
||||||
export type NotFoundError = SessionV2.NotFoundError
|
export type NotFoundError = SessionV2.NotFoundError
|
||||||
@@ -44,23 +40,6 @@ export type NotFoundError = SessionV2.NotFoundError
|
|||||||
export const PromptConflictError = SessionV2.PromptConflictError
|
export const PromptConflictError = SessionV2.PromptConflictError
|
||||||
export type PromptConflictError = SessionV2.PromptConflictError
|
export type PromptConflictError = SessionV2.PromptConflictError
|
||||||
|
|
||||||
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
|
|
||||||
"Session.ModelUnavailableError",
|
|
||||||
{
|
|
||||||
providerID: Model.Ref.fields.providerID,
|
|
||||||
modelID: Model.Ref.fields.id,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
|
||||||
"Session.VariantUnavailableError",
|
|
||||||
{
|
|
||||||
providerID: Model.Ref.fields.providerID,
|
|
||||||
modelID: Model.Ref.fields.id,
|
|
||||||
variant: ModelV2.VariantID,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export { MessageDecodeError }
|
export { MessageDecodeError }
|
||||||
|
|
||||||
export interface CreateInput {
|
export interface CreateInput {
|
||||||
@@ -99,7 +78,7 @@ export interface MessageInput {
|
|||||||
|
|
||||||
export interface EventsInput {
|
export interface EventsInput {
|
||||||
readonly sessionID: ID
|
readonly sessionID: ID
|
||||||
readonly after?: EventCursor
|
readonly after?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
@@ -107,9 +86,7 @@ export interface Interface {
|
|||||||
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
|
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||||
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
|
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
|
||||||
readonly switchModel: (
|
readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError>
|
||||||
input: SwitchModelInput,
|
|
||||||
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
|
|
||||||
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
|
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
|
||||||
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
|
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
|
||||||
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
export * as SessionV2 from "./session"
|
export * as SessionV2 from "./session"
|
||||||
export * from "./session/schema"
|
export * from "./session/schema"
|
||||||
|
|
||||||
import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
|
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||||
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm"
|
||||||
import { ProjectV2 } from "./project"
|
import { ProjectV2 } from "./project"
|
||||||
import { WorkspaceV2 } from "./workspace"
|
import { WorkspaceV2 } from "./workspace"
|
||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
@@ -25,7 +25,6 @@ import { fromRow } from "./session/info"
|
|||||||
import { SessionRunner } from "./session/runner/index"
|
import { SessionRunner } from "./session/runner/index"
|
||||||
import { SessionStore } from "./session/store"
|
import { SessionStore } from "./session/store"
|
||||||
import { SessionExecution } from "./session/execution"
|
import { SessionExecution } from "./session/execution"
|
||||||
import { logFailure } from "./session/logging"
|
|
||||||
import { MessageDecodeError } from "./session/error"
|
import { MessageDecodeError } from "./session/error"
|
||||||
import { SessionEvent } from "./session/event"
|
import { SessionEvent } from "./session/event"
|
||||||
import { SessionInput } from "./session/input"
|
import { SessionInput } from "./session/input"
|
||||||
@@ -124,12 +123,9 @@ export interface Interface {
|
|||||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||||
readonly events: (input: {
|
readonly events: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
after?: EventV2.Cursor
|
after?: number
|
||||||
}) => Stream.Stream<EventV2.CursorEvent<SessionEvent.DurableEvent>, NotFoundError>
|
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||||
readonly switchAgent: (input: {
|
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||||
sessionID: SessionSchema.ID
|
|
||||||
agent: string
|
|
||||||
}) => Effect.Effect<void, OperationUnavailableError>
|
|
||||||
readonly switchModel: (input: {
|
readonly switchModel: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
model: ModelV2.Ref
|
model: ModelV2.Ref
|
||||||
@@ -171,20 +167,6 @@ export const layer = Layer.effect(
|
|||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||||
const scope = yield* Effect.scope
|
|
||||||
|
|
||||||
const enqueueWake = (admitted: SessionInput.Admitted) =>
|
|
||||||
execution.wake(admitted.sessionID, admitted.admittedSeq).pipe(
|
|
||||||
Effect.tapCause((cause) =>
|
|
||||||
Cause.hasInterruptsOnly(cause)
|
|
||||||
? Effect.void
|
|
||||||
: logFailure("Failed to wake Session", admitted.sessionID, cause),
|
|
||||||
),
|
|
||||||
Effect.ignore,
|
|
||||||
Effect.forkIn(scope, { startImmediately: true }),
|
|
||||||
Effect.asVoid,
|
|
||||||
)
|
|
||||||
|
|
||||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
@@ -266,6 +248,13 @@ export const layer = Layer.effect(
|
|||||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||||
|
if ("subpath" in input && input.subpath)
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
eq(SessionTable.path, input.subpath),
|
||||||
|
and(gte(SessionTable.path, `${input.subpath}/`), lt(SessionTable.path, `${input.subpath}0`)),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||||
if (input.anchor) {
|
if (input.anchor) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
@@ -339,20 +328,12 @@ export const layer = Layer.effect(
|
|||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
result
|
result
|
||||||
.get(input.sessionID)
|
.get(input.sessionID)
|
||||||
.pipe(Effect.as(events.aggregateEvents({ aggregateID: input.sessionID, after: input.after }))),
|
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||||
).pipe(
|
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||||
Stream.filter((event): event is EventV2.CursorEvent<SessionEvent.DurableEvent> =>
|
|
||||||
isDurableSessionEvent(event.event),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||||
Effect.uninterruptible(
|
Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
|
|
||||||
if (input.resume !== false) yield* enqueueWake(admitted)
|
|
||||||
return admitted
|
|
||||||
}, Effect.uninterruptible)
|
|
||||||
const messageID = input.id ?? SessionMessage.ID.create()
|
const messageID = input.id ?? SessionMessage.ID.create()
|
||||||
const delivery = input.delivery ?? "steer"
|
const delivery = input.delivery ?? "steer"
|
||||||
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
|
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
|
||||||
@@ -370,7 +351,8 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
if (!SessionInput.equivalent(admitted, expected))
|
if (!SessionInput.equivalent(admitted, expected))
|
||||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
return yield* returnPrompt(admitted)
|
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
||||||
|
return admitted
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -380,8 +362,14 @@ export const layer = Layer.effect(
|
|||||||
skill: Effect.fn("V2Session.skill")(function* () {
|
skill: Effect.fn("V2Session.skill")(function* () {
|
||||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||||
}),
|
}),
|
||||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
|
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||||
return yield* new OperationUnavailableError({ operation: "switchAgent" })
|
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) {
|
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
@@ -405,19 +393,7 @@ export const layer = Layer.effect(
|
|||||||
yield* execution.resume(sessionID)
|
yield* execution.resume(sessionID)
|
||||||
}),
|
}),
|
||||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||||
Effect.uninterruptible(
|
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||||
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.seq === undefined)
|
|
||||||
return yield* Effect.die("Interrupt request event is missing aggregate sequence")
|
|
||||||
yield* execution.interrupt(sessionID, event.seq)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,31 @@
|
|||||||
export * as SessionContextEpoch from "./context-epoch"
|
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 { DateTime, Effect, Schema } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
|
||||||
import type { Database } from "../database/database"
|
import type { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { Location } from "../location"
|
|
||||||
import { SystemContext } from "../system-context/index"
|
import { SystemContext } from "../system-context/index"
|
||||||
import { ContextSnapshotDecodeError } from "./error"
|
import { ContextSnapshotDecodeError } from "./error"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
|
import { SessionHistory } from "./history"
|
||||||
import { SessionInput } from "./input"
|
import { SessionInput } from "./input"
|
||||||
import { SessionMessageID } from "./message-id"
|
import { SessionMessageID } from "./message-id"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionContextEpochTable, SessionTable } from "./sql"
|
import { SessionContextEpochTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
class RevisionMismatch extends Error {}
|
|
||||||
class LocationMismatch extends Error {}
|
|
||||||
export class AgentMismatch extends Error {}
|
|
||||||
export class AgentReplacementBlocked extends Schema.TaggedErrorClass<AgentReplacementBlocked>()(
|
|
||||||
"SessionContextEpoch.AgentReplacementBlocked",
|
|
||||||
{ sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID },
|
|
||||||
) {}
|
|
||||||
|
|
||||||
const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect.Effect<A, E> =>
|
|
||||||
attempt().pipe(
|
|
||||||
Effect.catchDefect((defect) =>
|
|
||||||
defect instanceof RevisionMismatch
|
|
||||||
? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt)))
|
|
||||||
: Effect.die(defect),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
interface Prepared {
|
interface Prepared {
|
||||||
readonly baseline: string
|
readonly baseline: string
|
||||||
readonly baselineSeq: number
|
readonly baselineSeq: number
|
||||||
readonly revision: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initialize(
|
export function initialize(
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
context: Effect.Effect<SystemContext.SystemContext>,
|
context: Effect.Effect<SystemContext.SystemContext>,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
location: Location.Ref,
|
|
||||||
agent: AgentV2.ID,
|
|
||||||
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||||
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe(
|
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize"))
|
||||||
Effect.withSpan("SessionContextEpoch.initialize"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepare(
|
export function prepare(
|
||||||
@@ -56,12 +33,8 @@ export function prepare(
|
|||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
context: Effect.Effect<SystemContext.SystemContext>,
|
context: Effect.Effect<SystemContext.SystemContext>,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
location: Location.Ref,
|
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
|
||||||
agent: AgentV2.ID,
|
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare"))
|
||||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError | AgentReplacementBlocked> {
|
|
||||||
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe(
|
|
||||||
Effect.withSpan("SessionContextEpoch.prepare"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const prepareOnce = Effect.fnUntraced(function* (
|
const prepareOnce = Effect.fnUntraced(function* (
|
||||||
@@ -69,57 +42,50 @@ const prepareOnce = Effect.fnUntraced(function* (
|
|||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
context: Effect.Effect<SystemContext.SystemContext>,
|
context: Effect.Effect<SystemContext.SystemContext>,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
location: Location.Ref,
|
|
||||||
agent: AgentV2.ID,
|
|
||||||
) {
|
) {
|
||||||
const [value, stored] = 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) {
|
if (!stored) {
|
||||||
const generation = yield* SystemContext.initialize(value)
|
const generation = yield* SystemContext.initialize(value)
|
||||||
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
|
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||||
return { baseline: generation.baseline, baselineSeq, revision: 0 }
|
return { baseline: generation.baseline, baselineSeq }
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
|
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
|
||||||
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
|
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
|
||||||
)
|
)
|
||||||
const replacingAgent = stored.agent !== agent
|
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
|
||||||
const result =
|
const result = replacementSeq
|
||||||
stored.replacement_seq === null && !replacingAgent
|
? yield* SystemContext.replace(value, snapshot)
|
||||||
? yield* SystemContext.reconcile(value, snapshot)
|
: yield* SystemContext.reconcile(value, snapshot)
|
||||||
: yield* SystemContext.replace(value, snapshot)
|
|
||||||
if (result._tag === "ReplacementBlocked" && replacingAgent) {
|
|
||||||
yield* fence(db, sessionID, agent, stored.revision)
|
|
||||||
return yield* new AgentReplacementBlocked({ sessionID, previous: stored.agent, current: agent })
|
|
||||||
}
|
|
||||||
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
|
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
|
||||||
yield* fence(db, sessionID, agent, stored.revision)
|
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision }
|
|
||||||
}
|
}
|
||||||
if (result._tag === "ReplacementReady") {
|
if (result._tag === "ReplacementReady") {
|
||||||
const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
const baselineSeq = replacementSeq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
||||||
yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation)
|
yield* replace(db, sessionID, baselineSeq, result.generation)
|
||||||
return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 }
|
return { baseline: result.generation.baseline, baselineSeq }
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* events.publish(
|
yield* events.publish(
|
||||||
SessionEvent.ContextUpdated,
|
SessionEvent.ContextUpdated,
|
||||||
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||||
{ commit: () => advance(db, sessionID, 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* (
|
const initializeOnce = Effect.fnUntraced(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
context: Effect.Effect<SystemContext.SystemContext>,
|
context: Effect.Effect<SystemContext.SystemContext>,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
location: Location.Ref,
|
|
||||||
agent: AgentV2.ID,
|
|
||||||
) {
|
) {
|
||||||
if (yield* exists(db, sessionID)) return
|
if (yield* exists(db, sessionID)) return
|
||||||
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
|
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
|
||||||
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
|
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||||
return { baseline: generation.baseline, baselineSeq, revision: 0 }
|
return { baseline: generation.baseline, baselineSeq }
|
||||||
})
|
})
|
||||||
|
|
||||||
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
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)
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
const requireAgentSelection = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
agent: AgentV2.ID,
|
|
||||||
) {
|
|
||||||
const selected = yield* db
|
|
||||||
.select({ agent: SessionTable.agent })
|
|
||||||
.from(SessionTable)
|
|
||||||
.where(eq(SessionTable.id, sessionID))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (!selected || (selected.agent !== null && selected.agent !== agent)) return yield* Effect.die(new AgentMismatch())
|
|
||||||
})
|
|
||||||
|
|
||||||
export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
seq: number,
|
|
||||||
) {
|
|
||||||
return yield* db
|
|
||||||
.update(SessionContextEpochTable)
|
|
||||||
.set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(SessionContextEpochTable.session_id, sessionID),
|
|
||||||
lt(SessionContextEpochTable.baseline_seq, seq),
|
|
||||||
or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
@@ -189,155 +122,53 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
|||||||
const insert = Effect.fnUntraced(function* (
|
const insert = Effect.fnUntraced(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
location: Location.Ref,
|
|
||||||
agent: AgentV2.ID,
|
|
||||||
generation: SystemContext.Generation,
|
generation: SystemContext.Generation,
|
||||||
) {
|
) {
|
||||||
return yield* db
|
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
|
||||||
.transaction(
|
yield* db
|
||||||
() =>
|
.insert(SessionContextEpochTable)
|
||||||
Effect.gen(function* () {
|
.values({
|
||||||
const placed = yield* db
|
session_id: sessionID,
|
||||||
.select({ agent: SessionTable.agent })
|
baseline: generation.baseline,
|
||||||
.from(SessionTable)
|
snapshot: generation.snapshot,
|
||||||
.where(
|
baseline_seq: baselineSeq,
|
||||||
and(
|
})
|
||||||
eq(SessionTable.id, sessionID),
|
.run()
|
||||||
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" },
|
|
||||||
)
|
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
return baselineSeq
|
||||||
})
|
})
|
||||||
|
|
||||||
const replace = Effect.fnUntraced(function* (
|
const replace = Effect.fnUntraced(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
agent: AgentV2.ID,
|
|
||||||
expectedRevision: number,
|
|
||||||
baselineSeq: number,
|
baselineSeq: number,
|
||||||
generation: SystemContext.Generation,
|
generation: SystemContext.Generation,
|
||||||
) {
|
) {
|
||||||
yield* db
|
const updated = yield* db
|
||||||
.transaction(
|
.update(SessionContextEpochTable)
|
||||||
() =>
|
.set({
|
||||||
Effect.gen(function* () {
|
baseline: generation.baseline,
|
||||||
yield* requireAgentSelection(db, sessionID, agent)
|
snapshot: generation.snapshot,
|
||||||
const updated = yield* db
|
baseline_seq: baselineSeq,
|
||||||
.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,
|
|
||||||
})
|
})
|
||||||
.from(SessionContextEpochTable)
|
|
||||||
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
|
|
||||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
|
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
return (
|
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||||
value !== undefined &&
|
|
||||||
value.agent === agent &&
|
|
||||||
(value.selected === null || value.selected === agent) &&
|
|
||||||
value.revision === revision
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const advance = Effect.fnUntraced(function* (
|
const advance = Effect.fnUntraced(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
expectedRevision: number,
|
|
||||||
snapshot: SystemContext.Snapshot,
|
snapshot: SystemContext.Snapshot,
|
||||||
) {
|
) {
|
||||||
const updated = yield* db
|
const updated = yield* db
|
||||||
.update(SessionContextEpochTable)
|
.update(SessionContextEpochTable)
|
||||||
.set({ snapshot, revision: expectedRevision + 1 })
|
.set({ snapshot })
|
||||||
.where(
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
and(
|
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||||
eq(SessionContextEpochTable.session_id, sessionID),
|
|
||||||
eq(SessionContextEpochTable.revision, expectedRevision),
|
|
||||||
isNull(SessionContextEpochTable.replacement_seq),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning({ revision: SessionContextEpochTable.revision })
|
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ const Base = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
sync: {
|
durable: {
|
||||||
aggregate: "sessionID",
|
aggregate: "sessionID",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
const stepSettlementOptions = {
|
const stepSettlementOptions = {
|
||||||
sync: {
|
durable: {
|
||||||
aggregate: "sessionID",
|
aggregate: "sessionID",
|
||||||
version: 2,
|
version: 2,
|
||||||
},
|
},
|
||||||
@@ -118,13 +118,6 @@ export namespace PromptLifecycle {
|
|||||||
export type Promoted = typeof Promoted.Type
|
export type Promoted = typeof Promoted.Type
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InterruptRequested = EventV2.define({
|
|
||||||
type: "session.next.interrupt.requested",
|
|
||||||
...options,
|
|
||||||
schema: Base,
|
|
||||||
})
|
|
||||||
export type InterruptRequested = typeof InterruptRequested.Type
|
|
||||||
|
|
||||||
export const ContextUpdated = EventV2.define({
|
export const ContextUpdated = EventV2.define({
|
||||||
type: "session.next.context.updated",
|
type: "session.next.context.updated",
|
||||||
...options,
|
...options,
|
||||||
@@ -456,7 +449,7 @@ export namespace Compaction {
|
|||||||
|
|
||||||
export const Ended = EventV2.define({
|
export const Ended = EventV2.define({
|
||||||
type: "session.next.compaction.ended",
|
type: "session.next.compaction.ended",
|
||||||
sync: { aggregate: "sessionID", version: 2 },
|
durable: { aggregate: "sessionID", version: 2 },
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
messageID: SessionMessageID.ID,
|
messageID: SessionMessageID.ID,
|
||||||
@@ -475,7 +468,6 @@ const DurableDefinitions = [
|
|||||||
Prompted,
|
Prompted,
|
||||||
PromptLifecycle.Admitted,
|
PromptLifecycle.Admitted,
|
||||||
PromptLifecycle.Promoted,
|
PromptLifecycle.Promoted,
|
||||||
InterruptRequested,
|
|
||||||
ContextUpdated,
|
ContextUpdated,
|
||||||
Synthetic,
|
Synthetic,
|
||||||
Shell.Started,
|
Shell.Started,
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { SessionRunner } from "./runner/index"
|
|||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
|
|
||||||
export interface Interface {
|
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>
|
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||||
/** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */
|
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||||
readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void, SessionRunner.RunError>
|
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||||
readonly interrupt: (sessionID: SessionSchema.ID, 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. */
|
/** 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 { LocationServiceMap } from "../../location-layer"
|
||||||
import { SessionRunCoordinator } from "../run-coordinator"
|
import { SessionRunCoordinator } from "../run-coordinator"
|
||||||
import { SessionRunner } from "../runner"
|
import { SessionRunner } from "../runner"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
import { SessionStore } from "../store"
|
import { SessionStore } from "../store"
|
||||||
import { SessionExecution } from "../execution"
|
import { SessionExecution } from "../execution"
|
||||||
import { logFailure } from "../logging"
|
|
||||||
|
|
||||||
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
@@ -13,15 +12,19 @@ export const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const locations = yield* LocationServiceMap
|
const locations = yield* LocationServiceMap
|
||||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, void, SessionRunner.RunError>({
|
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
||||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) {
|
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||||
const session = yield* store.get(sessionID)
|
const session = yield* store.get(sessionID)
|
||||||
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
|
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
|
||||||
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe(
|
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe(
|
||||||
Effect.provide(locations.get(session.location)),
|
Effect.provide(locations.get(session.location)),
|
||||||
|
Effect.tapCause((cause) =>
|
||||||
|
Cause.hasInterruptsOnly(cause)
|
||||||
|
? Effect.void
|
||||||
|
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return SessionExecution.Service.of({
|
return SessionExecution.Service.of({
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ type DatabaseService = Database.Interface["db"]
|
|||||||
|
|
||||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
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
|
return yield* db
|
||||||
.select()
|
.select({ seq: SessionMessageTable.seq })
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||||
.orderBy(desc(SessionMessageTable.seq))
|
.orderBy(desc(SessionMessageTable.seq))
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
|||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.flatMap((event) =>
|
Effect.flatMap((event) =>
|
||||||
event.seq === undefined
|
event.durable === undefined
|
||||||
? Effect.die("Prompt admission event is missing aggregate sequence")
|
? Effect.die("Prompt admission event is missing aggregate sequence")
|
||||||
: Effect.succeed(
|
: Effect.succeed(
|
||||||
new Admitted({
|
new Admitted({
|
||||||
admittedSeq: event.seq,
|
admittedSeq: event.durable.seq,
|
||||||
id: input.id,
|
id: input.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
@@ -117,13 +117,6 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
|||||||
readonly timeCreated: DateTime.Utc
|
readonly timeCreated: DateTime.Utc
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const message = yield* db
|
|
||||||
.select({ id: SessionMessageTable.id })
|
|
||||||
.from(SessionMessageTable)
|
|
||||||
.where(eq(SessionMessageTable.id, input.id))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
|
||||||
const stored = yield* db
|
const stored = yield* db
|
||||||
.insert(SessionInputTable)
|
.insert(SessionInputTable)
|
||||||
.values({
|
.values({
|
||||||
@@ -208,37 +201,6 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS
|
|||||||
input.sessionID === expected.sessionID &&
|
input.sessionID === expected.sessionID &&
|
||||||
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
|
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
|
||||||
|
|
||||||
export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
event: EventV2.Payload,
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
Schema.is(SessionEvent.PromptLifecycle.Admitted)(event) ||
|
|
||||||
Schema.is(SessionEvent.PromptLifecycle.Promoted)(event)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
const id = reservedID(event)
|
|
||||||
if (id === undefined) return
|
|
||||||
const admitted = yield* db
|
|
||||||
.select({ id: SessionInputTable.id })
|
|
||||||
.from(SessionInputTable)
|
|
||||||
.where(eq(SessionInputTable.id, id))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (admitted === undefined) return
|
|
||||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
|
||||||
})
|
|
||||||
|
|
||||||
const reservedID = (event: EventV2.Payload) => {
|
|
||||||
if (Schema.is(SessionEvent.Step.Started)(event)) return event.data.assistantMessageID
|
|
||||||
if (Schema.is(SessionEvent.AgentSwitched)(event)) return event.data.messageID
|
|
||||||
if (Schema.is(SessionEvent.ModelSwitched)(event)) return event.data.messageID
|
|
||||||
if (Schema.is(SessionEvent.Prompted)(event)) return event.data.messageID
|
|
||||||
if (Schema.is(SessionEvent.Synthetic)(event)) return event.data.messageID
|
|
||||||
if (Schema.is(SessionEvent.Shell.Started)(event)) return event.data.messageID
|
|
||||||
if (Schema.is(SessionEvent.Compaction.Started)(event)) return event.data.messageID
|
|
||||||
}
|
|
||||||
|
|
||||||
export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* (
|
export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -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.admitted": () => Effect.void,
|
||||||
"session.next.prompt.promoted": () => Effect.void,
|
"session.next.prompt.promoted": () => Effect.void,
|
||||||
"session.next.interrupt.requested": () => Effect.void,
|
|
||||||
"session.next.context.updated": (event) =>
|
"session.next.context.updated": (event) =>
|
||||||
adapter.appendMessage(
|
adapter.appendMessage(
|
||||||
new SessionMessage.System({
|
new SessionMessage.System({
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
|
|||||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||||
const updateMessage = (message: SessionMessage.Message) => {
|
const updateMessage = (message: SessionMessage.Message) => {
|
||||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
const encoded = encodeMessage(message)
|
const encoded = encodeMessage(message)
|
||||||
const { id, type, ...data } = encoded
|
const { id, type, ...data } = encoded
|
||||||
return db
|
return db
|
||||||
@@ -192,7 +192,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) {
|
function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) {
|
||||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
const encoded = encodeMessage(message)
|
const encoded = encodeMessage(message)
|
||||||
const { id, type, ...data } = encoded
|
const { id, type, ...data } = encoded
|
||||||
return db
|
return db
|
||||||
@@ -201,7 +201,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message:
|
|||||||
id: SessionMessage.ID.make(id),
|
id: SessionMessage.ID.make(id),
|
||||||
session_id: event.data.sessionID,
|
session_id: event.data.sessionID,
|
||||||
type,
|
type,
|
||||||
seq: event.seq,
|
seq: event.durable.seq,
|
||||||
time_created: DateTime.toEpochMillis(message.time.created),
|
time_created: DateTime.toEpochMillis(message.time.created),
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
@@ -213,7 +213,6 @@ export const layer = Layer.effectDiscard(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
yield* events.beforeCommit((event) => SessionInput.guardReservedID(db, event))
|
|
||||||
yield* events.project(SessionV1.Event.Created, (event) =>
|
yield* events.project(SessionV1.Event.Created, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const stored = yield* db
|
const stored = yield* db
|
||||||
@@ -330,19 +329,14 @@ export const layer = Layer.effectDiscard(
|
|||||||
if (next) yield* applyUsage(db, sessionID, next)
|
if (next) yield* applyUsage(db, sessionID, next)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.AgentSwitched, (event) => {
|
yield* events.project(SessionEvent.AgentSwitched, (event) =>
|
||||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
db
|
||||||
return db
|
|
||||||
.update(SessionTable)
|
.update(SessionTable)
|
||||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
.pipe(
|
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||||
Effect.orDie,
|
)
|
||||||
Effect.andThen(run(db, event)),
|
|
||||||
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* db
|
yield* db
|
||||||
@@ -352,9 +346,6 @@ export const layer = Layer.effectDiscard(
|
|||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* run(db, event)
|
yield* run(db, event)
|
||||||
if (event.seq === undefined)
|
|
||||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
|
||||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||||
@@ -368,24 +359,22 @@ export const layer = Layer.effectDiscard(
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
|
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
|
||||||
yield* run(db, event)
|
yield* run(db, event)
|
||||||
if (event.seq === undefined)
|
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
|
||||||
yield* SessionInput.projectLegacyPrompted(db, {
|
yield* SessionInput.projectLegacyPrompted(db, {
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
prompt: event.data.prompt,
|
prompt: event.data.prompt,
|
||||||
delivery: event.data.delivery,
|
delivery: event.data.delivery,
|
||||||
timeCreated: event.data.timestamp,
|
timeCreated: event.data.timestamp,
|
||||||
promotedSeq: event.seq,
|
promotedSeq: event.durable.seq,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) =>
|
yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.seq === undefined)
|
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
|
||||||
yield* SessionInput.projectAdmitted(db, {
|
yield* SessionInput.projectAdmitted(db, {
|
||||||
admittedSeq: event.seq,
|
admittedSeq: event.durable.seq,
|
||||||
id: event.data.messageID,
|
id: event.data.messageID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
prompt: event.data.prompt,
|
prompt: event.data.prompt,
|
||||||
@@ -396,8 +385,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) =>
|
yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.seq === undefined)
|
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
|
||||||
yield* insertMessage(
|
yield* insertMessage(
|
||||||
db,
|
db,
|
||||||
event,
|
event,
|
||||||
@@ -406,18 +394,12 @@ export const layer = Layer.effectDiscard(
|
|||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
prompt: event.data.prompt,
|
prompt: event.data.prompt,
|
||||||
timeCreated: event.data.timeCreated,
|
timeCreated: event.data.timeCreated,
|
||||||
promotedSeq: event.seq,
|
promotedSeq: event.durable.seq,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
|
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.ContextUpdated, (event) => {
|
|
||||||
if (!event.replay || event.seq === undefined) return run(db, event)
|
|
||||||
return run(db, event).pipe(
|
|
||||||
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||||
@@ -435,15 +417,9 @@ export const layer = Layer.effectDiscard(
|
|||||||
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
|
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
||||||
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
|
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
|
||||||
if (event.version === 1) return Effect.void
|
event.durable?.version === 1 ? Effect.void : run(db, event),
|
||||||
const seq = event.seq
|
)
|
||||||
if (seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* run(db, event)
|
|
||||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,106 +1,43 @@
|
|||||||
export * as SessionRunCoordinator from "./run-coordinator"
|
export * as SessionRunCoordinator from "./run-coordinator"
|
||||||
|
|
||||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
|
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||||
import { SessionRunner } from "./runner"
|
|
||||||
import { logFailure } from "./logging"
|
|
||||||
import { SessionSchema } from "./schema"
|
|
||||||
|
|
||||||
export type Mode = "run" | "wake"
|
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||||
|
export interface Coordinator<Key, E> {
|
||||||
/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */
|
/** Starts execution while idle or joins the active execution. */
|
||||||
type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number }
|
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||||
|
/** Registers one coalesced follow-up after newly recorded work. */
|
||||||
/**
|
readonly wake: (key: Key) => Effect.Effect<void>
|
||||||
* Runs at most one drain chain per key while allowing different keys to drain concurrently.
|
/** Stops active execution and waits for its cleanup. */
|
||||||
*
|
readonly interrupt: (key: Key) => Effect.Effect<void>
|
||||||
* For each key:
|
|
||||||
*
|
|
||||||
* idle --run/wake--> draining --run/wake--> draining + one coalesced rerun --> idle
|
|
||||||
*
|
|
||||||
* `run` is an explicit drain request. It starts a chain or joins the current chain and
|
|
||||||
* upgrades a pending follow-up so the caller receives explicit-run semantics.
|
|
||||||
*
|
|
||||||
* `wake` reports that durable work may now be available. It starts a chain while idle or
|
|
||||||
* requests one coalesced follow-up while draining. Repeated wakes collapse together.
|
|
||||||
*
|
|
||||||
* `interrupt` stops the current ownership chain. Advisory wakes from before the interrupt
|
|
||||||
* boundary are suppressed; advisory wakes after the boundary run after cleanup.
|
|
||||||
*/
|
|
||||||
export interface Coordinator<Key, A, E> {
|
|
||||||
/** Starts or joins one explicit drain generation. */
|
|
||||||
readonly run: (key: Key) => Effect.Effect<A, E>
|
|
||||||
/** Coalesces one wake-up after durable work is recorded. */
|
|
||||||
readonly wake: (key: Key, seq?: number) => Effect.Effect<void>
|
|
||||||
/** Waits until the current ownership chain settles. */
|
|
||||||
readonly awaitIdle: (key: Key) => Effect.Effect<void, E>
|
|
||||||
/** Interrupts the active ownership chain without automatically draining pending wakes. */
|
|
||||||
readonly interrupt: (key: Key, seq?: number) => Effect.Effect<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */
|
type Entry<E> = {
|
||||||
type Entry<A, E> = {
|
readonly done: Deferred.Deferred<void, E>
|
||||||
readonly done: Deferred.Deferred<A, E>
|
|
||||||
readonly settled: Deferred.Deferred<Exit.Exit<A, E>>
|
|
||||||
current: Demand
|
|
||||||
pending?: Demand
|
|
||||||
explicitWaiter?: Deferred.Deferred<A, E>
|
|
||||||
interruptSeq?: number
|
|
||||||
owner?: Fiber.Fiber<void, never>
|
owner?: Fiber.Fiber<void, never>
|
||||||
|
pendingWake: boolean
|
||||||
stopping: boolean
|
stopping: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */
|
export const make = <Key, E>(options: {
|
||||||
const coalesce = (left: Demand | undefined, right: Demand): Demand => {
|
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||||
if (left?._tag === "run" || right._tag === "run") return { _tag: "run" }
|
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
|
||||||
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> =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const active = new Map<Key, Entry<A, E>>()
|
const active = new Map<Key, Entry<E>>()
|
||||||
const interruptSeq = new Map<Key, number>()
|
|
||||||
const report = yield* FiberSet.makeRuntime<never, void, never>()
|
|
||||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||||
const shutdown = Deferred.makeUnsafe<void>()
|
|
||||||
let closed = false
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
closed = true
|
|
||||||
Deferred.doneUnsafe(shutdown, Effect.void)
|
|
||||||
active.clear()
|
|
||||||
interruptSeq.clear()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred<A, E>): Entry<A, E> => ({
|
const makeEntry = (): Entry<E> => ({
|
||||||
done: Deferred.makeUnsafe<A, E>(),
|
done: Deferred.makeUnsafe<void, E>(),
|
||||||
settled: Deferred.makeUnsafe<Exit.Exit<A, E>>(),
|
pendingWake: false,
|
||||||
current,
|
|
||||||
explicitWaiter,
|
|
||||||
stopping: 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 ready = Deferred.makeUnsafe<void>()
|
||||||
const drain = Effect.suspend(() => options.drain(key, demand._tag))
|
|
||||||
// Initial work retains immediate-start behavior but cannot run before ownership is published.
|
|
||||||
// Observer-started successors yield once so synchronous drains cannot recurse on the JS stack.
|
|
||||||
const owner = fork(
|
const owner = fork(
|
||||||
(successor
|
(successor ? Effect.yieldNow : Deferred.await(ready)).pipe(
|
||||||
? Effect.yieldNow.pipe(Effect.andThen(drain))
|
Effect.andThen(Effect.suspend(() => options.drain(key, force))),
|
||||||
: Deferred.await(ready).pipe(Effect.andThen(drain))
|
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))),
|
||||||
).pipe(
|
|
||||||
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))),
|
|
||||||
Effect.exit,
|
Effect.exit,
|
||||||
Effect.asVoid,
|
Effect.asVoid,
|
||||||
),
|
),
|
||||||
@@ -109,176 +46,57 @@ export const make = <Key, A, E>(options: {
|
|||||||
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
|
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
|
||||||
}
|
}
|
||||||
|
|
||||||
const settle = (key: Key, entry: Entry<A, E>, demand: Demand, exit: Exit.Exit<A, E>) => {
|
const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => {
|
||||||
if (closed) {
|
if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) {
|
||||||
Deferred.doneUnsafe(entry.done, exit)
|
entry.pendingWake = false
|
||||||
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
|
start(key, entry, false, true)
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined
|
const successor = entry.pendingWake ? makeEntry() : undefined
|
||||||
if (successor === undefined) active.delete(key)
|
if (successor === undefined) active.delete(key)
|
||||||
else active.set(key, successor)
|
else {
|
||||||
if (successor !== undefined) start(key, successor, successor.current, true)
|
active.set(key, successor)
|
||||||
Deferred.doneUnsafe(entry.done, exit)
|
start(key, successor, false, true)
|
||||||
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)))
|
|
||||||
}
|
}
|
||||||
|
Deferred.doneUnsafe(entry.done, exit)
|
||||||
}
|
}
|
||||||
|
|
||||||
const wake = (key: Key, seq?: number) =>
|
const run = (key: Key): Effect.Effect<void, E> =>
|
||||||
Effect.sync(() => {
|
Effect.uninterruptibleMask((restore) => {
|
||||||
if (closed) return
|
|
||||||
if (!isAfterInterrupt(key, seq)) return
|
|
||||||
const entry = active.get(key)
|
const entry = active.get(key)
|
||||||
if (entry !== undefined) {
|
if (entry !== undefined) {
|
||||||
if (!acceptsWake(entry, seq)) return
|
if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
|
||||||
entry.pending = coalesce(entry.pending, { _tag: "wake", seq })
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = makeEntry({ _tag: "wake", seq })
|
const next = makeEntry()
|
||||||
active.set(key, next)
|
active.set(key, next)
|
||||||
start(key, next, next.current)
|
start(key, next, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
const awaitIdle = (key: Key): Effect.Effect<void, E> =>
|
const interrupt = (key: Key): Effect.Effect<void> =>
|
||||||
Effect.gen(function* () {
|
|
||||||
let firstFailure: Cause.Cause<E> | undefined
|
|
||||||
while (!closed) {
|
|
||||||
const entry = active.get(key)
|
|
||||||
if (entry === undefined) break
|
|
||||||
const exit = yield* Effect.raceFirst(
|
|
||||||
Deferred.await(entry.settled),
|
|
||||||
Deferred.await(shutdown).pipe(Effect.as(Exit.void)),
|
|
||||||
)
|
|
||||||
if (closed) break
|
|
||||||
if (exit._tag === "Failure" && firstFailure === undefined) firstFailure = exit.cause
|
|
||||||
}
|
|
||||||
if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure)
|
|
||||||
})
|
|
||||||
|
|
||||||
const interrupt = (key: Key, seq?: number): Effect.Effect<void> =>
|
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
const entry = active.get(key)
|
const entry = active.get(key)
|
||||||
const latest = interruptSeq.get(key)
|
|
||||||
if (seq !== undefined && latest !== undefined && seq <= latest)
|
|
||||||
return entry?.stopping && entry.owner !== undefined ? Fiber.interrupt(entry.owner) : Effect.void
|
|
||||||
if (seq !== undefined) interruptSeq.set(key, seq)
|
|
||||||
if (entry?.owner === undefined) return Effect.void
|
if (entry?.owner === undefined) return Effect.void
|
||||||
if (
|
|
||||||
seq !== undefined &&
|
|
||||||
entry.current._tag === "wake" &&
|
|
||||||
entry.current.seq !== undefined &&
|
|
||||||
entry.current.seq > seq
|
|
||||||
)
|
|
||||||
return Effect.void
|
|
||||||
if (entry.stopping) {
|
|
||||||
entry.interruptSeq = maxSeq(entry.interruptSeq, seq)
|
|
||||||
suppressPendingAtOrBefore(entry, seq)
|
|
||||||
return Fiber.interrupt(entry.owner)
|
|
||||||
}
|
|
||||||
entry.stopping = true
|
entry.stopping = true
|
||||||
entry.interruptSeq = seq
|
entry.pendingWake = false
|
||||||
suppressPendingAtOrBefore(entry, seq)
|
|
||||||
return Fiber.interrupt(entry.owner)
|
return Fiber.interrupt(entry.owner)
|
||||||
})
|
})
|
||||||
|
|
||||||
return { run, wake, awaitIdle, interrupt }
|
return { run, wake, interrupt }
|
||||||
|
|
||||||
function run(key: Key): Effect.Effect<A, E> {
|
|
||||||
return Effect.uninterruptibleMask((restore) => {
|
|
||||||
if (closed) return Effect.interrupt
|
|
||||||
const entry = active.get(key)
|
|
||||||
if (entry !== undefined) {
|
|
||||||
if (entry.stopping) {
|
|
||||||
return restore(Deferred.await(entry.settled).pipe(Effect.andThen(run(key))))
|
|
||||||
}
|
|
||||||
if (entry.current._tag === "wake") {
|
|
||||||
entry.pending = coalesce(entry.pending, { _tag: "run" })
|
|
||||||
entry.explicitWaiter ??= Deferred.makeUnsafe<A, E>()
|
|
||||||
return restore(awaitRun(entry.explicitWaiter))
|
|
||||||
}
|
|
||||||
return restore(awaitRun(entry.done))
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = makeEntry({ _tag: "run" })
|
|
||||||
active.set(key, next)
|
|
||||||
start(key, next, next.current)
|
|
||||||
return restore(awaitRun(next.done))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function awaitRun(done: Deferred.Deferred<A, E>): Effect.Effect<A, E> {
|
|
||||||
return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptsWake(entry: Entry<A, E>, seq: number | undefined) {
|
|
||||||
return !entry.stopping || (entry.interruptSeq !== undefined && seq !== undefined && seq > entry.interruptSeq)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAfterInterrupt(key: Key, seq: number | undefined) {
|
|
||||||
const latest = interruptSeq.get(key)
|
|
||||||
return latest === undefined || (seq !== undefined && seq > latest)
|
|
||||||
}
|
|
||||||
|
|
||||||
function suppressPendingAtOrBefore(entry: Entry<A, E>, seq: number | undefined) {
|
|
||||||
if (
|
|
||||||
entry.pending?._tag === "wake" &&
|
|
||||||
seq !== undefined &&
|
|
||||||
entry.pending.seq !== undefined &&
|
|
||||||
entry.pending.seq > seq
|
|
||||||
)
|
|
||||||
return
|
|
||||||
entry.pending = undefined
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface Interface extends Coordinator<SessionSchema.ID, void, SessionRunner.RunError> {}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunCoordinator") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
SessionRunner.Service.pipe(
|
|
||||||
Effect.flatMap((runner) =>
|
|
||||||
make<SessionSchema.ID, void, SessionRunner.RunError>({
|
|
||||||
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
|
|
||||||
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.map(Service.of),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { SessionSchema } from "../schema"
|
|||||||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||||
import { SessionRunnerModel } from "./model"
|
import { SessionRunnerModel } from "./model"
|
||||||
import type { SystemContext } from "../../system-context/index"
|
import type { SystemContext } from "../../system-context/index"
|
||||||
import type { SessionContextEpoch } from "../context-epoch"
|
|
||||||
import type { ToolOutputStore } from "../../tool-output-store"
|
import type { ToolOutputStore } from "../../tool-output-store"
|
||||||
|
|
||||||
export type RunError =
|
export type RunError =
|
||||||
@@ -15,7 +14,6 @@ export type RunError =
|
|||||||
| MessageDecodeError
|
| MessageDecodeError
|
||||||
| ContextSnapshotDecodeError
|
| ContextSnapshotDecodeError
|
||||||
| SystemContext.InitializationBlocked
|
| SystemContext.InitializationBlocked
|
||||||
| SessionContextEpoch.AgentReplacementBlocked
|
|
||||||
| ToolOutputStore.Error
|
| ToolOutputStore.Error
|
||||||
|
|
||||||
/** Runs one local continuation from already-recorded Session history. */
|
/** Runs one local continuation from already-recorded Session history. */
|
||||||
@@ -23,7 +21,7 @@ export interface Interface {
|
|||||||
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */
|
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */
|
||||||
readonly run: (input: {
|
readonly run: (input: {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly force?: boolean
|
readonly force: boolean
|
||||||
}) => Effect.Effect<void, RunError>
|
}) => Effect.Effect<void, RunError>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
isContextOverflowFailure,
|
isContextOverflowFailure,
|
||||||
type ProviderErrorEvent,
|
type ProviderErrorEvent,
|
||||||
} from "@opencode-ai/llm"
|
} from "@opencode-ai/llm"
|
||||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||||
import { AgentV2 } from "../../agent"
|
import { AgentV2 } from "../../agent"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { Database } from "../../database/database"
|
import { Database } from "../../database/database"
|
||||||
@@ -141,8 +141,8 @@ export const layer = Layer.effect(
|
|||||||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
||||||
|
|
||||||
type TurnTransition =
|
type TurnTransition =
|
||||||
// Request preparation observed a concurrent Session change and must restart from durable state.
|
// Automatic compaction completed; rebuild the request from compacted history.
|
||||||
| { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery }
|
| { readonly _tag: "ContinueAfterCompaction" }
|
||||||
// Overflow compaction completed; rebuild once through the path without overflow recovery.
|
// Overflow compaction completed; rebuild once through the path without overflow recovery.
|
||||||
| { readonly _tag: "ContinueAfterOverflowCompaction" }
|
| { readonly _tag: "ContinueAfterOverflowCompaction" }
|
||||||
|
|
||||||
@@ -152,20 +152,11 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) =>
|
const continueAfterCompaction = new TurnTransitionError({ _tag: "ContinueAfterCompaction" })
|
||||||
new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion })
|
|
||||||
const continueAfterOverflowCompaction = new TurnTransitionError({
|
const continueAfterOverflowCompaction = new TurnTransitionError({
|
||||||
_tag: "ContinueAfterOverflowCompaction",
|
_tag: "ContinueAfterOverflowCompaction",
|
||||||
})
|
})
|
||||||
|
|
||||||
const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) =>
|
|
||||||
Effect.catchDefect((defect) =>
|
|
||||||
defect instanceof SessionContextEpoch.AgentMismatch
|
|
||||||
? Effect.die(rebuildPreparedTurn(promotion))
|
|
||||||
: Effect.die(defect),
|
|
||||||
)
|
|
||||||
|
|
||||||
const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref))
|
|
||||||
const loadSystemContext = (agent: AgentV2.Selection) =>
|
const loadSystemContext = (agent: AgentV2.Selection) =>
|
||||||
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
|
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
@@ -181,13 +172,7 @@ export const layer = Layer.effect(
|
|||||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||||
return yield* Effect.interrupt
|
return yield* Effect.interrupt
|
||||||
const agent = yield* agents.select(session.agent)
|
const agent = yield* agents.select(session.agent)
|
||||||
const initialized = yield* SessionContextEpoch.initialize(
|
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
|
||||||
db,
|
|
||||||
loadSystemContext(agent),
|
|
||||||
session.id,
|
|
||||||
session.location,
|
|
||||||
agent.id,
|
|
||||||
).pipe(retryAgentMismatch(promotion))
|
|
||||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||||
let needsContinuation = false
|
let needsContinuation = false
|
||||||
if (promotion) {
|
if (promotion) {
|
||||||
@@ -199,18 +184,7 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const system =
|
const system =
|
||||||
initialized ??
|
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
|
||||||
(yield* SessionContextEpoch.prepare(
|
|
||||||
db,
|
|
||||||
events,
|
|
||||||
loadSystemContext(agent),
|
|
||||||
session.id,
|
|
||||||
session.location,
|
|
||||||
agent.id,
|
|
||||||
).pipe(retryAgentMismatch(undefined)))
|
|
||||||
const current = yield* getSession(sessionID)
|
|
||||||
if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model))
|
|
||||||
return yield* Effect.die(rebuildPreparedTurn())
|
|
||||||
const model = yield* models.resolve(session)
|
const model = yield* models.resolve(session)
|
||||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||||
const context = entries.map((entry) => entry.message)
|
const context = entries.map((entry) => entry.message)
|
||||||
@@ -228,7 +202,7 @@ export const layer = Layer.effect(
|
|||||||
toolChoice: isLastStep ? "none" : undefined,
|
toolChoice: isLastStep ? "none" : undefined,
|
||||||
})
|
})
|
||||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||||
return yield* Effect.die(rebuildPreparedTurn())
|
return yield* Effect.die(continueAfterCompaction)
|
||||||
const publisher = createLLMEventPublisher(events, {
|
const publisher = createLLMEventPublisher(events, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
@@ -242,8 +216,6 @@ export const layer = Layer.effect(
|
|||||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
||||||
withPublication(publisher.publish(event, outputPaths))
|
withPublication(publisher.publish(event, outputPaths))
|
||||||
let overflowFailure: ProviderErrorEvent | undefined
|
let overflowFailure: ProviderErrorEvent | undefined
|
||||||
if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision)))
|
|
||||||
return yield* Effect.die(rebuildPreparedTurn())
|
|
||||||
const providerStream = llm.stream(request).pipe(
|
const providerStream = llm.stream(request).pipe(
|
||||||
Stream.runForEach((event) =>
|
Stream.runForEach((event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -305,14 +277,7 @@ export const layer = Layer.effect(
|
|||||||
const llmFailure = failure instanceof LLMError ? failure : undefined
|
const llmFailure = failure instanceof LLMError ? failure : undefined
|
||||||
if (llmFailure && !publisher.hasProviderError()) {
|
if (llmFailure && !publisher.hasProviderError()) {
|
||||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||||
yield* withPublication(
|
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
|
||||||
events.publish(SessionEvent.Step.Failed, {
|
|
||||||
sessionID: session.id,
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
assistantMessageID: yield* publisher.startAssistant(),
|
|
||||||
error: { type: "unknown", message: llmFailure.reason.message },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||||
@@ -327,6 +292,8 @@ export const layer = Layer.effect(
|
|||||||
) {
|
) {
|
||||||
yield* FiberSet.clear(toolFibers)
|
yield* FiberSet.clear(toolFibers)
|
||||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||||
|
if (publisher.hasActiveAssistant())
|
||||||
|
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||||
}
|
}
|
||||||
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
|
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
|
||||||
const failure = Cause.squash(settled.cause)
|
const failure = Cause.squash(settled.cause)
|
||||||
@@ -357,7 +324,7 @@ export const layer = Layer.effect(
|
|||||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||||
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
|
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
|
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -371,7 +338,7 @@ export const layer = Layer.effect(
|
|||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||||
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
|
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
|
||||||
return yield* runTurn(sessionID, 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: {
|
const run = Effect.fn("SessionRunner.run")(function* (input: {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly force?: boolean
|
readonly force: boolean
|
||||||
}) {
|
}) {
|
||||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||||
if (input.force !== true && !hasSteer && !hasQueue) return
|
if (!input.force && !hasSteer && !hasQueue) return
|
||||||
yield* failInterruptedTools(input.sessionID)
|
yield* failInterruptedTools(input.sessionID)
|
||||||
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||||
let openActivity = input.force === true || hasSteer || hasQueue
|
let openActivity = input.force || hasSteer || hasQueue
|
||||||
while (openActivity) {
|
while (openActivity) {
|
||||||
let needsContinuation = true
|
let needsContinuation = true
|
||||||
for (let step = 1; needsContinuation; step++) {
|
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 OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
|
||||||
import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
|
import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
|
||||||
import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
|
import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
|
||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { produce } from "immer"
|
import { produce } from "immer"
|
||||||
import { Catalog } from "../../catalog"
|
import { Catalog } from "../../catalog"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
@@ -24,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>()(
|
export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
|
||||||
"SessionRunnerModel.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 {
|
export interface Interface {
|
||||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
|
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. */
|
/** Test or embedding seam for supplying a model resolver directly. */
|
||||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||||
|
|
||||||
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
|
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => {
|
||||||
if (credential?.value.type === "key") return Auth.value(credential.value.key)
|
if (credential?.value.type === "key") return Auth.value(credential.value.key)
|
||||||
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
|
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
|
||||||
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
||||||
@@ -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 id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
|
||||||
const variant = model.variants.find((item) => item.id === id)
|
const variant = model.variants.find((item) => item.id === id)
|
||||||
if (!variant) return model
|
if (!variant && variantID !== undefined && variantID !== "default")
|
||||||
return produce(model, (draft) => {
|
return Effect.fail(
|
||||||
ModelRequest.assign(draft.request, variant)
|
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) =>
|
const apiName = (model: ModelV2.Info) =>
|
||||||
@@ -85,7 +116,7 @@ const apiName = (model: ModelV2.Info) =>
|
|||||||
export const fromCatalogModel = (
|
export const fromCatalogModel = (
|
||||||
model: ModelV2.Info,
|
model: ModelV2.Info,
|
||||||
connection?: IntegrationConnection.Info,
|
connection?: IntegrationConnection.Info,
|
||||||
credential?: Credential.Stored,
|
credential?: Credential.Info,
|
||||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||||
const resolved =
|
const resolved =
|
||||||
credential?.value.metadata === undefined
|
credential?.value.metadata === undefined
|
||||||
@@ -124,8 +155,15 @@ export const fromCatalogModel = (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
|
export const resolve = (
|
||||||
fromCatalogModel(withVariant(model, session.model?.variant))
|
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) =>
|
export const supported = (model: ModelV2.Info) =>
|
||||||
model.api.type === "aisdk" &&
|
model.api.type === "aisdk" &&
|
||||||
@@ -147,14 +185,22 @@ export const locationLayer = Layer.effect(
|
|||||||
yield* boot.wait()
|
yield* boot.wait()
|
||||||
const defaultModel = session.model ? undefined : yield* catalog.model.default()
|
const defaultModel = session.model ? undefined : yield* catalog.model.default()
|
||||||
const selected = session.model
|
const selected = session.model
|
||||||
? yield* catalog.model.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 && supported(defaultModel)
|
||||||
? defaultModel
|
? defaultModel
|
||||||
: (yield* catalog.model.available()).find(supported)
|
: (yield* catalog.model.available()).find(supported)
|
||||||
|
if (!selected && session.model)
|
||||||
|
return yield* new ModelUnavailableError({
|
||||||
|
providerID: session.model.providerID,
|
||||||
|
modelID: session.model.id,
|
||||||
|
})
|
||||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||||
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
||||||
return yield* fromCatalogModel(
|
return yield* resolve(
|
||||||
withVariant(selected, session.model?.variant),
|
session,
|
||||||
|
selected,
|
||||||
connection,
|
connection,
|
||||||
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
|
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
|
const timestamp = DateTime.now
|
||||||
let assistantMessageID: SessionMessage.ID | undefined
|
let assistantMessageID: SessionMessage.ID | undefined
|
||||||
|
let assistantActive = false
|
||||||
|
let assistantFailed = false
|
||||||
let providerFailed = false
|
let providerFailed = false
|
||||||
|
|
||||||
const startAssistant = Effect.fnUntraced(function* () {
|
const startAssistant = Effect.fnUntraced(function* () {
|
||||||
if (assistantMessageID !== undefined) return assistantMessageID
|
if (assistantMessageID !== undefined) return assistantMessageID
|
||||||
assistantMessageID = SessionMessage.ID.create()
|
assistantMessageID = SessionMessage.ID.create()
|
||||||
|
assistantActive = true
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
...input,
|
...input,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
@@ -190,6 +193,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||||||
yield* flushFragments()
|
yield* flushFragments()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const failAssistant = Effect.fnUntraced(function* (message: string) {
|
||||||
|
if (assistantFailed) return
|
||||||
|
yield* flush()
|
||||||
|
const assistantMessageID = yield* startAssistant()
|
||||||
|
assistantActive = false
|
||||||
|
assistantFailed = true
|
||||||
|
yield* events.publish(SessionEvent.Step.Failed, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
timestamp: yield* timestamp,
|
||||||
|
assistantMessageID,
|
||||||
|
error: { type: "unknown", message },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
||||||
message: string,
|
message: string,
|
||||||
hostedOnly = false,
|
hostedOnly = false,
|
||||||
@@ -375,6 +392,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||||||
}
|
}
|
||||||
case "step-finish":
|
case "step-finish":
|
||||||
yield* flush()
|
yield* flush()
|
||||||
|
assistantActive = false
|
||||||
yield* events.publish(SessionEvent.Step.Ended, {
|
yield* events.publish(SessionEvent.Step.Ended, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
timestamp: yield* timestamp,
|
timestamp: yield* timestamp,
|
||||||
@@ -388,13 +406,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||||||
return
|
return
|
||||||
case "provider-error":
|
case "provider-error":
|
||||||
providerFailed = true
|
providerFailed = true
|
||||||
yield* flush()
|
yield* failAssistant(event.message)
|
||||||
yield* events.publish(SessionEvent.Step.Failed, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
timestamp: yield* timestamp,
|
|
||||||
assistantMessageID: yield* startAssistant(),
|
|
||||||
error: { type: "unknown", message: event.message },
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -402,10 +414,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||||||
return {
|
return {
|
||||||
publish,
|
publish,
|
||||||
flush,
|
flush,
|
||||||
|
failAssistant,
|
||||||
failUnsettledTools,
|
failUnsettledTools,
|
||||||
|
hasActiveAssistant: () => assistantActive,
|
||||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||||
hasProviderError: () => providerFailed,
|
hasProviderError: () => providerFailed,
|
||||||
startAssistant,
|
|
||||||
assistantMessageID: assistantMessageIDForTool,
|
assistantMessageID: assistantMessageIDForTool,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,12 +82,21 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
|||||||
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
|
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
|
||||||
return item.provider?.executed === true && result ? [call, result] : [call]
|
return item.provider?.executed === true && result ? [call, result] : [call]
|
||||||
})
|
})
|
||||||
|
const meaningful = content.filter((part) => {
|
||||||
|
if (part.type === "text") return part.text !== ""
|
||||||
|
if (part.type !== "reasoning") return true
|
||||||
|
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
|
||||||
|
})
|
||||||
const results = message.content
|
const results = message.content
|
||||||
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
||||||
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
|
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
|
||||||
.filter((message) => message !== undefined)
|
.filter((message) => message !== undefined)
|
||||||
.map(Message.tool)
|
.map(Message.tool)
|
||||||
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[] {
|
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
|
||||||
|
|||||||
@@ -170,9 +170,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
|
|||||||
.primaryKey()
|
.primaryKey()
|
||||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||||
baseline: text().notNull(),
|
baseline: text().notNull(),
|
||||||
agent: text().$type<AgentV2.ID>().notNull().default(AgentV2.defaultID),
|
|
||||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
|
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
|
||||||
baseline_seq: integer().notNull(),
|
baseline_seq: integer().notNull(),
|
||||||
replacement_seq: integer(),
|
|
||||||
revision: integer().notNull().default(0),
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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_LENGTH = 2_000
|
||||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||||
|
|
||||||
export class BinaryFileError extends Error {
|
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", {
|
||||||
constructor(readonly resource: string) {
|
resource: Schema.String,
|
||||||
super(`Cannot read binary file: ${resource}`)
|
}) {
|
||||||
this.name = "BinaryFileError"
|
override get message() {
|
||||||
|
return `Cannot read binary file: ${this.resource}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MediaIngestLimitError extends Error {
|
export class MediaIngestLimitError extends Schema.TaggedErrorClass<MediaIngestLimitError>()(
|
||||||
constructor(
|
"ReadTool.MediaIngestLimitError",
|
||||||
readonly resource: string,
|
{
|
||||||
readonly maximumBytes: number,
|
resource: Schema.String,
|
||||||
) {
|
maximumBytes: Schema.Number,
|
||||||
super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`)
|
},
|
||||||
this.name = "MediaIngestLimitError"
|
) {
|
||||||
|
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({
|
export const PageInput = Schema.Struct({
|
||||||
offset: PositiveInt.pipe(Schema.optional),
|
offset: PositiveInt.pipe(Schema.optional),
|
||||||
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
|
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
|
||||||
@@ -52,13 +90,13 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
|||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory">
|
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError>
|
||||||
readonly read: (
|
readonly read: (
|
||||||
path: AbsolutePath,
|
path: AbsolutePath,
|
||||||
resource: string,
|
resource: string,
|
||||||
page?: PageInput,
|
page?: PageInput,
|
||||||
) => Effect.Effect<FileSystem.Content | TextPage>
|
) => Effect.Effect<FileSystem.Content | TextPage, ReadError>
|
||||||
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage>
|
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
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
|
return nonPrintable / bytes.length > 0.3
|
||||||
}
|
}
|
||||||
|
const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) =>
|
||||||
|
Effect.try({
|
||||||
|
try: () => decoder.decode(bytes, { stream: bytes !== undefined }),
|
||||||
|
catch: (error) => {
|
||||||
|
if (error instanceof TypeError) return new MalformedUtf8Error({ resource })
|
||||||
|
throw error
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
||||||
|
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
|
||||||
|
|
||||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
||||||
const info = yield* fs.stat(input).pipe(Effect.orDie)
|
const info = yield* fs.stat(input)
|
||||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||||
if (!type) return yield* Effect.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
|
return type
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -125,32 +173,30 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
resource: string,
|
resource: string,
|
||||||
page: PageInput = {},
|
page: PageInput = {},
|
||||||
) {
|
) {
|
||||||
const real = yield* fs.realPath(input).pipe(Effect.orDie)
|
const real = yield* fs.realPath(input)
|
||||||
return yield* Effect.scoped(
|
return yield* Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = yield* fs.open(real, { flag: "r" }).pipe(Effect.orDie)
|
const file = yield* fs.open(real, { flag: "r" })
|
||||||
const info = yield* file.stat.pipe(Effect.orDie)
|
const info = yield* file.stat
|
||||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" }))
|
||||||
const first = Option.getOrElse(
|
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(),
|
() => new Uint8Array(),
|
||||||
)
|
)
|
||||||
const mime = imageMime(first)
|
const mime = imageMime(first)
|
||||||
if (mime) {
|
if (mime) {
|
||||||
if (info.size > MAX_MEDIA_INGEST_BYTES)
|
if (info.size > MAX_MEDIA_INGEST_BYTES)
|
||||||
return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
|
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
||||||
const chunks = [first]
|
const chunks = [first]
|
||||||
let total = first.length
|
let total = first.length
|
||||||
while (total <= MAX_MEDIA_INGEST_BYTES) {
|
while (total <= MAX_MEDIA_INGEST_BYTES) {
|
||||||
const chunk = yield* file
|
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
|
||||||
.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (Option.isNone(chunk)) break
|
if (Option.isNone(chunk)) break
|
||||||
chunks.push(chunk.value)
|
chunks.push(chunk.value)
|
||||||
total += chunk.value.length
|
total += chunk.value.length
|
||||||
}
|
}
|
||||||
if (total > MAX_MEDIA_INGEST_BYTES)
|
if (total > MAX_MEDIA_INGEST_BYTES)
|
||||||
return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
|
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
||||||
return {
|
return {
|
||||||
uri: pathToFileURL(real).href,
|
uri: pathToFileURL(real).href,
|
||||||
name: path.basename(real),
|
name: path.basename(real),
|
||||||
@@ -162,19 +208,19 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
mime,
|
mime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || binary(resource, first))
|
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || extensions.has(path.extname(resource).toLowerCase()))
|
||||||
return yield* Effect.die(new BinaryFileError(resource))
|
return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||||
if (!paged) {
|
if (!paged) {
|
||||||
|
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||||
const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))]
|
const text = [yield* decodeUtf8(resource, decoder, first)]
|
||||||
while (true) {
|
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 (Option.isNone(chunk)) break
|
||||||
if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(resource))
|
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
||||||
text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true })))
|
|
||||||
}
|
}
|
||||||
text.push(yield* Effect.sync(() => decoder.decode()))
|
text.push(yield* decodeUtf8(resource, decoder))
|
||||||
return {
|
return {
|
||||||
uri: pathToFileURL(real).href,
|
uri: pathToFileURL(real).href,
|
||||||
name: path.basename(real),
|
name: path.basename(real),
|
||||||
@@ -191,34 +237,29 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
let discard = false
|
let discard = false
|
||||||
let line = 1
|
let line = 1
|
||||||
let bytes = 0
|
let bytes = 0
|
||||||
let found = false
|
|
||||||
let truncated = false
|
|
||||||
let next: number | undefined
|
let next: number | undefined
|
||||||
const append = (input: string) => {
|
const append = (input: string) => {
|
||||||
if (line < offset) {
|
if (line < offset) {
|
||||||
line++
|
line++
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
|
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
|
||||||
truncated = true
|
next = line
|
||||||
next ??= line++
|
return false
|
||||||
return
|
|
||||||
}
|
}
|
||||||
found = true
|
|
||||||
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
|
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
|
||||||
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
|
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
|
||||||
if (bytes + size > MAX_READ_BYTES) {
|
if (bytes + size > MAX_READ_BYTES) {
|
||||||
truncated = true
|
next = line
|
||||||
next ??= line++
|
return false
|
||||||
return
|
|
||||||
}
|
}
|
||||||
lines.push(text)
|
lines.push(text)
|
||||||
bytes += size
|
bytes += size
|
||||||
line++
|
line++
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
const consume = (chunk: Uint8Array) => {
|
const consume = (input: string) => {
|
||||||
if (chunk.includes(0)) throw new BinaryFileError(resource)
|
let text = input
|
||||||
let text = decoder.decode(chunk, { stream: true })
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const index = text.indexOf("\n")
|
const index = text.indexOf("\n")
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -235,25 +276,44 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
pending = ""
|
pending = ""
|
||||||
discard = false
|
discard = false
|
||||||
text = text.slice(index + 1)
|
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))
|
const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
|
||||||
while (true) {
|
let start = 0
|
||||||
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
|
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
|
if (Option.isNone(chunk)) break
|
||||||
yield* Effect.sync(() => consume(chunk.value))
|
done = !(yield* consumeChunk(chunk.value))
|
||||||
}
|
}
|
||||||
const tail = yield* Effect.sync(() => decoder.decode())
|
if (!done) {
|
||||||
if (!discard) pending += tail
|
const tail = yield* decodeUtf8(resource, decoder)
|
||||||
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
if (!discard) pending += tail
|
||||||
if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
|
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({
|
return new TextPage({
|
||||||
type: "text-page",
|
type: "text-page",
|
||||||
content: lines.join("\n"),
|
content: lines.join("\n"),
|
||||||
mime: FSUtil.mimeType(real),
|
mime: FSUtil.mimeType(real),
|
||||||
offset,
|
offset,
|
||||||
truncated,
|
truncated: next !== undefined,
|
||||||
...(next === undefined ? {} : { next }),
|
...(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 = {}) {
|
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 real = yield* fs.realPath(input)
|
||||||
const items = yield* fs.readDirectoryEntries(real).pipe(Effect.orDie)
|
const items = yield* fs.readDirectoryEntries(real)
|
||||||
const offset = page.offset ?? 1
|
const offset = page.offset ?? 1
|
||||||
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
||||||
const entries = yield* Effect.forEach(
|
const entries = yield* Effect.forEach(
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard(
|
|||||||
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
|
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
|
||||||
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
|
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
|
||||||
return yield* Effect.die(new Error("Path escapes the allowed read root"))
|
return yield* Effect.die(new Error("Path escapes the allowed read root"))
|
||||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
const real = yield* fs.realPath(absolute)
|
||||||
const root = yield* fs.realPath(selected).pipe(Effect.orDie)
|
const root = yield* fs.realPath(selected)
|
||||||
if (!FSUtil.contains(root, real))
|
if (!FSUtil.contains(root, real))
|
||||||
return yield* Effect.die(new Error("Path escapes the allowed read root"))
|
return yield* Effect.die(new Error("Path escapes the allowed read root"))
|
||||||
const resource = path.relative(root, real).replaceAll("\\", "/") || "."
|
const resource = path.relative(root, real).replaceAll("\\", "/") || "."
|
||||||
@@ -83,7 +83,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
|
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
|
||||||
}
|
}
|
||||||
if ("encoding" in content && content.encoding === "base64")
|
if ("encoding" in content && content.encoding === "base64")
|
||||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError(resource))
|
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||||
return content
|
return content
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError((error) => {
|
Effect.mapError((error) => {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
export * as WebFetchTool from "./webfetch"
|
export * as WebFetchTool from "./webfetch"
|
||||||
|
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import { Duration, Effect, Layer, Schema, Stream } from "effect"
|
import { Duration, Effect, Layer, Schema } from "effect"
|
||||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { Parser } from "htmlparser2"
|
import { Parser } from "htmlparser2"
|
||||||
import TurndownService from "turndown"
|
import TurndownService from "turndown"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
|
import { collectBoundedResponseBody } from "./http-body"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
|
|
||||||
@@ -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))
|
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
|
||||||
|
|
||||||
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
|
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
|
||||||
Effect.gen(function* () {
|
collectBoundedResponseBody(
|
||||||
const contentLength = response.headers["content-length"]
|
response,
|
||||||
if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
|
MAX_RESPONSE_BYTES,
|
||||||
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
|
() => new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`),
|
||||||
}
|
)
|
||||||
const chunks: Uint8Array[] = []
|
|
||||||
let size = 0
|
|
||||||
yield* Stream.runForEach(response.stream, (chunk) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
size += chunk.byteLength
|
|
||||||
if (size > MAX_RESPONSE_BYTES)
|
|
||||||
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
|
|
||||||
chunks.push(chunk)
|
|
||||||
return undefined
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return Buffer.concat(chunks, size)
|
|
||||||
})
|
|
||||||
|
|
||||||
const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||||
const isImageAttachment = (mime: string) =>
|
const isImageAttachment = (mime: string) =>
|
||||||
@@ -171,12 +159,16 @@ export const layer = Layer.effectDiscard(
|
|||||||
orElse: () => Effect.fail(new Error("Request timed out")),
|
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 {
|
return {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
contentType,
|
contentType,
|
||||||
format: input.format,
|
format: input.format,
|
||||||
output: content,
|
output,
|
||||||
}
|
}
|
||||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
|
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PositiveInt } from "../schema"
|
|||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
|
import { collectBoundedResponseBody } from "./http-body"
|
||||||
import { checksum } from "../util/encode"
|
import { checksum } from "../util/encode"
|
||||||
|
|
||||||
export const name = "websearch"
|
export const name = "websearch"
|
||||||
@@ -164,10 +165,12 @@ const callMcp = <F extends Schema.Struct.Fields>(
|
|||||||
)
|
)
|
||||||
return yield* Effect.gen(function* () {
|
return yield* Effect.gen(function* () {
|
||||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||||
const body = yield* response.text
|
const body = yield* collectBoundedResponseBody(
|
||||||
if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES)
|
response,
|
||||||
return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
|
MAX_RESPONSE_BYTES,
|
||||||
return yield* parseResponse(body)
|
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
|
||||||
|
)
|
||||||
|
return yield* parseResponse(body.toString("utf8"))
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.timeoutOrElse({
|
Effect.timeoutOrElse({
|
||||||
duration: Duration.seconds(25),
|
duration: Duration.seconds(25),
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ export type WithParts = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
sync: {
|
durable: {
|
||||||
aggregate: "sessionID",
|
aggregate: "sessionID",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { required } from "./plugin/provider-helper"
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
const locationLayer = Layer.succeed(
|
const locationLayer = Layer.succeed(
|
||||||
Location.Service,
|
Location.Service,
|
||||||
@@ -21,12 +25,7 @@ const it = testEffect(
|
|||||||
Catalog.locationLayer.pipe(
|
Catalog.locationLayer.pipe(
|
||||||
Layer.provideMerge(EventV2.defaultLayer),
|
Layer.provideMerge(EventV2.defaultLayer),
|
||||||
Layer.provideMerge(locationLayer),
|
Layer.provideMerge(locationLayer),
|
||||||
Layer.provideMerge(
|
Layer.provideMerge(Credential.defaultLayer),
|
||||||
Layer.mock(Credential.Service)({
|
|
||||||
all: () => Effect.succeed([]),
|
|
||||||
list: () => Effect.succeed([]),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,38 +47,30 @@ describe("CatalogV2", () => {
|
|||||||
|
|
||||||
it.effect("derives availability from active credentials without changing provider state", () => {
|
it.effect("derives availability from active credentials without changing provider state", () => {
|
||||||
const integrationID = Integration.ID.make("test")
|
const integrationID = Integration.ID.make("test")
|
||||||
const first = {
|
|
||||||
id: Credential.ID.create(),
|
|
||||||
integrationID,
|
|
||||||
label: "First",
|
|
||||||
value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
|
|
||||||
}
|
|
||||||
const second = {
|
|
||||||
id: Credential.ID.create(),
|
|
||||||
integrationID,
|
|
||||||
label: "Second",
|
|
||||||
value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
|
|
||||||
}
|
|
||||||
let active = first
|
|
||||||
const layer = Catalog.locationLayer.pipe(
|
const layer = Catalog.locationLayer.pipe(
|
||||||
Layer.fresh,
|
Layer.fresh,
|
||||||
Layer.provideMerge(EventV2.defaultLayer),
|
Layer.provideMerge(EventV2.defaultLayer),
|
||||||
Layer.provideMerge(locationLayer),
|
Layer.provideMerge(locationLayer),
|
||||||
Layer.provideMerge(
|
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
|
||||||
Layer.mock(Credential.Service)({
|
|
||||||
all: () => Effect.sync(() => [active]),
|
|
||||||
list: () => Effect.sync(() => [active]),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||||
|
yield* credentials.create({
|
||||||
|
integrationID,
|
||||||
|
label: "First",
|
||||||
|
value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
|
||||||
|
})
|
||||||
|
|
||||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||||
active = second
|
yield* credentials.create({
|
||||||
|
integrationID,
|
||||||
|
label: "Second",
|
||||||
|
value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
|
||||||
|
})
|
||||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||||
}).pipe(Effect.provide(layer))
|
}).pipe(Effect.provide(layer))
|
||||||
|
|||||||
@@ -1,14 +1,52 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Option, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { it, required, withEnv } from "../plugin/provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
import { catalogHost, host, integrationHost } from "../plugin/host"
|
import { PluginTestLayer } from "../plugin/fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* (config: Config.Interface) {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({
|
||||||
|
...ConfigProviderPlugin.Plugin,
|
||||||
|
effect: ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
effect,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() =>
|
||||||
|
Object.entries(previous).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function request(headers: Record<string, string>, variant?: string) {
|
function request(headers: Record<string, string>, variant?: string) {
|
||||||
return {
|
return {
|
||||||
@@ -23,8 +61,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||||||
it.effect("partitions existing model variant bodies without changing config shape", () =>
|
it.effect("partitions existing model variant bodies without changing config shape", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const integrations = yield* Integration.Service
|
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const providerID = ProviderV2.ID.opencode
|
const providerID = ProviderV2.ID.opencode
|
||||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||||
const config = Config.Service.of({
|
const config = Config.Service.of({
|
||||||
@@ -57,12 +93,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* plugin.add({
|
yield* addPlugin(config)
|
||||||
...ConfigProviderPlugin.Plugin,
|
|
||||||
effect: ConfigProviderPlugin.Plugin.effect(
|
|
||||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
|
|
||||||
).pipe(Effect.provideService(Config.Service, config)),
|
|
||||||
})
|
|
||||||
|
|
||||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||||
expect(model.variants).toMatchObject([
|
expect(model.variants).toMatchObject([
|
||||||
@@ -82,8 +113,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||||||
it.effect("uses the effective provider package across layered config", () =>
|
it.effect("uses the effective provider package across layered config", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const integrations = yield* Integration.Service
|
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const providerID = ProviderV2.ID.opencode
|
const providerID = ProviderV2.ID.opencode
|
||||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||||
const config = Config.Service.of({
|
const config = Config.Service.of({
|
||||||
@@ -116,12 +145,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* plugin.add({
|
yield* addPlugin(config)
|
||||||
...ConfigProviderPlugin.Plugin,
|
|
||||||
effect: ConfigProviderPlugin.Plugin.effect(
|
|
||||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
|
|
||||||
).pipe(Effect.provideService(Config.Service, config)),
|
|
||||||
})
|
|
||||||
|
|
||||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||||
expect(model.variants[0]).toMatchObject({
|
expect(model.variants[0]).toMatchObject({
|
||||||
@@ -137,7 +161,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const providerID = ProviderV2.ID.make("custom")
|
const providerID = ProviderV2.ID.make("custom")
|
||||||
const modelID = ModelV2.ID.make("chat")
|
const modelID = ModelV2.ID.make("chat")
|
||||||
const config = Config.Service.of({
|
const config = Config.Service.of({
|
||||||
@@ -217,12 +240,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* plugin.add({
|
yield* addPlugin(config)
|
||||||
...ConfigProviderPlugin.Plugin,
|
|
||||||
effect: ConfigProviderPlugin.Plugin.effect(
|
|
||||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
|
|
||||||
).pipe(Effect.provideService(Config.Service, config)),
|
|
||||||
})
|
|
||||||
|
|
||||||
const provider = required(yield* catalog.provider.get(providerID))
|
const provider = required(yield* catalog.provider.get(providerID))
|
||||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||||
|
|||||||
@@ -1,49 +1,35 @@
|
|||||||
import path from "path"
|
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { testEffect } from "./lib/effect"
|
||||||
import { it } from "./lib/effect"
|
|
||||||
|
|
||||||
function layer(directory: string) {
|
const it = testEffect(Credential.defaultLayer)
|
||||||
return Credential.layer.pipe(
|
|
||||||
Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Credential", () => {
|
describe("Credential", () => {
|
||||||
it.live("stores, updates, lists, and removes credentials", () =>
|
it.effect("stores, updates, lists, and removes credentials", () =>
|
||||||
Effect.acquireRelease(
|
Effect.gen(function* () {
|
||||||
Effect.promise(() => tmpdir()),
|
const credentials = yield* Credential.Service
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
const integrationID = Integration.ID.make("openai")
|
||||||
).pipe(
|
const created = yield* credentials.create({
|
||||||
Effect.flatMap((tmp) =>
|
integrationID,
|
||||||
Effect.gen(function* () {
|
label: "Work",
|
||||||
const credentials = yield* Credential.Service
|
value: new Credential.Key({ type: "key", key: "secret" }),
|
||||||
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])
|
expect(yield* credentials.list(integrationID)).toEqual([created])
|
||||||
yield* credentials.update(created.id, { label: "Personal" })
|
yield* credentials.update(created.id, { label: "Personal" })
|
||||||
expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal")
|
expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal")
|
||||||
|
|
||||||
const replacement = yield* credentials.create({
|
const replacement = yield* credentials.create({
|
||||||
integrationID,
|
integrationID,
|
||||||
label: "Replacement",
|
label: "Replacement",
|
||||||
value: new Credential.Key({ type: "key", key: "replacement" }),
|
value: new Credential.Key({ type: "key", key: "replacement" }),
|
||||||
})
|
})
|
||||||
expect(yield* credentials.list(integrationID)).toEqual([replacement])
|
expect(yield* credentials.list(integrationID)).toEqual([replacement])
|
||||||
|
|
||||||
yield* credentials.remove(replacement.id)
|
yield* credentials.remove(replacement.id)
|
||||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||||
}).pipe(Effect.provide(layer(tmp.path))),
|
}),
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ describe("DatabaseMigration", () => {
|
|||||||
).toEqual({ name: "session_context_epoch" })
|
).toEqual({ name: "session_context_epoch" })
|
||||||
expect(
|
expect(
|
||||||
yield* db.get(
|
yield* db.get(
|
||||||
sql`SELECT name, 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.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
|
||||||
expect(
|
expect(
|
||||||
yield* db.all(
|
yield* db.all(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const Message = EventV2.define({
|
|||||||
|
|
||||||
const SyncMessage = EventV2.define({
|
const SyncMessage = EventV2.define({
|
||||||
type: "test.sync",
|
type: "test.sync",
|
||||||
sync: {
|
durable: {
|
||||||
version: 1,
|
version: 1,
|
||||||
aggregate: "id",
|
aggregate: "id",
|
||||||
},
|
},
|
||||||
@@ -42,7 +42,7 @@ const SyncMessage = EventV2.define({
|
|||||||
|
|
||||||
const SyncSent = EventV2.define({
|
const SyncSent = EventV2.define({
|
||||||
type: "test.sent",
|
type: "test.sent",
|
||||||
sync: {
|
durable: {
|
||||||
version: 1,
|
version: 1,
|
||||||
aggregate: "messageID",
|
aggregate: "messageID",
|
||||||
},
|
},
|
||||||
@@ -61,7 +61,7 @@ const GlobalMessage = EventV2.define({
|
|||||||
|
|
||||||
const VersionedMessage = EventV2.define({
|
const VersionedMessage = EventV2.define({
|
||||||
type: "test.versioned",
|
type: "test.versioned",
|
||||||
sync: {
|
durable: {
|
||||||
version: 2,
|
version: 2,
|
||||||
aggregate: "id",
|
aggregate: "id",
|
||||||
},
|
},
|
||||||
@@ -73,7 +73,7 @@ const VersionedMessage = EventV2.define({
|
|||||||
|
|
||||||
const SyncTimestamp = EventV2.define({
|
const SyncTimestamp = EventV2.define({
|
||||||
type: "test.timestamp",
|
type: "test.timestamp",
|
||||||
sync: {
|
durable: {
|
||||||
version: 1,
|
version: 1,
|
||||||
aggregate: "id",
|
aggregate: "id",
|
||||||
},
|
},
|
||||||
@@ -132,7 +132,7 @@ describe("EventV2", () => {
|
|||||||
const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" })
|
const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" })
|
||||||
|
|
||||||
expect(event.type).toBe("test.versioned")
|
expect(event.type).toBe("test.versioned")
|
||||||
expect(event.version).toBe(2)
|
expect(event.durable?.version).toBe(2)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,12 +146,12 @@ describe("EventV2", () => {
|
|||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const latest = EventV2.define({
|
const latest = EventV2.define({
|
||||||
type: "test.out-of-order",
|
type: "test.out-of-order",
|
||||||
sync: { version: 2, aggregate: "id" },
|
durable: { version: 2, aggregate: "id" },
|
||||||
schema: { id: Schema.String },
|
schema: { id: Schema.String },
|
||||||
})
|
})
|
||||||
EventV2.define({
|
EventV2.define({
|
||||||
type: "test.out-of-order",
|
type: "test.out-of-order",
|
||||||
sync: { version: 1, aggregate: "id" },
|
durable: { version: 1, aggregate: "id" },
|
||||||
schema: { id: Schema.String },
|
schema: { id: Schema.String },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("commits local operational state inside a new synchronized event transaction", () =>
|
it.effect("commits local operational state inside a new durable event transaction", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const received = new Array<string>()
|
const received = new Array<string>()
|
||||||
@@ -207,7 +207,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rolls back the synchronized event and projector when the local commit fails", () =>
|
it.effect("rolls back the durable event and projector when the local commit fails", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -236,7 +236,7 @@ describe("EventV2", () => {
|
|||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit)
|
const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit)
|
||||||
|
|
||||||
expect(String(exit)).toContain("Local commit hooks require a synchronized event")
|
expect(String(exit)).toContain("Local commit hooks require a durable event")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -290,7 +290,6 @@ describe("EventV2", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const received = new Array<string>()
|
const received = new Array<string>()
|
||||||
yield* events.sync(() => Effect.die("sync defect"))
|
|
||||||
yield* events.listen(() => {
|
yield* events.listen(() => {
|
||||||
throw new Error("listener defect")
|
throw new Error("listener defect")
|
||||||
})
|
})
|
||||||
@@ -303,7 +302,7 @@ describe("EventV2", () => {
|
|||||||
const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" })
|
const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" })
|
||||||
|
|
||||||
expect(received).toEqual([SyncMessage.type])
|
expect(received).toEqual([SyncMessage.type])
|
||||||
expect(event.seq).toBeNumber()
|
expect(event.durable?.seq).toBeNumber()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -336,49 +335,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("does not synchronize live-only events", () =>
|
it.effect("inserts durable event rows on publish", () =>
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const synchronized = new Array<string>()
|
|
||||||
const unsubscribe = yield* events.sync((event) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
synchronized.push(event.type)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* Effect.addFinalizer(() => unsubscribe)
|
|
||||||
|
|
||||||
yield* events.publish(Message, { text: "live only" })
|
|
||||||
yield* events.publish(SyncMessage, { id: "one", text: "durable" })
|
|
||||||
|
|
||||||
expect(synchronized).toEqual([SyncMessage.type])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("synchronizes only after the durable event commits", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
const synchronized = new Array<boolean>()
|
|
||||||
yield* events.sync((event) =>
|
|
||||||
db
|
|
||||||
.select({ id: EventTable.id })
|
|
||||||
.from(EventTable)
|
|
||||||
.where(eq(EventTable.id, event.id))
|
|
||||||
.get()
|
|
||||||
.pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
Effect.map((row) => synchronized.push(row !== undefined)),
|
|
||||||
Effect.asVoid,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* events.publish(SyncMessage, { id: EventV2.ID.create(), text: "durable" })
|
|
||||||
|
|
||||||
expect(synchronized).toEqual([true])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("inserts sync event rows on publish", () =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -398,7 +355,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("increments sync event seq per aggregate", () =>
|
it.effect("increments durable event seq per aggregate", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -417,22 +374,22 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("replays durable aggregate events after a cursor and tails new events", () =>
|
it.effect("replays durable aggregate events after a sequence and tails new events", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = EventV2.ID.create()
|
const aggregateID = EventV2.ID.create()
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
||||||
const fiber = yield* events
|
const fiber = yield* events
|
||||||
.aggregateEvents({ aggregateID, after: EventV2.Cursor.make(0) })
|
.durable({ aggregateID, after: 0 })
|
||||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "two" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "two" })
|
||||||
|
|
||||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
|
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||||
[EventV2.Cursor.make(1), { id: aggregateID, text: "one" }],
|
[1, { id: aggregateID, text: "one" }],
|
||||||
[EventV2.Cursor.make(2), { id: aggregateID, text: "two" }],
|
[2, { id: aggregateID, text: "two" }],
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -442,20 +399,18 @@ describe("EventV2", () => {
|
|||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = EventV2.ID.create()
|
const aggregateID = EventV2.ID.create()
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
||||||
const fiber = yield* events
|
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||||
.aggregateEvents({ aggregateID })
|
|
||||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
|
||||||
|
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* Fiber.join(fiber)).map((event) => [
|
Array.from(yield* Fiber.join(fiber)).map((event) => [
|
||||||
event.cursor,
|
event.durable?.seq,
|
||||||
(event.event.data as { text: string }).text,
|
(event.data as { text: string }).text,
|
||||||
]),
|
]),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
[EventV2.Cursor.make(0), "zero"],
|
[0, "zero"],
|
||||||
[EventV2.Cursor.make(1), "one"],
|
[1, "one"],
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -465,30 +420,27 @@ describe("EventV2", () => {
|
|||||||
const readStarted = yield* Deferred.make<void>()
|
const readStarted = yield* Deferred.make<void>()
|
||||||
const continueRead = yield* Deferred.make<void>()
|
const continueRead = yield* Deferred.make<void>()
|
||||||
let pause = true
|
let pause = true
|
||||||
const database = Database.layerFromPath(":memory:")
|
|
||||||
const eventLayer = EventV2.layerWith({
|
const eventLayer = EventV2.layerWith({
|
||||||
beforeAggregateRead: () =>
|
beforeAggregateRead: () =>
|
||||||
pause
|
pause
|
||||||
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
|
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
|
||||||
: Effect.void,
|
: Effect.void,
|
||||||
}).pipe(Layer.provide(database))
|
}).pipe(Layer.provide(Database.defaultLayer))
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = EventV2.ID.create()
|
const aggregateID = EventV2.ID.create()
|
||||||
const fiber = yield* events
|
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
.aggregateEvents({ aggregateID })
|
|
||||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
||||||
yield* Deferred.await(readStarted)
|
yield* Deferred.await(readStarted)
|
||||||
|
|
||||||
pause = false
|
pause = false
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" })
|
||||||
yield* Deferred.succeed(continueRead, undefined)
|
yield* Deferred.succeed(continueRead, undefined)
|
||||||
|
|
||||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
|
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||||
[EventV2.Cursor.make(0), { id: aggregateID, text: "during handoff" }],
|
[0, { id: aggregateID, text: "during handoff" }],
|
||||||
])
|
])
|
||||||
}).pipe(Effect.provide(Layer.mergeAll(database, eventLayer)))
|
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -498,7 +450,7 @@ describe("EventV2", () => {
|
|||||||
const aggregateID = EventV2.ID.create()
|
const aggregateID = EventV2.ID.create()
|
||||||
const count = 64
|
const count = 64
|
||||||
const fiber = yield* events
|
const fiber = yield* events
|
||||||
.aggregateEvents({ aggregateID })
|
.durable({ aggregateID })
|
||||||
.pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
|
.pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
@@ -506,11 +458,8 @@ describe("EventV2", () => {
|
|||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) })
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual(
|
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
|
||||||
Array.from({ length: count }, (_, index) => [
|
Array.from({ length: count }, (_, index) => [index, { id: aggregateID, text: String(index) }]),
|
||||||
EventV2.Cursor.make(index),
|
|
||||||
{ id: aggregateID, text: String(index) },
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -519,15 +468,13 @@ describe("EventV2", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = EventV2.ID.create()
|
const aggregateID = EventV2.ID.create()
|
||||||
const fiber = yield* events
|
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
.aggregateEvents({ aggregateID })
|
|
||||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
yield* events.publish(Message, { text: "live only" })
|
yield* events.publish(Message, { text: "live only" })
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" })
|
||||||
|
|
||||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.event.type)).toEqual([SyncMessage.type])
|
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([SyncMessage.type])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -550,7 +497,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("replays sync events through projectors", () =>
|
it.effect("replays durable events through projectors", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const received = new Array<EventV2.Payload>()
|
const received = new Array<EventV2.Payload>()
|
||||||
@@ -706,7 +653,7 @@ describe("EventV2", () => {
|
|||||||
})
|
})
|
||||||
.pipe(Effect.exit)
|
.pipe(Effect.exit)
|
||||||
|
|
||||||
expect(String(exit)).toContain("Unknown sync event type")
|
expect(String(exit)).toContain("Unknown durable event type")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -843,7 +790,7 @@ describe("EventV2", () => {
|
|||||||
const replayed = {
|
const replayed = {
|
||||||
id: published.id,
|
id: published.id,
|
||||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||||
seq: published.seq!,
|
seq: published.durable!.seq,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
data: published.data,
|
data: published.data,
|
||||||
}
|
}
|
||||||
@@ -988,7 +935,7 @@ describe("EventV2", () => {
|
|||||||
yield* events.replay(replayed, { publish: true })
|
yield* events.replay(replayed, { publish: true })
|
||||||
yield* events.replay(replayed, { publish: true })
|
yield* events.replay(replayed, { publish: true })
|
||||||
|
|
||||||
expect(received).toMatchObject([{ id: replayed.id, seq: 0, data: replayed.data }])
|
expect(received).toMatchObject([{ id: replayed.id, durable: { seq: 0, version: 1 }, data: replayed.data }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1110,7 +1057,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("remove clears sync event sequence", () =>
|
it.effect("remove clears durable event sequence", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const received = new Array<EventV2.Payload>()
|
const received = new Array<EventV2.Payload>()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
|||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
|
const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer)
|
||||||
const { effect: it } = testEffect(live)
|
const { effect: it } = testEffect(live)
|
||||||
|
|
||||||
describe("FSUtil", () => {
|
describe("FSUtil", () => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe("Ripgrep", () => {
|
|||||||
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
||||||
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
|
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
|
||||||
expect(result.map((item) => item.path)).toEqual([RelativePath.make(path.join("src", "match.ts"))])
|
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -35,7 +35,7 @@ describe("Ripgrep", () => {
|
|||||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
|
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
|
||||||
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
|
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0]?.entry.path).toBe(RelativePath.make(path.join("src", "match.ts")))
|
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
|
||||||
expect(result[0]?.submatches[0]?.text).toBe("needle")
|
expect(result[0]?.submatches[0]?.text).toBe("needle")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { tmpdir } from "./tmpdir"
|
||||||
|
|
||||||
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
|
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -10,3 +12,15 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
|
|||||||
vcs: input.vcs,
|
vcs: input.vcs,
|
||||||
} satisfies Location.Interface
|
} satisfies Location.Interface
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const tempLocationLayer = Layer.unwrap(
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.map((tmp) => {
|
||||||
|
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
|
||||||
|
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,45 +4,12 @@ import * as TestClock from "effect/testing/TestClock"
|
|||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { it } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const layer = Integration.locationLayer.pipe(
|
const it = testEffect(
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Integration.locationLayer.pipe(Layer.provideMerge(Credential.defaultLayer), Layer.provideMerge(EventV2.defaultLayer)),
|
||||||
Layer.provide(
|
|
||||||
Layer.mock(Credential.Service)({
|
|
||||||
create: () => Effect.die("unexpected credential creation"),
|
|
||||||
list: () => Effect.succeed([]),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
function connectionLayer(
|
|
||||||
created: Array<{
|
|
||||||
integrationID: Integration.ID
|
|
||||||
label?: string
|
|
||||||
value: Credential.Info
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
return Integration.locationLayer.pipe(
|
|
||||||
Layer.provideMerge(EventV2.defaultLayer),
|
|
||||||
Layer.provide(
|
|
||||||
Layer.mock(Credential.Service)({
|
|
||||||
create: (input) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
created.push(input)
|
|
||||||
return new Credential.Stored({
|
|
||||||
id: Credential.ID.create(),
|
|
||||||
integrationID: input.integrationID,
|
|
||||||
label: input.label ?? "default",
|
|
||||||
value: input.value,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
list: () => Effect.succeed([]),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Integration", () => {
|
describe("Integration", () => {
|
||||||
it.effect("registers integrations through the editor", () =>
|
it.effect("registers integrations through the editor", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -59,7 +26,7 @@ describe("Integration", () => {
|
|||||||
|
|
||||||
yield* Scope.close(scope, Exit.void)
|
yield* Scope.close(scope, Exit.void)
|
||||||
expect(yield* integrations.get(openai)).toBeUndefined()
|
expect(yield* integrations.get(openai)).toBeUndefined()
|
||||||
}).pipe(Effect.provide(layer)),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("reveals the previous registration when an override closes", () =>
|
it.effect("reveals the previous registration when an override closes", () =>
|
||||||
@@ -80,7 +47,7 @@ describe("Integration", () => {
|
|||||||
yield* Scope.close(second, Exit.void)
|
yield* Scope.close(second, Exit.void)
|
||||||
expect((yield* integrations.get(id))?.name).toBe("OpenAI")
|
expect((yield* integrations.get(id))?.name).toBe("OpenAI")
|
||||||
expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id])
|
expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id])
|
||||||
}).pipe(Effect.provide(layer)),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("registers and overrides methods independently", () =>
|
it.effect("registers and overrides methods independently", () =>
|
||||||
@@ -128,17 +95,13 @@ describe("Integration", () => {
|
|||||||
yield* Scope.close(second, Exit.void)
|
yield* Scope.close(second, Exit.void)
|
||||||
expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" })
|
expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" })
|
||||||
expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })])
|
expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })])
|
||||||
}).pipe(Effect.provide(layer)),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("connects with a key and stores the credential", () => {
|
it.effect("connects with a key and stores the credential", () =>
|
||||||
const created: Array<{
|
Effect.gen(function* () {
|
||||||
integrationID: Integration.ID
|
|
||||||
label?: string
|
|
||||||
value: Credential.Info
|
|
||||||
}> = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const integrationID = Integration.ID.make("openai")
|
const integrationID = Integration.ID.make("openai")
|
||||||
yield* integrations.transform((editor) =>
|
yield* integrations.transform((editor) =>
|
||||||
@@ -158,25 +121,21 @@ describe("Integration", () => {
|
|||||||
label: "Work",
|
label: "Work",
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(created).toEqual([
|
expect(yield* credentials.list(integrationID)).toEqual([
|
||||||
{
|
expect.objectContaining({
|
||||||
integrationID,
|
integrationID,
|
||||||
label: "Work",
|
label: "Work",
|
||||||
value: new Credential.Key({ type: "key", key: "secret" }),
|
value: new Credential.Key({ type: "key", key: "secret" }),
|
||||||
},
|
}),
|
||||||
])
|
])
|
||||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||||
}).pipe(Effect.provide(connectionLayer(created)))
|
}),
|
||||||
})
|
)
|
||||||
|
|
||||||
it.effect("completes code OAuth once and stores the credential", () => {
|
it.effect("completes code OAuth once and stores the credential", () =>
|
||||||
const created: Array<{
|
Effect.gen(function* () {
|
||||||
integrationID: Integration.ID
|
|
||||||
label?: string
|
|
||||||
value: Credential.Info
|
|
||||||
}> = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
const integrationID = Integration.ID.make("openai")
|
const integrationID = Integration.ID.make("openai")
|
||||||
const methodID = Integration.MethodID.make("chatgpt")
|
const methodID = Integration.MethodID.make("chatgpt")
|
||||||
yield* integrations.transform((editor) =>
|
yield* integrations.transform((editor) =>
|
||||||
@@ -212,29 +171,27 @@ describe("Integration", () => {
|
|||||||
expect(attempt.mode).toBe("code")
|
expect(attempt.mode).toBe("code")
|
||||||
yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" })
|
yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" })
|
||||||
|
|
||||||
expect(created[0]).toEqual({
|
expect((yield* credentials.list(integrationID))[0]).toEqual(
|
||||||
integrationID,
|
expect.objectContaining({
|
||||||
label: "Personal",
|
integrationID,
|
||||||
value: new Credential.OAuth({
|
label: "Personal",
|
||||||
type: "oauth",
|
value: new Credential.OAuth({
|
||||||
methodID,
|
type: "oauth",
|
||||||
access: "access",
|
methodID,
|
||||||
refresh: "refresh",
|
access: "access",
|
||||||
expires: 1,
|
refresh: "refresh",
|
||||||
metadata: { code: "1234" },
|
expires: 1,
|
||||||
|
metadata: { code: "1234" },
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
)
|
||||||
}).pipe(Effect.provide(connectionLayer(created)))
|
}),
|
||||||
})
|
)
|
||||||
|
|
||||||
it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => {
|
it.effect("keeps code attempts open when the code is missing and closes them on cancel", () =>
|
||||||
const created: Array<{
|
Effect.gen(function* () {
|
||||||
integrationID: Integration.ID
|
|
||||||
label?: string
|
|
||||||
value: Credential.Info
|
|
||||||
}> = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
const integrationID = Integration.ID.make("openai")
|
const integrationID = Integration.ID.make("openai")
|
||||||
const methodID = Integration.MethodID.make("chatgpt")
|
const methodID = Integration.MethodID.make("chatgpt")
|
||||||
let closed = false
|
let closed = false
|
||||||
@@ -261,18 +218,14 @@ describe("Integration", () => {
|
|||||||
expect(closed).toBe(false)
|
expect(closed).toBe(false)
|
||||||
yield* integrations.attempt.cancel(attempt.attemptID)
|
yield* integrations.attempt.cancel(attempt.attemptID)
|
||||||
expect(closed).toBe(true)
|
expect(closed).toBe(true)
|
||||||
expect(created).toEqual([])
|
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||||
}).pipe(Effect.provide(connectionLayer(created)))
|
}),
|
||||||
})
|
)
|
||||||
|
|
||||||
it.effect("completes auto OAuth in the background", () => {
|
it.effect("completes auto OAuth in the background", () =>
|
||||||
const created: Array<{
|
Effect.gen(function* () {
|
||||||
integrationID: Integration.ID
|
|
||||||
label?: string
|
|
||||||
value: Credential.Info
|
|
||||||
}> = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
const integrationID = Integration.ID.make("openai")
|
const integrationID = Integration.ID.make("openai")
|
||||||
const methodID = Integration.MethodID.make("browser")
|
const methodID = Integration.MethodID.make("browser")
|
||||||
yield* integrations.transform((editor) =>
|
yield* integrations.transform((editor) =>
|
||||||
@@ -297,18 +250,14 @@ describe("Integration", () => {
|
|||||||
status: "complete",
|
status: "complete",
|
||||||
time: attempt.time,
|
time: attempt.time,
|
||||||
})
|
})
|
||||||
expect(created).toHaveLength(1)
|
expect(yield* credentials.list(integrationID)).toHaveLength(1)
|
||||||
}).pipe(Effect.provide(connectionLayer(created)))
|
}),
|
||||||
})
|
)
|
||||||
|
|
||||||
it.effect("expires abandoned OAuth attempts", () => {
|
it.effect("expires abandoned OAuth attempts", () =>
|
||||||
const created: Array<{
|
Effect.gen(function* () {
|
||||||
integrationID: Integration.ID
|
|
||||||
label?: string
|
|
||||||
value: Credential.Info
|
|
||||||
}> = []
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
const integrationID = Integration.ID.make("openai")
|
const integrationID = Integration.ID.make("openai")
|
||||||
const methodID = Integration.MethodID.make("browser")
|
const methodID = Integration.MethodID.make("browser")
|
||||||
let closed = false
|
let closed = false
|
||||||
@@ -337,34 +286,12 @@ describe("Integration", () => {
|
|||||||
time: attempt.time,
|
time: attempt.time,
|
||||||
})
|
})
|
||||||
expect(closed).toBe(true)
|
expect(closed).toBe(true)
|
||||||
expect(created).toEqual([])
|
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||||
}).pipe(Effect.provide(connectionLayer(created)))
|
}),
|
||||||
})
|
)
|
||||||
|
|
||||||
it.effect("projects credential and env connections", () => {
|
it.effect("projects credential and env connections", () => {
|
||||||
const integrationID = Integration.ID.make("acme")
|
const integrationID = Integration.ID.make("acme")
|
||||||
const rows = [
|
|
||||||
{
|
|
||||||
id: Credential.ID.create(),
|
|
||||||
integrationID,
|
|
||||||
label: "Work",
|
|
||||||
value: new Credential.Key({ type: "key", key: "a" }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: Credential.ID.create(),
|
|
||||||
integrationID,
|
|
||||||
label: "Personal",
|
|
||||||
value: new Credential.Key({ type: "key", key: "b" }),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const projectionLayer = Integration.locationLayer.pipe(
|
|
||||||
Layer.provide(EventV2.defaultLayer),
|
|
||||||
Layer.provide(
|
|
||||||
Layer.mock(Credential.Service)({
|
|
||||||
list: () => Effect.succeed(rows.map((row) => new Credential.Stored(row))),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return Effect.acquireUseRelease(
|
return Effect.acquireUseRelease(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const previous = process.env.INTEGRATION_TEST_ACME_KEY
|
const previous = process.env.INTEGRATION_TEST_ACME_KEY
|
||||||
@@ -375,6 +302,7 @@ describe("Integration", () => {
|
|||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
yield* integrations.transform((editor) =>
|
yield* integrations.transform((editor) =>
|
||||||
editor.method.update({
|
editor.method.update({
|
||||||
integrationID,
|
integrationID,
|
||||||
@@ -384,23 +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.
|
// Stored credentials and detected env vars appear as connections.
|
||||||
expect((yield* integrations.get(integrationID))?.connections).toEqual([
|
expect((yield* integrations.get(integrationID))?.connections).toEqual([
|
||||||
{ type: "credential", id: rows[0]!.id, label: "Work" },
|
|
||||||
{
|
{
|
||||||
type: "credential",
|
type: "credential",
|
||||||
id: rows[1]!.id,
|
id: personal.id,
|
||||||
label: "Personal",
|
label: "Personal",
|
||||||
},
|
},
|
||||||
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
|
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
|
||||||
])
|
])
|
||||||
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
|
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
|
||||||
type: "credential",
|
type: "credential",
|
||||||
id: rows[1]!.id,
|
id: personal.id,
|
||||||
label: "Personal",
|
label: "Personal",
|
||||||
})
|
})
|
||||||
}).pipe(Effect.provide(projectionLayer)),
|
expect(work.id).not.toBe(personal.id)
|
||||||
|
}),
|
||||||
(previous) =>
|
(previous) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY
|
if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { 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 { Tool } from "@opencode-ai/core/public"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||||
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { toolDefinitions } from "./lib/tool"
|
import { toolDefinitions } from "./lib/tool"
|
||||||
@@ -36,8 +40,7 @@ const it = testEffect(
|
|||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
EventV2.defaultLayer,
|
EventV2.defaultLayer,
|
||||||
Credential.defaultLayer,
|
Credential.defaultLayer.pipe(Layer.fresh),
|
||||||
Credential.layer.pipe(Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh))),
|
|
||||||
Npm.defaultLayer,
|
Npm.defaultLayer,
|
||||||
ModelsDev.defaultLayer,
|
ModelsDev.defaultLayer,
|
||||||
FSUtil.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
@@ -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", () =>
|
it.live("installs public plugins into a location", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
@@ -21,34 +21,39 @@ import { SessionStore } from "@opencode-ai/core/session/store"
|
|||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const database = Database.layerFromPath(":memory:")
|
|
||||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
|
||||||
const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
|
|
||||||
const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events))
|
|
||||||
const project = Project.layer.pipe(
|
const project = Project.layer.pipe(
|
||||||
Layer.provide(database),
|
Layer.provide(Database.defaultLayer),
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(directories),
|
Layer.provide(ProjectDirectories.defaultLayer),
|
||||||
)
|
)
|
||||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
|
||||||
const sessions = SessionV2.layer.pipe(
|
const sessions = SessionV2.layer.pipe(
|
||||||
Layer.provide(database),
|
Layer.provide(Database.defaultLayer),
|
||||||
Layer.provide(events),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
Layer.provide(project),
|
Layer.provide(project),
|
||||||
Layer.provide(store),
|
Layer.provide(SessionStore.defaultLayer),
|
||||||
Layer.provide(SessionExecution.noopLayer),
|
Layer.provide(SessionExecution.noopLayer),
|
||||||
)
|
)
|
||||||
const layer = MoveSession.layer.pipe(
|
const layer = MoveSession.layer.pipe(
|
||||||
Layer.provide(database),
|
Layer.provide(Database.defaultLayer),
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(events),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
Layer.provide(project),
|
Layer.provide(project),
|
||||||
Layer.provide(sessions),
|
Layer.provide(sessions),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions),
|
Layer.mergeAll(
|
||||||
|
layer,
|
||||||
|
Database.defaultLayer,
|
||||||
|
EventV2.defaultLayer,
|
||||||
|
ProjectDirectories.defaultLayer,
|
||||||
|
project,
|
||||||
|
SessionProjector.defaultLayer,
|
||||||
|
SessionStore.defaultLayer,
|
||||||
|
SessionExecution.noopLayer,
|
||||||
|
sessions,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
function abs(input: string) {
|
function abs(input: string) {
|
||||||
|
|||||||
@@ -18,29 +18,25 @@ import { eq } from "drizzle-orm"
|
|||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const database = Database.layerFromPath(":memory:")
|
|
||||||
const current = Layer.succeed(
|
const current = Layer.succeed(
|
||||||
Location.Service,
|
Location.Service,
|
||||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||||
)
|
)
|
||||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
|
||||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
|
||||||
const sessions = SessionV2.layer.pipe(
|
const sessions = SessionV2.layer.pipe(
|
||||||
Layer.provide(events),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
Layer.provide(database),
|
Layer.provide(Database.defaultLayer),
|
||||||
Layer.provide(store),
|
Layer.provide(SessionStore.defaultLayer),
|
||||||
Layer.provide(Project.defaultLayer),
|
Layer.provide(Project.defaultLayer),
|
||||||
Layer.provide(SessionExecution.noopLayer),
|
Layer.provide(SessionExecution.noopLayer),
|
||||||
)
|
)
|
||||||
const saved = PermissionSaved.layer.pipe(Layer.provide(database))
|
|
||||||
const layer = PermissionV2.locationLayer.pipe(
|
const layer = PermissionV2.locationLayer.pipe(
|
||||||
Layer.provideMerge(database),
|
Layer.provideMerge(Database.defaultLayer),
|
||||||
Layer.provideMerge(store),
|
Layer.provideMerge(SessionStore.defaultLayer),
|
||||||
Layer.provideMerge(events),
|
Layer.provideMerge(EventV2.defaultLayer),
|
||||||
Layer.provideMerge(current),
|
Layer.provideMerge(current),
|
||||||
Layer.provideMerge(sessions),
|
Layer.provideMerge(sessions),
|
||||||
Layer.provideMerge(SessionExecution.noopLayer),
|
Layer.provideMerge(SessionExecution.noopLayer),
|
||||||
Layer.provideMerge(saved),
|
Layer.provideMerge(PermissionSaved.defaultLayer),
|
||||||
)
|
)
|
||||||
const it = testEffect(layer)
|
const it = testEffect(layer)
|
||||||
|
|
||||||
|
|||||||
@@ -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", () =>
|
it.effect("serializes same-ID additions and leaves one removable attachment", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const values = state()
|
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 plugins = PluginV2.layer.pipe(Layer.provide(events))
|
||||||
const policy = Policy.layer.pipe(Layer.provide(locationLayer))
|
const policy = Policy.layer.pipe(Layer.provide(locationLayer))
|
||||||
const connections = Credential.layer.pipe(
|
const connections = Credential.defaultLayer.pipe(Layer.fresh)
|
||||||
Layer.fresh,
|
|
||||||
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
|
|
||||||
Layer.provide(events),
|
|
||||||
)
|
|
||||||
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
|
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
|
||||||
const catalog = Catalog.layer.pipe(
|
const catalog = Catalog.layer.pipe(
|
||||||
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
|
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
|
||||||
|
|||||||
@@ -3,17 +3,35 @@ import { createAlibaba } from "@ai-sdk/alibaba"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
|
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
|
||||||
import { addPlugin, it, model } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: AlibabaPlugin.id, effect: AlibabaPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
describe("AlibabaPlugin", () => {
|
describe("AlibabaPlugin", () => {
|
||||||
it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () =>
|
it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AlibabaPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
|
||||||
|
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/alibaba",
|
||||||
|
options: { name: "alibaba" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -23,10 +41,17 @@ describe("AlibabaPlugin", () => {
|
|||||||
it.effect("ignores non-Alibaba SDK packages", () =>
|
it.effect("ignores non-Alibaba SDK packages", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AlibabaPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
|
||||||
|
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
options: { name: "alibaba" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeUndefined()
|
expect(result.sdk).toBeUndefined()
|
||||||
@@ -36,11 +61,14 @@ describe("AlibabaPlugin", () => {
|
|||||||
it.effect("matches the old bundled Alibaba SDK provider naming", () =>
|
it.effect("matches the old bundled Alibaba SDK provider naming", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AlibabaPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-alibaba", "qwen"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")),
|
||||||
|
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/alibaba",
|
package: "@ai-sdk/alibaba",
|
||||||
options: { name: "custom-alibaba", apiKey: "test" },
|
options: { name: "custom-alibaba", apiKey: "test" },
|
||||||
},
|
},
|
||||||
@@ -56,8 +84,11 @@ describe("AlibabaPlugin", () => {
|
|||||||
it.effect("uses the old default languageModel(api.id) behavior", () =>
|
it.effect("uses the old default languageModel(api.id) behavior", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AlibabaPlugin)
|
yield* addPlugin()
|
||||||
const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } })
|
const item = new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")),
|
||||||
|
api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" },
|
||||||
|
})
|
||||||
const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {})
|
const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {})
|
||||||
const language = result.sdk?.languageModel(item.api.id)
|
const language = result.sdk?.languageModel(item.api.id)
|
||||||
expect(language?.modelId).toBe("qwen-plus")
|
expect(language?.modelId).toBe("qwen-plus")
|
||||||
|
|||||||
@@ -1,10 +1,61 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
|
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: AmazonBedrockPlugin.id, effect: AmazonBedrockPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
fx,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
Object.entries(previous).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
|
function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
|
||||||
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
|
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
|
||||||
@@ -28,11 +79,10 @@ function openAIUrl(language: unknown, path: string, modelId: string) {
|
|||||||
describe("AmazonBedrockPlugin", () => {
|
describe("AmazonBedrockPlugin", () => {
|
||||||
it.effect("moves endpoint option to api URL", () =>
|
it.effect("moves endpoint option to api URL", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const bedrock = provider("amazon-bedrock", {
|
const bedrock = new ProviderV2.Info({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock),
|
||||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
|
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
|
||||||
request: {
|
request: {
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -44,6 +94,7 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
item.request = bedrock.request
|
item.request = bedrock.request
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock))
|
const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock))
|
||||||
expect(result.api).toEqual({
|
expect(result.api).toEqual({
|
||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
@@ -58,11 +109,14 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/amazon-bedrock",
|
package: "@ai-sdk/amazon-bedrock",
|
||||||
options: {
|
options: {
|
||||||
name: "amazon-bedrock",
|
name: "amazon-bedrock",
|
||||||
@@ -83,11 +137,14 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/amazon-bedrock",
|
package: "@ai-sdk/amazon-bedrock",
|
||||||
options: {
|
options: {
|
||||||
name: "amazon-bedrock",
|
name: "amazon-bedrock",
|
||||||
@@ -117,11 +174,18 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "test-provider",
|
||||||
|
},
|
||||||
|
}),
|
||||||
package: "@ai-sdk/amazon-bedrock",
|
package: "@ai-sdk/amazon-bedrock",
|
||||||
options: { name: "amazon-bedrock" },
|
options: { name: "amazon-bedrock" },
|
||||||
},
|
},
|
||||||
@@ -137,11 +201,14 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
|
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/amazon-bedrock",
|
package: "@ai-sdk/amazon-bedrock",
|
||||||
options: { name: "amazon-bedrock", region: "eu-west-1" },
|
options: { name: "amazon-bedrock", region: "eu-west-1" },
|
||||||
},
|
},
|
||||||
@@ -156,11 +223,14 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
|
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/amazon-bedrock",
|
package: "@ai-sdk/amazon-bedrock",
|
||||||
options: { name: "amazon-bedrock" },
|
options: { name: "amazon-bedrock" },
|
||||||
},
|
},
|
||||||
@@ -175,11 +245,14 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
|
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/amazon-bedrock",
|
package: "@ai-sdk/amazon-bedrock",
|
||||||
options: { name: "amazon-bedrock" },
|
options: { name: "amazon-bedrock" },
|
||||||
},
|
},
|
||||||
@@ -195,11 +268,14 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const headers: Array<string | null> = []
|
const headers: Array<string | null> = []
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/amazon-bedrock",
|
package: "@ai-sdk/amazon-bedrock",
|
||||||
options: {
|
options: {
|
||||||
name: "amazon-bedrock",
|
name: "amazon-bedrock",
|
||||||
@@ -224,11 +300,14 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const headers: Array<string | null> = []
|
const headers: Array<string | null> = []
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/amazon-bedrock",
|
package: "@ai-sdk/amazon-bedrock",
|
||||||
options: {
|
options: {
|
||||||
name: "amazon-bedrock",
|
name: "amazon-bedrock",
|
||||||
@@ -252,12 +331,17 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "openai.gpt-5.5", {
|
model: new ModelV2.Info({
|
||||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("openai.gpt-5.5"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
package: "@ai-sdk/amazon-bedrock/mantle",
|
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||||
options: {
|
options: {
|
||||||
@@ -281,12 +365,17 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "openai.gpt-5.5", {
|
model: new ModelV2.Info({
|
||||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("openai.gpt-5.5"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" },
|
options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" },
|
||||||
@@ -296,8 +385,13 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "openai.gpt-oss-safeguard-120b", {
|
model: new ModelV2.Info({
|
||||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
options: { region: "us-east-1" },
|
options: { region: "us-east-1" },
|
||||||
@@ -311,12 +405,17 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
it.effect("ignores other Bedrock provider subpaths", () =>
|
it.effect("ignores other Bedrock provider subpaths", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5", {
|
model: new ModelV2.Info({
|
||||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/anthropic" },
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/amazon-bedrock/anthropic",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
package: "@ai-sdk/amazon-bedrock/anthropic",
|
package: "@ai-sdk/amazon-bedrock/anthropic",
|
||||||
options: { name: "amazon-bedrock" },
|
options: { name: "amazon-bedrock" },
|
||||||
@@ -340,11 +439,18 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const headers: Array<string | null> = []
|
const headers: Array<string | null> = []
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "test-provider",
|
||||||
|
},
|
||||||
|
}),
|
||||||
package: "@ai-sdk/amazon-bedrock",
|
package: "@ai-sdk/amazon-bedrock",
|
||||||
options: {
|
options: {
|
||||||
name: "amazon-bedrock",
|
name: "amazon-bedrock",
|
||||||
@@ -371,11 +477,14 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -384,7 +493,10 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: { region: "eu-west-1" },
|
options: { region: "eu-west-1" },
|
||||||
},
|
},
|
||||||
@@ -393,7 +505,14 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "test-provider",
|
||||||
|
},
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: { region: "eu-west-1" },
|
options: { region: "eu-west-1" },
|
||||||
},
|
},
|
||||||
@@ -402,7 +521,10 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: { region: "ap-northeast-1" },
|
options: { region: "ap-northeast-1" },
|
||||||
},
|
},
|
||||||
@@ -411,7 +533,10 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: { region: "ap-southeast-2" },
|
options: { region: "ap-southeast-2" },
|
||||||
},
|
},
|
||||||
@@ -432,11 +557,14 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -517,12 +645,15 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
expected: "au.anthropic.claude-sonnet-4-5",
|
expected: "au.anthropic.claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
for (const item of cases) {
|
for (const item of cases) {
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("amazon-bedrock", item.modelID),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)),
|
||||||
|
api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: { region: item.region },
|
options: { region: item.region },
|
||||||
},
|
},
|
||||||
@@ -537,11 +668,14 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, AmazonBedrockPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("openai", "anthropic.claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: { region: "eu-west-1" },
|
options: { region: "eu-west-1" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,19 +1,34 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
|
import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, it, model, provider, required } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: AnthropicPlugin.id, effect: AnthropicPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
describe("AnthropicPlugin", () => {
|
describe("AnthropicPlugin", () => {
|
||||||
it.effect("applies legacy beta headers", () =>
|
it.effect("applies legacy beta headers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, AnthropicPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("anthropic", {
|
const item = new ProviderV2.Info({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.anthropic),
|
||||||
api: { type: "aisdk", package: "@ai-sdk/anthropic" },
|
api: { type: "aisdk", package: "@ai-sdk/anthropic" },
|
||||||
request: { headers: { Existing: "1" }, body: {} },
|
request: { headers: { Existing: "1" }, body: {} },
|
||||||
})
|
})
|
||||||
@@ -22,6 +37,7 @@ describe("AnthropicPlugin", () => {
|
|||||||
draft.request = item.request
|
draft.request = item.request
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe(
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe(
|
||||||
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
||||||
)
|
)
|
||||||
@@ -31,10 +47,9 @@ describe("AnthropicPlugin", () => {
|
|||||||
|
|
||||||
it.effect("ignores non-Anthropic providers", () =>
|
it.effect("ignores non-Anthropic providers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, AnthropicPlugin)
|
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
|
||||||
yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {}))
|
yield* addPlugin()
|
||||||
expect(
|
expect(
|
||||||
required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"],
|
required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"],
|
||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
@@ -44,54 +59,40 @@ describe("AnthropicPlugin", () => {
|
|||||||
it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () =>
|
it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const providers: string[] = []
|
yield* addPlugin()
|
||||||
yield* addPlugin(plugin, AnthropicPlugin)
|
const result = yield* plugin.trigger(
|
||||||
yield* plugin.add({
|
|
||||||
id: PluginV2.ID.make("anthropic-sdk-inspector"),
|
|
||||||
effect: Effect.succeed({
|
|
||||||
"aisdk.sdk": (evt) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider)
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-anthropic", "claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/anthropic",
|
package: "@ai-sdk/anthropic",
|
||||||
options: { name: "custom-anthropic", apiKey: "test" },
|
options: { name: "custom-anthropic", apiKey: "test" },
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(providers).toEqual(["custom-anthropic"])
|
expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("custom-anthropic")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () =>
|
it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const providers: string[] = []
|
yield* addPlugin()
|
||||||
yield* addPlugin(plugin, AnthropicPlugin)
|
const result = yield* plugin.trigger(
|
||||||
yield* plugin.add({
|
|
||||||
id: PluginV2.ID.make("anthropic-sdk-inspector"),
|
|
||||||
effect: Effect.succeed({
|
|
||||||
"aisdk.sdk": (evt) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider)
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("anthropic", "claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/anthropic",
|
package: "@ai-sdk/anthropic",
|
||||||
options: { name: "anthropic", apiKey: "test" },
|
options: { name: "anthropic", apiKey: "test" },
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(providers).toEqual(["anthropic"])
|
expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,23 +1,73 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
|
import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: AzureCognitiveServicesPlugin.id, effect: AzureCognitiveServicesPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
fx,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
Object.entries(previous).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("AzureCognitiveServicesPlugin", () => {
|
describe("AzureCognitiveServicesPlugin", () => {
|
||||||
it.effect("maps the resource env var to the Azure SDK baseURL", () =>
|
it.effect("maps the resource env var to the Azure SDK baseURL", () =>
|
||||||
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
|
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
|
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
|
||||||
item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
|
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
|
||||||
expect(result.api).toEqual({
|
expect(result.api).toEqual({
|
||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
@@ -33,14 +83,16 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||||||
it.effect("leaves baseURL unset without resource env and ignores other providers", () =>
|
it.effect("leaves baseURL unset without resource env and ignores other providers", () =>
|
||||||
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const azure = provider("azure-cognitive-services", {
|
const azure = new ProviderV2.Info({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")),
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
||||||
})
|
})
|
||||||
const openai = provider("openai")
|
const openai = new ProviderV2.Info({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||||
|
api: { type: "aisdk", package: "test-provider" },
|
||||||
|
})
|
||||||
catalog.provider.update(azure.id, (item) => {
|
catalog.provider.update(azure.id, (item) => {
|
||||||
item.api = azure.api
|
item.api = azure.api
|
||||||
})
|
})
|
||||||
@@ -48,6 +100,7 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||||||
item.api = openai.api
|
item.api = openai.api
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
|
const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
|
||||||
const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai))
|
const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai))
|
||||||
expect(azure.request.body.baseURL).toBeUndefined()
|
expect(azure.request.body.baseURL).toBeUndefined()
|
||||||
@@ -62,11 +115,14 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("azure-cognitive-services", "deployment"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
options: { useCompletionUrls: true },
|
options: { useCompletionUrls: true },
|
||||||
},
|
},
|
||||||
@@ -80,15 +136,29 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
const ignored = yield* plugin.trigger(
|
const ignored = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(calls).toEqual(["responses:deployment"])
|
expect(calls).toEqual(["responses:deployment"])
|
||||||
@@ -101,11 +171,17 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const sdk = fakeSelectorSdk(calls)
|
const sdk = fakeSelectorSdk(calls)
|
||||||
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("azure-cognitive-services", "messages-deployment"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(
|
||||||
|
ProviderV2.ID.make("azure-cognitive-services"),
|
||||||
|
ModelV2.ID.make("messages-deployment"),
|
||||||
|
),
|
||||||
|
api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel },
|
sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -114,7 +190,10 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("azure-cognitive-services", "chat-deployment"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { chat: sdk.chat, languageModel: sdk.languageModel },
|
sdk: { chat: sdk.chat, languageModel: sdk.languageModel },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -123,7 +202,13 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("azure-cognitive-services", "language-deployment"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(
|
||||||
|
ProviderV2.ID.make("azure-cognitive-services"),
|
||||||
|
ModelV2.ID.make("language-deployment"),
|
||||||
|
),
|
||||||
|
api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { languageModel: sdk.languageModel },
|
sdk: { languageModel: sdk.languageModel },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +1,73 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
|
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: AzurePlugin.id, effect: AzurePlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
fx,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
Object.entries(previous).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("AzurePlugin", () => {
|
describe("AzurePlugin", () => {
|
||||||
it.effect("resolves resourceName from env", () =>
|
it.effect("resolves resourceName from env", () =>
|
||||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
catalog.provider.update(ProviderV2.ID.azure, (item) => {
|
catalog.provider.update(ProviderV2.ID.azure, (item) => {
|
||||||
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
|
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* addPlugin(plugin, AzurePlugin)
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -26,10 +76,10 @@ describe("AzurePlugin", () => {
|
|||||||
it.effect("keeps explicit resourceName over env and ignores other providers", () =>
|
it.effect("keeps explicit resourceName over env and ignores other providers", () =>
|
||||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const azure = provider("azure", {
|
const azure = new ProviderV2.Info({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.azure),
|
||||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||||
request: { headers: {}, body: { resourceName: "from-config" } },
|
request: { headers: {}, body: { resourceName: "from-config" } },
|
||||||
})
|
})
|
||||||
@@ -39,7 +89,7 @@ describe("AzurePlugin", () => {
|
|||||||
})
|
})
|
||||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||||
})
|
})
|
||||||
yield* addPlugin(plugin, AzurePlugin)
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config")
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config")
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined()
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
@@ -49,10 +99,10 @@ describe("AzurePlugin", () => {
|
|||||||
it.effect("falls back to env when configured resourceName is blank", () =>
|
it.effect("falls back to env when configured resourceName is blank", () =>
|
||||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const azure = provider("azure", {
|
const azure = new ProviderV2.Info({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.azure),
|
||||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||||
request: { headers: {}, body: { resourceName: "" } },
|
request: { headers: {}, body: { resourceName: "" } },
|
||||||
})
|
})
|
||||||
@@ -61,7 +111,7 @@ describe("AzurePlugin", () => {
|
|||||||
item.request = azure.request
|
item.request = azure.request
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* addPlugin(plugin, AzurePlugin)
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -70,10 +120,10 @@ describe("AzurePlugin", () => {
|
|||||||
it.effect("falls back to env when configured resourceName is whitespace", () =>
|
it.effect("falls back to env when configured resourceName is whitespace", () =>
|
||||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const azure = provider("azure", {
|
const azure = new ProviderV2.Info({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.azure),
|
||||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||||
request: { headers: {}, body: { resourceName: " " } },
|
request: { headers: {}, body: { resourceName: " " } },
|
||||||
})
|
})
|
||||||
@@ -82,7 +132,7 @@ describe("AzurePlugin", () => {
|
|||||||
item.request = azure.request
|
item.request = azure.request
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* addPlugin(plugin, AzurePlugin)
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -92,11 +142,14 @@ describe("AzurePlugin", () => {
|
|||||||
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
|
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AzurePlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("azure", "deployment"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/azure",
|
package: "@ai-sdk/azure",
|
||||||
options: { name: "azure", baseURL: "https://proxy.example.com/openai" },
|
options: { name: "azure", baseURL: "https://proxy.example.com/openai" },
|
||||||
},
|
},
|
||||||
@@ -111,11 +164,18 @@ describe("AzurePlugin", () => {
|
|||||||
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
|
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, AzurePlugin)
|
yield* addPlugin()
|
||||||
const exit = yield* plugin
|
const exit = yield* plugin
|
||||||
.trigger(
|
.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/azure",
|
||||||
|
options: { name: "azure" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
.pipe(Effect.exit)
|
.pipe(Effect.exit)
|
||||||
@@ -128,10 +188,17 @@ describe("AzurePlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, AzurePlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: { useCompletionUrls: true },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(calls).toEqual(["chat:deployment"])
|
expect(calls).toEqual(["chat:deployment"])
|
||||||
@@ -142,10 +209,17 @@ describe("AzurePlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, AzurePlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: { useCompletionUrls: true },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(calls).toEqual(["chat:deployment"])
|
expect(calls).toEqual(["chat:deployment"])
|
||||||
@@ -156,11 +230,13 @@ describe("AzurePlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, AzurePlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("azure", "deployment", {
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||||
request: { headers: {}, body: { useCompletionUrls: true } },
|
request: { headers: {}, body: { useCompletionUrls: true } },
|
||||||
}),
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
@@ -176,15 +252,29 @@ describe("AzurePlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, AzurePlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
const ignored = yield* plugin.trigger(
|
const ignored = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(calls).toEqual(["responses:deployment"])
|
expect(calls).toEqual(["responses:deployment"])
|
||||||
@@ -200,11 +290,14 @@ describe("AzurePlugin", () => {
|
|||||||
calls.push(`${method}:${id}`)
|
calls.push(`${method}:${id}`)
|
||||||
return { modelId: id, provider: method, specificationVersion: "v3" }
|
return { modelId: id, provider: method, specificationVersion: "v3" }
|
||||||
}
|
}
|
||||||
yield* addPlugin(plugin, AzurePlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("azure", "messages-deployment"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") },
|
sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -212,7 +305,14 @@ describe("AzurePlugin", () => {
|
|||||||
)
|
)
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")),
|
||||||
|
api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: { languageModel: make("languageModel") },
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"])
|
expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"])
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { describe, expect, mock } from "bun:test"
|
import { describe, expect, mock } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
|
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, it, model, required } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
const cerebrasOptions: Record<string, unknown>[] = []
|
const cerebrasOptions: Record<string, unknown>[] = []
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: CerebrasPlugin.id, effect: CerebrasPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
void mock.module("@ai-sdk/cerebras", () => ({
|
void mock.module("@ai-sdk/cerebras", () => ({
|
||||||
createCerebras: (options: Record<string, unknown>) => {
|
createCerebras: (options: Record<string, unknown>) => {
|
||||||
@@ -21,16 +31,15 @@ void mock.module("@ai-sdk/cerebras", () => ({
|
|||||||
describe("CerebrasPlugin", () => {
|
describe("CerebrasPlugin", () => {
|
||||||
it.effect("applies the legacy integration header", () =>
|
it.effect("applies the legacy integration header", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, CerebrasPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
|
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
|
||||||
item.api = { type: "aisdk", package: "@ai-sdk/cerebras" }
|
item.api = { type: "aisdk", package: "@ai-sdk/cerebras" }
|
||||||
item.request.headers.Existing = "1"
|
item.request.headers.Existing = "1"
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({
|
yield* addPlugin()
|
||||||
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.request.headers).toEqual({
|
||||||
Existing: "1",
|
Existing: "1",
|
||||||
"X-Cerebras-3rd-Party-Integration": "opencode",
|
"X-Cerebras-3rd-Party-Integration": "opencode",
|
||||||
})
|
})
|
||||||
@@ -39,11 +48,10 @@ describe("CerebrasPlugin", () => {
|
|||||||
|
|
||||||
it.effect("ignores non-Cerebras providers", () =>
|
it.effect("ignores non-Cerebras providers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, CerebrasPlugin)
|
|
||||||
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
|
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({})
|
yield* addPlugin()
|
||||||
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.request.headers).toEqual({})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,11 +59,21 @@ describe("CerebrasPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
cerebrasOptions.length = 0
|
cerebrasOptions.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CerebrasPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(
|
||||||
|
ProviderV2.ID.make("custom-cerebras"),
|
||||||
|
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||||
|
),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "test-provider",
|
||||||
|
},
|
||||||
|
}),
|
||||||
package: "@ai-sdk/cerebras",
|
package: "@ai-sdk/cerebras",
|
||||||
options: { name: "custom-cerebras", apiKey: "test" },
|
options: { name: "custom-cerebras", apiKey: "test" },
|
||||||
},
|
},
|
||||||
@@ -70,11 +88,21 @@ describe("CerebrasPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
cerebrasOptions.length = 0
|
cerebrasOptions.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CerebrasPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(
|
||||||
|
ProviderV2.ID.make("custom-cerebras"),
|
||||||
|
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||||
|
),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "test-provider",
|
||||||
|
},
|
||||||
|
}),
|
||||||
package: "@ai-sdk/cerebras",
|
package: "@ai-sdk/cerebras",
|
||||||
options: { name: "configured-cerebras", apiKey: "test" },
|
options: { name: "configured-cerebras", apiKey: "test" },
|
||||||
},
|
},
|
||||||
@@ -88,11 +116,21 @@ describe("CerebrasPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
cerebrasOptions.length = 0
|
cerebrasOptions.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CerebrasPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(
|
||||||
|
ProviderV2.ID.make("custom-cerebras"),
|
||||||
|
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||||
|
),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "test-provider",
|
||||||
|
},
|
||||||
|
}),
|
||||||
package: "@ai-sdk/groq",
|
package: "@ai-sdk/groq",
|
||||||
options: { name: "custom-cerebras", apiKey: "test" },
|
options: { name: "custom-cerebras", apiKey: "test" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,41 @@
|
|||||||
import { describe, expect, mock } from "bun:test"
|
import { describe, expect, mock } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
|
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
|
||||||
import { addPlugin, it, model, withEnv } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: CloudflareAIGatewayPlugin.id, effect: CloudflareAIGatewayPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
fx,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
Object.entries(previous).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const aiGatewayCalls: Record<string, unknown>[] = []
|
const aiGatewayCalls: Record<string, unknown>[] = []
|
||||||
const unifiedCalls: string[] = []
|
const unifiedCalls: string[] = []
|
||||||
@@ -78,11 +111,14 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "ai-gateway-provider",
|
package: "ai-gateway-provider",
|
||||||
options: { name: "cloudflare-ai-gateway" },
|
options: { name: "cloudflare-ai-gateway" },
|
||||||
},
|
},
|
||||||
@@ -98,12 +134,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetCalls()
|
resetCalls()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "ai-gateway-provider",
|
package: "ai-gateway-provider",
|
||||||
options: {
|
options: {
|
||||||
name: "cloudflare-ai-gateway",
|
name: "cloudflare-ai-gateway",
|
||||||
@@ -142,12 +181,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetCalls()
|
resetCalls()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "ai-gateway-provider",
|
package: "ai-gateway-provider",
|
||||||
options: {
|
options: {
|
||||||
name: "cloudflare-ai-gateway",
|
name: "cloudflare-ai-gateway",
|
||||||
@@ -171,12 +213,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetCalls()
|
resetCalls()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "ai-gateway-provider",
|
package: "ai-gateway-provider",
|
||||||
options: {
|
options: {
|
||||||
name: "cloudflare-ai-gateway",
|
name: "cloudflare-ai-gateway",
|
||||||
@@ -208,12 +253,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetCalls()
|
resetCalls()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "ai-gateway-provider",
|
package: "ai-gateway-provider",
|
||||||
options: {
|
options: {
|
||||||
name: "cloudflare-ai-gateway",
|
name: "cloudflare-ai-gateway",
|
||||||
@@ -239,12 +287,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetCalls()
|
resetCalls()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "ai-gateway-provider",
|
package: "ai-gateway-provider",
|
||||||
options: { name: "cloudflare-ai-gateway" },
|
options: { name: "cloudflare-ai-gateway" },
|
||||||
},
|
},
|
||||||
@@ -261,12 +312,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetCalls()
|
resetCalls()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "ai-gateway-provider",
|
package: "ai-gateway-provider",
|
||||||
options: { name: "cloudflare-ai-gateway" },
|
options: { name: "cloudflare-ai-gateway" },
|
||||||
},
|
},
|
||||||
@@ -284,12 +338,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetCalls()
|
resetCalls()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "ai-gateway-provider",
|
package: "ai-gateway-provider",
|
||||||
options: { name: "cloudflare-ai-gateway" },
|
options: { name: "cloudflare-ai-gateway" },
|
||||||
},
|
},
|
||||||
@@ -313,12 +370,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetCalls()
|
resetCalls()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "ai-gateway-provider",
|
package: "ai-gateway-provider",
|
||||||
options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" },
|
options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" },
|
||||||
},
|
},
|
||||||
@@ -336,12 +396,22 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetCalls()
|
resetCalls()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(
|
||||||
|
ProviderV2.ID.make("cloudflare-ai-gateway"),
|
||||||
|
ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
|
||||||
|
),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "test-provider",
|
||||||
|
},
|
||||||
|
}),
|
||||||
package: "ai-gateway-provider",
|
package: "ai-gateway-provider",
|
||||||
options: { name: "cloudflare-ai-gateway" },
|
options: { name: "cloudflare-ai-gateway" },
|
||||||
},
|
},
|
||||||
@@ -364,12 +434,15 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetCalls()
|
resetCalls()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "cloudflare-ai-gateway" },
|
options: { name: "cloudflare-ai-gateway" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,9 +3,59 @@ import { Effect } from "effect"
|
|||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: CloudflareWorkersAIPlugin.id, effect: CloudflareWorkersAIPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
effect,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() =>
|
||||||
|
Object.entries(previous).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
|
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
|
||||||
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
|
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
|
||||||
@@ -37,12 +87,15 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||||||
provider.api = { type: "aisdk", package: "test-provider" }
|
provider.api = { type: "aisdk", package: "test-provider" }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
yield* addPlugin()
|
||||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))
|
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))
|
||||||
const sdk = yield* plugin.trigger(
|
const sdk = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-workers-ai", "@cf/model", { api: provider.api }),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||||
|
api: { id: ModelV2.ID.make("@cf/model"), ...provider.api },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
|
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
|
||||||
},
|
},
|
||||||
@@ -61,14 +114,13 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||||||
it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
|
it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
|
||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
|
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||||
provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
|
provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
package: "test-provider",
|
package: "test-provider",
|
||||||
@@ -82,12 +134,18 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
|
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
model: new ModelV2.Info({
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("@cf/model"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
url: "https://proxy.example/v1",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
|
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
|
||||||
@@ -102,7 +160,6 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||||||
it.effect("uses env account ID over configured account ID", () =>
|
it.effect("uses env account ID over configured account ID", () =>
|
||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
|
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||||
@@ -110,7 +167,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||||||
provider.request.body.accountId = "configured-acct"
|
provider.request.body.accountId = "configured-acct"
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
package: "test-provider",
|
package: "test-provider",
|
||||||
@@ -124,12 +181,18 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
|
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
model: new ModelV2.Info({
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("@cf/model"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
url: "https://proxy.example/v1",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: {
|
options: {
|
||||||
@@ -153,12 +216,14 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||||
api: {
|
api: {
|
||||||
|
id: ModelV2.ID.make("@cf/model"),
|
||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||||
@@ -183,11 +248,14 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-workers-ai", "alias", { api: { id: ModelV2.ID.make("@cf/api-model") } }),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")),
|
||||||
|
api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -202,12 +270,18 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
model: new ModelV2.Info({
|
||||||
api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" },
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("@cf/model"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/anthropic",
|
||||||
|
url: "https://proxy.example/v1",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
package: "@ai-sdk/anthropic",
|
package: "@ai-sdk/anthropic",
|
||||||
options: { name: "cloudflare-workers-ai" },
|
options: { name: "cloudflare-workers-ai" },
|
||||||
|
|||||||
@@ -2,10 +2,34 @@ import { describe, expect, mock } from "bun:test"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
|
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
const cohereOptions: Record<string, any>[] = []
|
const cohereOptions: Record<string, any>[] = []
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: CoherePlugin.id, effect: CoherePlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void mock.module("@ai-sdk/cohere", () => ({
|
void mock.module("@ai-sdk/cohere", () => ({
|
||||||
createCohere: (options: Record<string, any>) => {
|
createCohere: (options: Record<string, any>) => {
|
||||||
@@ -24,18 +48,32 @@ describe("CoherePlugin", () => {
|
|||||||
it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () =>
|
it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CoherePlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
const ignored = yield* plugin.trigger(
|
const ignored = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("cohere", "command"), package: "@ai-sdk/openai-compatible", options: { name: "cohere" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
|
||||||
|
api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
options: { name: "cohere" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(ignored.sdk).toBeUndefined()
|
expect(ignored.sdk).toBeUndefined()
|
||||||
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("cohere", "command"), package: "@ai-sdk/cohere", options: { name: "cohere" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
|
||||||
|
api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/cohere",
|
||||||
|
options: { name: "cohere" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -45,11 +83,14 @@ describe("CoherePlugin", () => {
|
|||||||
it.effect("uses the model provider ID as the bundled SDK name", () =>
|
it.effect("uses the model provider ID as the bundled SDK name", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, CoherePlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-cohere", "command-r-plus"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")),
|
||||||
|
api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/cohere",
|
package: "@ai-sdk/cohere",
|
||||||
options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" },
|
options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" },
|
||||||
},
|
},
|
||||||
@@ -70,10 +111,17 @@ describe("CoherePlugin", () => {
|
|||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const sdk = fakeSelectorSdk(calls)
|
const sdk = fakeSelectorSdk(calls)
|
||||||
yield* addPlugin(plugin, CoherePlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")),
|
||||||
|
api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
import { describe, expect, mock } from "bun:test"
|
import { describe, expect, mock } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
|
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
|
||||||
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { addPlugin, it, model } from "./provider-helper"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
const itAISDK = testEffect(
|
const it = testEffect(PluginTestLayer)
|
||||||
Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))),
|
const deepinfraOptions: Record<string, unknown>[] = []
|
||||||
)
|
|
||||||
const deepinfraOptions: Record<string, any>[] = []
|
|
||||||
const deepinfraLanguageModels: string[] = []
|
const deepinfraLanguageModels: string[] = []
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: DeepInfraPlugin.id, effect: DeepInfraPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
void mock.module("@ai-sdk/deepinfra", () => ({
|
void mock.module("@ai-sdk/deepinfra", () => ({
|
||||||
createDeepInfra: (options: Record<string, any>) => {
|
createDeepInfra: (options: Record<string, unknown>) => {
|
||||||
const captured = { ...options }
|
const captured = { ...options }
|
||||||
deepinfraOptions.push(captured)
|
deepinfraOptions.push(captured)
|
||||||
return {
|
return {
|
||||||
@@ -36,10 +41,17 @@ describe("DeepInfraPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetDeepInfraMock()
|
resetDeepInfraMock()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, DeepInfraPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/deepinfra",
|
||||||
|
options: { name: "deepinfra" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -50,11 +62,14 @@ describe("DeepInfraPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetDeepInfraMock()
|
resetDeepInfraMock()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, DeepInfraPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-deepinfra", "model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/deepinfra",
|
package: "@ai-sdk/deepinfra",
|
||||||
options: { name: "custom-deepinfra", apiKey: "test" },
|
options: { name: "custom-deepinfra", apiKey: "test" },
|
||||||
},
|
},
|
||||||
@@ -69,11 +84,14 @@ describe("DeepInfraPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetDeepInfraMock()
|
resetDeepInfraMock()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, DeepInfraPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("deepinfra", "model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/deepinfra",
|
package: "@ai-sdk/deepinfra",
|
||||||
options: { name: "deepinfra", apiKey: "test" },
|
options: { name: "deepinfra", apiKey: "test" },
|
||||||
},
|
},
|
||||||
@@ -88,7 +106,7 @@ describe("DeepInfraPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetDeepInfraMock()
|
resetDeepInfraMock()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, DeepInfraPlugin)
|
yield* addPlugin()
|
||||||
const packages = [
|
const packages = [
|
||||||
"unmatched-package",
|
"unmatched-package",
|
||||||
"@ai-sdk/deepinfra-compatible",
|
"@ai-sdk/deepinfra-compatible",
|
||||||
@@ -98,7 +116,14 @@ describe("DeepInfraPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ignored = yield* plugin.trigger(
|
const ignored = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||||
|
}),
|
||||||
|
package: item,
|
||||||
|
options: { name: "deepinfra" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(ignored.sdk).toBeUndefined()
|
expect(ignored.sdk).toBeUndefined()
|
||||||
@@ -106,7 +131,14 @@ describe("DeepInfraPlugin", () => {
|
|||||||
)
|
)
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/deepinfra",
|
||||||
|
options: { name: "deepinfra" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -114,17 +146,36 @@ describe("DeepInfraPlugin", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
itAISDK.effect("uses the default languageModel selection for DeepInfra models", () =>
|
it.effect("uses the default languageModel selection for DeepInfra models", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resetDeepInfraMock()
|
resetDeepInfraMock()
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
yield* addPlugin()
|
||||||
yield* addPlugin(plugin, DeepInfraPlugin)
|
const sdkEvent = yield* plugin.trigger(
|
||||||
const language = yield* aisdk.language(
|
"aisdk.sdk",
|
||||||
model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", {
|
{
|
||||||
api: { type: "aisdk", package: "@ai-sdk/deepinfra" },
|
model: new ModelV2.Info({
|
||||||
}),
|
...ModelV2.Info.empty(
|
||||||
|
ProviderV2.ID.make("deepinfra"),
|
||||||
|
ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"),
|
||||||
|
),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/deepinfra",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/deepinfra",
|
||||||
|
options: { name: "deepinfra" },
|
||||||
|
},
|
||||||
|
{},
|
||||||
)
|
)
|
||||||
|
const result = yield* plugin.trigger(
|
||||||
|
"aisdk.language",
|
||||||
|
{ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options },
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
const language = result.language ?? result.sdk.languageModel(result.model.api.id)
|
||||||
expect(language.provider).toBe("deepinfra.chat")
|
expect(language.provider).toBe("deepinfra.chat")
|
||||||
expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"])
|
expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"])
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,43 +1,40 @@
|
|||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Cause, Effect, Layer, Option } from "effect"
|
import { Cause, Effect, Layer } from "effect"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
|
import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
|
||||||
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { host } from "./host"
|
import { PluginTestLayer } from "./fixture"
|
||||||
import { fixtureProvider, it, model, npmLayer } from "./provider-helper"
|
|
||||||
|
|
||||||
|
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||||
const fixtureProviderPath = fileURLToPath(fixtureProvider)
|
const fixtureProviderPath = fileURLToPath(fixtureProvider)
|
||||||
const itWithAISDK = testEffect(
|
const it = testEffect(PluginTestLayer)
|
||||||
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
|
const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginTestLayer)))
|
||||||
)
|
|
||||||
|
|
||||||
function npmEntrypointLayer(entrypoint?: string) {
|
function npmEntrypoint(entrypoint?: string) {
|
||||||
return Layer.succeed(
|
return Npm.Service.of({
|
||||||
Npm.Service,
|
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||||
Npm.Service.of({
|
install: () => Effect.void,
|
||||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
which: () => Effect.succeed(undefined),
|
||||||
install: () => Effect.void,
|
})
|
||||||
which: () => Effect.succeed(undefined),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dynamicPlugin(layer = npmLayer) {
|
const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
|
||||||
return {
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({
|
||||||
id: DynamicProviderPlugin.id,
|
id: DynamicProviderPlugin.id,
|
||||||
effect: Effect.gen(function* () {
|
effect: DynamicProviderPlugin.effect(npm ? { ...host, npm } : host),
|
||||||
yield* DynamicProviderPlugin.effect(host({ npm: yield* Npm.Service }))
|
})
|
||||||
}).pipe(Effect.provide(layer)),
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tempEntrypoint(source: string) {
|
function tempEntrypoint(source: string) {
|
||||||
return Effect.acquireRelease(
|
return Effect.acquireRelease(
|
||||||
@@ -55,11 +52,14 @@ describe("DynamicProviderPlugin", () => {
|
|||||||
it.effect("creates an SDK from a provider factory export", () =>
|
it.effect("creates an SDK from a provider factory export", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* plugin.add(dynamicPlugin())
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom", "test-model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
|
||||||
|
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
|
||||||
|
}),
|
||||||
package: fixtureProvider,
|
package: fixtureProvider,
|
||||||
options: { name: "custom", marker: "dynamic" },
|
options: { name: "custom", marker: "dynamic" },
|
||||||
},
|
},
|
||||||
@@ -74,11 +74,14 @@ describe("DynamicProviderPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const sdk = { marker: "existing" }
|
const sdk = { marker: "existing" }
|
||||||
yield* plugin.add(dynamicPlugin())
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom", "test-model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
|
||||||
|
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
|
||||||
|
}),
|
||||||
package: fixtureProvider,
|
package: fixtureProvider,
|
||||||
options: { name: "custom", marker: "dynamic" },
|
options: { name: "custom", marker: "dynamic" },
|
||||||
},
|
},
|
||||||
@@ -91,11 +94,14 @@ describe("DynamicProviderPlugin", () => {
|
|||||||
it.effect("injects the provider ID as the SDK factory name", () =>
|
it.effect("injects the provider ID as the SDK factory name", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* plugin.add(dynamicPlugin())
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-provider", "test-model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
|
||||||
|
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
|
||||||
|
}),
|
||||||
package: fixtureProvider,
|
package: fixtureProvider,
|
||||||
options: { name: "custom-provider", marker: "dynamic" },
|
options: { name: "custom-provider", marker: "dynamic" },
|
||||||
},
|
},
|
||||||
@@ -108,11 +114,14 @@ describe("DynamicProviderPlugin", () => {
|
|||||||
it.effect("loads npm packages through their resolved import entrypoint", () =>
|
it.effect("loads npm packages through their resolved import entrypoint", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(fixtureProviderPath)))
|
yield* addPlugin(npmEntrypoint(fixtureProviderPath))
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("npm-provider", "test-model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
|
||||||
|
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" },
|
||||||
|
}),
|
||||||
package: "fixture-provider",
|
package: "fixture-provider",
|
||||||
options: { name: "npm-provider", marker: "npm" },
|
options: { name: "npm-provider", marker: "npm" },
|
||||||
},
|
},
|
||||||
@@ -126,9 +135,14 @@ describe("DynamicProviderPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
yield* plugin.add(dynamicPlugin(npmEntrypointLayer()))
|
yield* addPlugin(npmEntrypoint())
|
||||||
const exit = yield* aisdk
|
const exit = yield* aisdk
|
||||||
.language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } }))
|
.language(
|
||||||
|
new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
|
||||||
|
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
.pipe(Effect.exit)
|
.pipe(Effect.exit)
|
||||||
expect(exit._tag).toBe("Failure")
|
expect(exit._tag).toBe("Failure")
|
||||||
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
|
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
|
||||||
@@ -139,10 +153,13 @@ describe("DynamicProviderPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
yield* plugin.add(dynamicPlugin())
|
yield* addPlugin()
|
||||||
const exit = yield* aisdk
|
const exit = yield* aisdk
|
||||||
.language(
|
.language(
|
||||||
model("bad-import", "alias", { api: { type: "aisdk", package: "file:///missing/provider-factory.js" } }),
|
new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
|
||||||
|
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" },
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.pipe(Effect.exit)
|
.pipe(Effect.exit)
|
||||||
expect(exit._tag).toBe("Failure")
|
expect(exit._tag).toBe("Failure")
|
||||||
@@ -155,9 +172,14 @@ describe("DynamicProviderPlugin", () => {
|
|||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
|
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
|
||||||
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(tmp.entrypoint)))
|
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
|
||||||
const exit = yield* aisdk
|
const exit = yield* aisdk
|
||||||
.language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } }))
|
.language(
|
||||||
|
new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
|
||||||
|
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
.pipe(Effect.exit)
|
.pipe(Effect.exit)
|
||||||
expect(exit._tag).toBe("Failure")
|
expect(exit._tag).toBe("Failure")
|
||||||
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
|
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
|
||||||
@@ -168,9 +190,10 @@ describe("DynamicProviderPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
yield* plugin.add(dynamicPlugin())
|
yield* addPlugin()
|
||||||
const language = yield* aisdk.language(
|
const language = yield* aisdk.language(
|
||||||
model("custom", "alias", {
|
new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
|
||||||
api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider },
|
api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import { describe, expect, mock } from "bun:test"
|
import { describe, expect, mock } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
|
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
|
||||||
import { addPlugin, it, model } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
const gatewayCalls: Record<string, unknown>[] = []
|
const gatewayCalls: Record<string, unknown>[] = []
|
||||||
const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"]
|
const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"]
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: GatewayPlugin.id, effect: GatewayPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
mock.module("@ai-sdk/gateway", () => ({
|
mock.module("@ai-sdk/gateway", () => ({
|
||||||
createGateway(options: Record<string, unknown>) {
|
createGateway(options: Record<string, unknown>) {
|
||||||
@@ -27,10 +38,17 @@ describe("GatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
gatewayCalls.length = 0
|
gatewayCalls.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GatewayPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/gateway",
|
||||||
|
options: { name: "gateway" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -42,12 +60,19 @@ describe("GatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
gatewayCalls.length = 0
|
gatewayCalls.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("vercel", "anthropic/claude-sonnet-4"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("anthropic/claude-sonnet-4"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "test-provider",
|
||||||
|
},
|
||||||
|
}),
|
||||||
package: "@ai-sdk/gateway",
|
package: "@ai-sdk/gateway",
|
||||||
options: { name: "vercel", apiKey: "test-key" },
|
options: { name: "vercel", apiKey: "test-key" },
|
||||||
},
|
},
|
||||||
@@ -63,19 +88,33 @@ describe("GatewayPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
gatewayCalls.length = 0
|
gatewayCalls.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GatewayPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
for (const modelID of vercelGatewayModels) {
|
for (const modelID of vercelGatewayModels) {
|
||||||
const ignored = yield* plugin.trigger(
|
const ignored = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("vercel", modelID), package: "@ai-sdk/vercel", options: { name: "vercel" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
|
||||||
|
api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/vercel",
|
||||||
|
options: { name: "vercel" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(ignored.sdk).toBeUndefined()
|
expect(ignored.sdk).toBeUndefined()
|
||||||
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("vercel", modelID), package: "@ai-sdk/gateway", options: { name: "vercel" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
|
||||||
|
api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/gateway",
|
||||||
|
options: { name: "vercel" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
|
|||||||
@@ -3,19 +3,51 @@ import { Effect } from "effect"
|
|||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
|
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model, required } from "./provider-helper"
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: GithubCopilotPlugin.id, effect: GithubCopilotPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("GithubCopilotPlugin", () => {
|
describe("GithubCopilotPlugin", () => {
|
||||||
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
|
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
yield* addPlugin()
|
||||||
const ignored = yield* plugin.trigger(
|
const ignored = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("github-copilot", "gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "github-copilot" },
|
options: { name: "github-copilot" },
|
||||||
},
|
},
|
||||||
@@ -24,7 +56,10 @@ describe("GithubCopilotPlugin", () => {
|
|||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("github-copilot", "gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/github-copilot",
|
package: "@ai-sdk/github-copilot",
|
||||||
options: { name: "github-copilot" },
|
options: { name: "github-copilot" },
|
||||||
},
|
},
|
||||||
@@ -39,11 +74,14 @@ describe("GithubCopilotPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("github-copilot", "claude-sonnet-4"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -57,11 +95,14 @@ describe("GithubCopilotPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("github-copilot", "alias", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -75,30 +116,65 @@ describe("GithubCopilotPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
@@ -115,11 +191,14 @@ describe("GithubCopilotPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("github-copilot", "default", { api: { id: ModelV2.ID.make("gpt-5") } }),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -128,7 +207,10 @@ describe("GithubCopilotPlugin", () => {
|
|||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("github-copilot", "small", { api: { id: ModelV2.ID.make("gpt-5-mini") } }),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -137,7 +219,10 @@ describe("GithubCopilotPlugin", () => {
|
|||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("github-copilot", "sonnet", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -149,13 +234,12 @@ describe("GithubCopilotPlugin", () => {
|
|||||||
|
|
||||||
it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
|
it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
|
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
|
||||||
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(
|
expect(
|
||||||
required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
|
required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
|
||||||
.enabled,
|
.enabled,
|
||||||
@@ -165,13 +249,12 @@ describe("GithubCopilotPlugin", () => {
|
|||||||
|
|
||||||
it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
|
it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
|
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
|
||||||
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(
|
expect(
|
||||||
required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
|
required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
|
||||||
.enabled,
|
.enabled,
|
||||||
@@ -183,10 +266,17 @@ describe("GithubCopilotPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, GithubCopilotPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(calls).toEqual([])
|
expect(calls).toEqual([])
|
||||||
|
|||||||
@@ -1,12 +1,43 @@
|
|||||||
import { describe, expect, mock } from "bun:test"
|
import { describe, expect, mock } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
|
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, it, model, required, withEnv } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
const gitlabSDKOptions: Record<string, unknown>[] = []
|
const gitlabSDKOptions: Record<string, unknown>[] = []
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: GitLabPlugin.id, effect: GitLabPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
effect,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() =>
|
||||||
|
Object.entries(previous).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
void mock.module("gitlab-ai-provider", () => ({
|
void mock.module("gitlab-ai-provider", () => ({
|
||||||
VERSION: "test-version",
|
VERSION: "test-version",
|
||||||
@@ -32,10 +63,17 @@ describe("GitLabPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
gitlabSDKOptions.length = 0
|
gitlabSDKOptions.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GitLabPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||||
|
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "gitlab-ai-provider",
|
||||||
|
options: { name: "gitlab" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(gitlabSDKOptions).toHaveLength(1)
|
expect(gitlabSDKOptions).toHaveLength(1)
|
||||||
@@ -65,10 +103,17 @@ describe("GitLabPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
gitlabSDKOptions.length = 0
|
gitlabSDKOptions.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GitLabPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||||
|
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "gitlab-ai-provider",
|
||||||
|
options: { name: "gitlab" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example")
|
expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example")
|
||||||
@@ -86,11 +131,14 @@ describe("GitLabPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
gitlabSDKOptions.length = 0
|
gitlabSDKOptions.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GitLabPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("gitlab", "claude"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||||
|
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "gitlab-ai-provider",
|
package: "gitlab-ai-provider",
|
||||||
options: {
|
options: {
|
||||||
name: "gitlab",
|
name: "gitlab",
|
||||||
@@ -127,10 +175,17 @@ describe("GitLabPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
gitlabSDKOptions.length = 0
|
gitlabSDKOptions.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GitLabPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||||
|
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/openai",
|
||||||
|
options: { name: "gitlab" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeUndefined()
|
expect(result.sdk).toBeUndefined()
|
||||||
@@ -142,11 +197,13 @@ describe("GitLabPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: [string, unknown][] = []
|
const calls: [string, unknown][] = []
|
||||||
yield* addPlugin(plugin, GitLabPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("gitlab", "duo-workflow-custom", {
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
|
||||||
|
api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" },
|
||||||
request: {
|
request: {
|
||||||
headers: {},
|
headers: {},
|
||||||
body: { workflowRef: "ref", workflowDefinition: "definition" },
|
body: { workflowRef: "ref", workflowDefinition: "definition" },
|
||||||
@@ -178,11 +235,14 @@ describe("GitLabPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: [string, unknown][] = []
|
const calls: [string, unknown][] = []
|
||||||
yield* addPlugin(plugin, GitLabPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("gitlab", "duo-workflow-exact"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")),
|
||||||
|
api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: {
|
sdk: {
|
||||||
workflowChat: (id: string, options: unknown) => {
|
workflowChat: (id: string, options: unknown) => {
|
||||||
calls.push([id, options])
|
calls.push([id, options])
|
||||||
@@ -205,11 +265,13 @@ describe("GitLabPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: [string, unknown][] = []
|
const calls: [string, unknown][] = []
|
||||||
yield* addPlugin(plugin, GitLabPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("gitlab", "duo-workflow-custom", {
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
|
||||||
|
api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" },
|
||||||
request: {
|
request: {
|
||||||
headers: {},
|
headers: {},
|
||||||
body: { featureFlags: { request_flag: true } },
|
body: { featureFlags: { request_flag: true } },
|
||||||
@@ -234,11 +296,13 @@ describe("GitLabPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: [string, unknown][] = []
|
const calls: [string, unknown][] = []
|
||||||
yield* addPlugin(plugin, GitLabPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("gitlab", "claude", {
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||||
|
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||||
request: { headers: { h: "v" }, body: {} },
|
request: { headers: { h: "v" }, body: {} },
|
||||||
}),
|
}),
|
||||||
sdk: {
|
sdk: {
|
||||||
|
|||||||
@@ -1,10 +1,50 @@
|
|||||||
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
|
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: definition.id, effect: definition.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
effect,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
Object.entries(previous).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selector(calls: string[]) {
|
||||||
|
return (id: string) => {
|
||||||
|
calls.push(`languageModel:${id}`)
|
||||||
|
return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("GoogleVertexAnthropicPlugin", () => {
|
describe("GoogleVertexAnthropicPlugin", () => {
|
||||||
it.effect("resolves legacy project and location env on provider update", () =>
|
it.effect("resolves legacy project and location env on provider update", () =>
|
||||||
@@ -19,17 +59,19 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||||||
},
|
},
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))
|
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||||
expect(provider.request.body.project).toBe("cloud-project")
|
expect(
|
||||||
expect(provider.request.body.location).toBe("cloud-location")
|
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project,
|
||||||
|
).toBe("cloud-project")
|
||||||
|
expect(
|
||||||
|
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location,
|
||||||
|
).toBe("cloud-location")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -37,9 +79,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||||||
it.effect("keeps configured project and location over env fallback", () =>
|
it.effect("keeps configured project and location over env fallback", () =>
|
||||||
withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
|
withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||||
@@ -47,9 +87,13 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||||||
provider.request.body.location = "configured-location"
|
provider.request.body.location = "configured-location"
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))
|
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||||
expect(provider.request.body.project).toBe("configured-project")
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe(
|
||||||
expect(provider.request.body.location).toBe("configured-location")
|
"configured-project",
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location,
|
||||||
|
).toBe("configured-location")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -67,11 +111,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("google-vertex-anthropic", "claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(
|
||||||
|
ProviderV2.ID.make("google-vertex-anthropic"),
|
||||||
|
ModelV2.ID.make("claude-sonnet-4-5"),
|
||||||
|
),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/google-vertex/anthropic",
|
package: "@ai-sdk/google-vertex/anthropic",
|
||||||
options: { name: "google-vertex-anthropic" },
|
options: { name: "google-vertex-anthropic" },
|
||||||
},
|
},
|
||||||
@@ -90,11 +140,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("google-vertex-anthropic", "claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(
|
||||||
|
ProviderV2.ID.make("google-vertex-anthropic"),
|
||||||
|
ModelV2.ID.make("claude-sonnet-4-5"),
|
||||||
|
),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/google-vertex/anthropic",
|
package: "@ai-sdk/google-vertex/anthropic",
|
||||||
options: { name: "google-vertex-anthropic" },
|
options: { name: "google-vertex-anthropic" },
|
||||||
},
|
},
|
||||||
@@ -110,11 +166,14 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||||||
it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () =>
|
it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("google-vertex", "claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/google-vertex/anthropic",
|
package: "@ai-sdk/google-vertex/anthropic",
|
||||||
options: { name: "google-vertex", project: "project", location: "eu" },
|
options: { name: "google-vertex", project: "project", location: "eu" },
|
||||||
},
|
},
|
||||||
@@ -129,11 +188,14 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||||||
it.effect("keeps configured baseURL for google-vertex Anthropic models", () =>
|
it.effect("keeps configured baseURL for google-vertex Anthropic models", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("google-vertex", "claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/google-vertex/anthropic",
|
package: "@ai-sdk/google-vertex/anthropic",
|
||||||
options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" },
|
options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" },
|
||||||
},
|
},
|
||||||
@@ -146,12 +208,15 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||||||
it.effect("selects google-vertex Anthropic language models through V2 plugins", () =>
|
it.effect("selects google-vertex Anthropic language models through V2 plugins", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
yield* addPlugin(GoogleVertexPlugin)
|
||||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||||
const sdkResult = yield* plugin.trigger(
|
const sdkResult = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("google-vertex", " claude-sonnet-4-5 "),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
|
||||||
|
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/google-vertex/anthropic",
|
package: "@ai-sdk/google-vertex/anthropic",
|
||||||
options: { name: "google-vertex", project: "project", location: "us" },
|
options: { name: "google-vertex", project: "project", location: "us" },
|
||||||
},
|
},
|
||||||
@@ -160,7 +225,10 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||||||
const languageResult = yield* plugin.trigger(
|
const languageResult = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("google-vertex", " claude-sonnet-4-5 "),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
|
||||||
|
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: sdkResult.sdk,
|
sdk: sdkResult.sdk,
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -178,12 +246,18 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("google-vertex-anthropic", " claude-sonnet-4-5 "),
|
model: new ModelV2.Info({
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
...ModelV2.Info.empty(
|
||||||
|
ProviderV2.ID.make("google-vertex-anthropic"),
|
||||||
|
ModelV2.ID.make(" claude-sonnet-4-5 "),
|
||||||
|
),
|
||||||
|
api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: { languageModel: selector(calls) },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@@ -196,12 +270,15 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, GoogleVertexAnthropicPlugin)
|
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("google-vertex", "claude-sonnet-4-5"),
|
model: new ModelV2.Info({
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: { languageModel: selector(calls) },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -1,13 +1,63 @@
|
|||||||
import { describe, expect, mock } from "bun:test"
|
import { describe, expect, mock } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
|
import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
const vertexOptions: Record<string, any>[] = []
|
const vertexOptions: Record<string, any>[] = []
|
||||||
const googleAuthOptions: Record<string, any>[] = []
|
const googleAuthOptions: Record<string, any>[] = []
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: GoogleVertexPlugin.id, effect: GoogleVertexPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
effect,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() =>
|
||||||
|
Object.entries(previous).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void mock.module("@ai-sdk/google-vertex", () => ({
|
void mock.module("@ai-sdk/google-vertex", () => ({
|
||||||
createVertex: (options: Record<string, any>) => {
|
createVertex: (options: Record<string, any>) => {
|
||||||
@@ -37,9 +87,7 @@ void mock.module("google-auth-library", () => ({
|
|||||||
describe("GoogleVertexPlugin", () => {
|
describe("GoogleVertexPlugin", () => {
|
||||||
it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () =>
|
it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
|
catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
|
||||||
provider.api = {
|
provider.api = {
|
||||||
@@ -49,6 +97,7 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode))
|
const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode))
|
||||||
expect(provider.request.body).toEqual({})
|
expect(provider.request.body).toEqual({})
|
||||||
@@ -67,9 +116,7 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
},
|
},
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||||
provider.api = {
|
provider.api = {
|
||||||
@@ -79,6 +126,7 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
yield* addPlugin()
|
||||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||||
expect(provider.request.body.project).toBe("google-cloud-project")
|
expect(provider.request.body.project).toBe("google-cloud-project")
|
||||||
expect(provider.request.body.location).toBe("google-vertex-location")
|
expect(provider.request.body.location).toBe("google-vertex-location")
|
||||||
@@ -107,7 +155,6 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
vertexOptions.length = 0
|
vertexOptions.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||||
provider.api = {
|
provider.api = {
|
||||||
@@ -117,12 +164,18 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
yield* addPlugin()
|
||||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("google-vertex", "gemini", {
|
model: new ModelV2.Info({
|
||||||
api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
|
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("gemini"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/google-vertex",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
package: "@ai-sdk/google-vertex",
|
package: "@ai-sdk/google-vertex",
|
||||||
options: { name: "google-vertex" },
|
options: { name: "google-vertex" },
|
||||||
@@ -154,9 +207,7 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
},
|
},
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||||
provider.api = {
|
provider.api = {
|
||||||
@@ -168,6 +219,7 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
provider.request.body.location = "global"
|
provider.request.body.location = "global"
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
yield* addPlugin()
|
||||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||||
expect(provider.request.body.project).toBe("config-project")
|
expect(provider.request.body.project).toBe("config-project")
|
||||||
expect(provider.request.body.location).toBe("global")
|
expect(provider.request.body.location).toBe("global")
|
||||||
@@ -182,9 +234,7 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
|
|
||||||
it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
|
it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||||
provider.api = {
|
provider.api = {
|
||||||
@@ -196,6 +246,7 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
provider.request.body.location = "eu"
|
provider.request.body.location = "eu"
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
yield* addPlugin()
|
||||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||||
expect(provider.api).toEqual({
|
expect(provider.api).toEqual({
|
||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
@@ -217,15 +268,14 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
},
|
},
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" }
|
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" }
|
||||||
provider.request.body.project = "config-project"
|
provider.request.body.project = "config-project"
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
yield* addPlugin()
|
||||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||||
expect(provider.request.body.project).toBe("config-project")
|
expect(provider.request.body.project).toBe("config-project")
|
||||||
expect(provider.request.body.location).toBe("us-central1")
|
expect(provider.request.body.location).toBe("us-central1")
|
||||||
@@ -243,12 +293,17 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
vertexOptions.length = 0
|
vertexOptions.length = 0
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("google-vertex", "gemini", {
|
model: new ModelV2.Info({
|
||||||
api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
|
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("gemini"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/google-vertex",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
package: "@ai-sdk/google-vertex",
|
package: "@ai-sdk/google-vertex",
|
||||||
options: { name: "google-vertex" },
|
options: { name: "google-vertex" },
|
||||||
@@ -268,21 +323,17 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
googleAuthOptions.length = 0
|
googleAuthOptions.length = 0
|
||||||
const fetchCalls: { input: Parameters<typeof fetch>[0]; init?: RequestInit }[] = []
|
const fetchCalls: { input: Parameters<typeof fetch>[0]; init?: RequestInit }[] = []
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.add({
|
yield* plugin.hook("aisdk.sdk", (evt) =>
|
||||||
id: PluginV2.ID.make("capture-openai-compatible"),
|
Effect.promise(async () => {
|
||||||
effect: Effect.succeed({
|
if (evt.model.providerID !== "google-vertex") return
|
||||||
"aisdk.sdk": (evt) =>
|
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||||
Effect.promise(async () => {
|
expect(typeof evt.options.fetch).toBe("function")
|
||||||
if (evt.model.providerID !== "google-vertex") return
|
await evt.options.fetch("https://vertex.example", {
|
||||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
headers: { "x-test": "1" },
|
||||||
expect(typeof evt.options.fetch).toBe("function")
|
})
|
||||||
await evt.options.fetch("https://vertex.example", {
|
|
||||||
headers: { "x-test": "1" },
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
})
|
)
|
||||||
const originalFetch = fetch
|
const originalFetch = fetch
|
||||||
;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async (
|
;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async (
|
||||||
input: Parameters<typeof fetch>[0],
|
input: Parameters<typeof fetch>[0],
|
||||||
@@ -297,8 +348,13 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
plugin.trigger(
|
plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("google-vertex", "gemini", {
|
model: new ModelV2.Info({
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("gemini"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "google-vertex" },
|
options: { name: "google-vertex" },
|
||||||
@@ -322,11 +378,14 @@ describe("GoogleVertexPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, GoogleVertexPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("google-vertex", " gemini-2.5-pro "),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")),
|
||||||
|
api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
|
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
|
||||||
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { addPlugin, it, model } from "./provider-helper"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
const itWithAISDK = testEffect(
|
const it = testEffect(PluginTestLayer)
|
||||||
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
|
|
||||||
)
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: GooglePlugin.id, effect: GooglePlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
describe("GooglePlugin", () => {
|
describe("GooglePlugin", () => {
|
||||||
it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () =>
|
it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GooglePlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-google", "gemini"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")),
|
||||||
|
api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/google",
|
package: "@ai-sdk/google",
|
||||||
options: { name: "custom-google", apiKey: "test" },
|
options: { name: "custom-google", apiKey: "test" },
|
||||||
},
|
},
|
||||||
@@ -34,34 +41,49 @@ describe("GooglePlugin", () => {
|
|||||||
it.effect("ignores non-Google SDK packages", () =>
|
it.effect("ignores non-Google SDK packages", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GooglePlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")),
|
||||||
|
api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/google-vertex",
|
||||||
|
options: { name: "google" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeUndefined()
|
expect(result.sdk).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
itWithAISDK.effect("uses default languageModel loading with provider ID parity", () =>
|
it.effect("uses default languageModel loading with provider ID parity", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
yield* addPlugin()
|
||||||
yield* addPlugin(plugin, GooglePlugin)
|
const sdkEvent = yield* plugin.trigger(
|
||||||
const language = yield* aisdk.language(
|
"aisdk.sdk",
|
||||||
model("custom-google", "alias", {
|
{
|
||||||
api: {
|
model: new ModelV2.Info({
|
||||||
id: ModelV2.ID.make("gemini-api"),
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
|
||||||
type: "aisdk",
|
api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" },
|
||||||
package: "@ai-sdk/google",
|
}),
|
||||||
},
|
package: "@ai-sdk/google",
|
||||||
request: {
|
options: { name: "custom-google", apiKey: "test" },
|
||||||
headers: {},
|
},
|
||||||
body: { apiKey: "test" },
|
{},
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
const result = yield* plugin.trigger(
|
||||||
|
"aisdk.language",
|
||||||
|
{
|
||||||
|
model: sdkEvent.model,
|
||||||
|
sdk: sdkEvent.sdk,
|
||||||
|
options: sdkEvent.options,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
const language = result.language ?? result.sdk.languageModel(result.model.api.id)
|
||||||
expect(language.modelId).toBe("gemini-api")
|
expect(language.modelId).toBe("gemini-api")
|
||||||
expect(language.provider).toBe("custom-google")
|
expect(language.provider).toBe("custom-google")
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { createGroq } from "@ai-sdk/groq"
|
import { createGroq } from "@ai-sdk/groq"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
|
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
|
||||||
import { addPlugin, it, model } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
const aisdkIt = testEffect(
|
const it = testEffect(PluginTestLayer)
|
||||||
AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))),
|
|
||||||
)
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: GroqPlugin.id, effect: GroqPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
describe("GroqPlugin", () => {
|
describe("GroqPlugin", () => {
|
||||||
it.effect("creates a Groq SDK for @ai-sdk/groq", () =>
|
it.effect("creates a Groq SDK for @ai-sdk/groq", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GroqPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
|
||||||
|
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/groq",
|
||||||
|
options: { name: "groq" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -30,10 +41,17 @@ describe("GroqPlugin", () => {
|
|||||||
it.effect("ignores non-Groq SDK packages", () =>
|
it.effect("ignores non-Groq SDK packages", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GroqPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
|
||||||
|
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
options: { name: "groq" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeUndefined()
|
expect(result.sdk).toBeUndefined()
|
||||||
@@ -43,10 +61,17 @@ describe("GroqPlugin", () => {
|
|||||||
it.effect("only matches the bundled @ai-sdk/groq package exactly", () =>
|
it.effect("only matches the bundled @ai-sdk/groq package exactly", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GroqPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
|
||||||
|
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/groq/compat",
|
||||||
|
options: { name: "groq" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeUndefined()
|
expect(result.sdk).toBeUndefined()
|
||||||
@@ -56,11 +81,14 @@ describe("GroqPlugin", () => {
|
|||||||
it.effect("matches the old bundled Groq SDK provider naming", () =>
|
it.effect("matches the old bundled Groq SDK provider naming", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, GroqPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-groq", "llama"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")),
|
||||||
|
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/groq",
|
package: "@ai-sdk/groq",
|
||||||
options: { name: "custom-groq", apiKey: "test" },
|
options: { name: "custom-groq", apiKey: "test" },
|
||||||
},
|
},
|
||||||
@@ -75,26 +103,32 @@ describe("GroqPlugin", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
aisdkIt.effect("uses the default languageModel(api.id) behavior", () =>
|
it.effect("uses the default languageModel(api.id) behavior", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
yield* addPlugin()
|
||||||
yield* addPlugin(plugin, GroqPlugin)
|
const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters<typeof createGroq>[0] & {
|
||||||
const result = yield* aisdk.language(
|
name: string
|
||||||
model("groq", "alias", {
|
})
|
||||||
api: {
|
const result = yield* plugin.trigger(
|
||||||
id: ModelV2.ID.make("llama-api"),
|
"aisdk.language",
|
||||||
type: "aisdk",
|
{
|
||||||
package: "@ai-sdk/groq",
|
model: new ModelV2.Info({
|
||||||
},
|
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")),
|
||||||
request: {
|
api: {
|
||||||
headers: {},
|
id: ModelV2.ID.make("llama-api"),
|
||||||
body: { apiKey: "test" },
|
type: "aisdk",
|
||||||
},
|
package: "@ai-sdk/groq",
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
sdk,
|
||||||
|
options: { name: "groq", apiKey: "test" },
|
||||||
|
},
|
||||||
|
{},
|
||||||
)
|
)
|
||||||
expect(result.modelId).toBe("llama-api")
|
const language = result.language ?? sdk.languageModel(result.model.api.id)
|
||||||
expect(result.provider).toBe("groq.chat")
|
expect(language.modelId).toBe("llama-api")
|
||||||
|
expect(language.provider).toBe("groq.chat")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
import { Npm } from "@opencode-ai/core/npm"
|
|
||||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
|
||||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
|
||||||
import { expect } from "bun:test"
|
|
||||||
import { Effect, Layer, Option } from "effect"
|
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
|
||||||
import { Location } from "@opencode-ai/core/location"
|
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
||||||
import { location } from "../fixture/location"
|
|
||||||
import { testEffect } from "../lib/effect"
|
|
||||||
import { aisdkHost, catalogHost, host, integrationHost } from "./host"
|
|
||||||
|
|
||||||
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
|
||||||
|
|
||||||
export function required<T>(value: T | undefined): T {
|
|
||||||
if (value === undefined) throw new Error("Expected value")
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
const locationLayer = Layer.succeed(
|
|
||||||
Location.Service,
|
|
||||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const npmLayer = Layer.succeed(
|
|
||||||
Npm.Service,
|
|
||||||
Npm.Service.of({
|
|
||||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
|
||||||
install: () => Effect.void,
|
|
||||||
which: () => Effect.succeed(undefined),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const catalogLayer = Layer.succeed(
|
|
||||||
Catalog.Service,
|
|
||||||
Catalog.Service.of({
|
|
||||||
transform: (_transform) => Effect.die("unexpected catalog.transform"),
|
|
||||||
rebuild: () => Effect.die("unexpected catalog.rebuild"),
|
|
||||||
provider: {
|
|
||||||
get: () => Effect.die("unexpected provider.get"),
|
|
||||||
all: () => Effect.succeed([]),
|
|
||||||
available: () => Effect.succeed([]),
|
|
||||||
},
|
|
||||||
model: {
|
|
||||||
get: () => Effect.die("unexpected model.get"),
|
|
||||||
all: () => Effect.succeed([]),
|
|
||||||
available: () => Effect.succeed([]),
|
|
||||||
default: () => Effect.succeed(undefined),
|
|
||||||
small: () => Effect.succeed(undefined),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const integrations = Integration.locationLayer.pipe(
|
|
||||||
Layer.provide(EventV2.defaultLayer),
|
|
||||||
Layer.provide(
|
|
||||||
Layer.mock(Credential.Service)({
|
|
||||||
create: () => Effect.die("unexpected credential creation"),
|
|
||||||
all: () => Effect.succeed([]),
|
|
||||||
list: () => Effect.succeed([]),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const it = testEffect(
|
|
||||||
Catalog.locationLayer.pipe(
|
|
||||||
Layer.provideMerge(integrations),
|
|
||||||
Layer.provideMerge(
|
|
||||||
Layer.mock(Credential.Service)({
|
|
||||||
all: () => Effect.succeed([]),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Layer.provideMerge(EventV2.defaultLayer),
|
|
||||||
Layer.provideMerge(locationLayer),
|
|
||||||
Layer.provideMerge(npmLayer),
|
|
||||||
Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
export function addPlugin(plugin: PluginV2.Interface, definition: Plugin<any>) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const catalog = yield* Effect.serviceOption(Catalog.Service)
|
|
||||||
const integration = yield* Effect.serviceOption(Integration.Service)
|
|
||||||
const npm = yield* Effect.serviceOption(Npm.Service)
|
|
||||||
const effect =
|
|
||||||
typeof definition.effect === "function"
|
|
||||||
? definition.effect(
|
|
||||||
host({
|
|
||||||
aisdk: aisdkHost(plugin),
|
|
||||||
...(Option.isSome(catalog) ? { catalog: catalogHost(catalog.value) } : {}),
|
|
||||||
...(Option.isSome(integration) ? { integration: integrationHost(integration.value) } : {}),
|
|
||||||
...(Option.isSome(npm) ? { npm: npm.value } : {}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: definition.effect
|
|
||||||
yield* plugin.add({ id: definition.id, effect })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProviderInput = Partial<Omit<ProviderV2.Info, "api" | "request">> & {
|
|
||||||
api?: ProviderV2.Api
|
|
||||||
request?: ProviderV2.Request
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelInput = Partial<Omit<ModelV2.Info, "api" | "request">> & {
|
|
||||||
api?: (ProviderV2.Api & { id?: ModelV2.ID }) | { id: ModelV2.ID }
|
|
||||||
request?: ModelV2.Info["request"]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function provider(providerID: string, options?: ProviderInput) {
|
|
||||||
return new ProviderV2.Info({
|
|
||||||
...ProviderV2.Info.empty(ProviderV2.ID.make(providerID)),
|
|
||||||
api: options?.api ?? {
|
|
||||||
type: "aisdk",
|
|
||||||
package: "test-provider",
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
request: {
|
|
||||||
headers: {},
|
|
||||||
body: {},
|
|
||||||
...options?.request,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function model(providerID: string, modelID: string, options?: ModelInput) {
|
|
||||||
return new ModelV2.Info({
|
|
||||||
...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
|
||||||
...options,
|
|
||||||
api:
|
|
||||||
options?.api && "type" in options.api
|
|
||||||
? { id: ModelV2.ID.make(modelID), ...options.api }
|
|
||||||
: {
|
|
||||||
id: ModelV2.ID.make(modelID),
|
|
||||||
...options?.api,
|
|
||||||
type: "aisdk",
|
|
||||||
package: "test-provider",
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
headers: {},
|
|
||||||
body: {},
|
|
||||||
...options?.request,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
|
||||||
return Effect.acquireUseRelease(
|
|
||||||
Effect.sync(() => {
|
|
||||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
|
||||||
for (const [key, value] of Object.entries(vars)) {
|
|
||||||
if (value === undefined) delete process.env[key]
|
|
||||||
else process.env[key] = value
|
|
||||||
}
|
|
||||||
return previous
|
|
||||||
}),
|
|
||||||
() => fx(),
|
|
||||||
(previous) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
for (const [key, value] of Object.entries(previous)) {
|
|
||||||
if (value === undefined) delete process.env[key]
|
|
||||||
else process.env[key] = value
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fakeSelectorSdk(calls: string[]) {
|
|
||||||
const make = (method: string) => (id: string) => {
|
|
||||||
calls.push(`${method}:${id}`)
|
|
||||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
responses: make("responses"),
|
|
||||||
messages: make("messages"),
|
|
||||||
chat: make("chat"),
|
|
||||||
languageModel: make("languageModel"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expectPluginRegistered(ids: string[], id: string) {
|
|
||||||
expect(ids).toContain(PluginV2.ID.make(id))
|
|
||||||
}
|
|
||||||
@@ -2,96 +2,98 @@ import { describe, expect } from "bun:test"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||||
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
|
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: KiloPlugin.id, effect: KiloPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
describe("KiloPlugin", () => {
|
describe("KiloPlugin", () => {
|
||||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||||
Effect.sync(() =>
|
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))),
|
||||||
expectPluginRegistered(
|
|
||||||
ProviderPlugins.map((item) => item.id),
|
|
||||||
"kilo",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("applies legacy referer headers only to kilo", () =>
|
it.effect("applies legacy referer headers only to kilo", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, KiloPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const kilo = provider("kilo", {
|
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
provider.api = {
|
||||||
request: { headers: { Existing: "value" }, body: {} },
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
url: "https://api.kilo.ai/api/gateway",
|
||||||
|
}
|
||||||
|
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||||
})
|
})
|
||||||
catalog.provider.update(kilo.id, (draft) => {
|
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||||
draft.api = kilo.api
|
|
||||||
draft.request = kilo.request
|
|
||||||
})
|
|
||||||
catalog.provider.update(provider("openrouter").id, () => {})
|
|
||||||
})
|
})
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({
|
yield* addPlugin()
|
||||||
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
|
||||||
Existing: "value",
|
Existing: "value",
|
||||||
"HTTP-Referer": "https://opencode.ai/",
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
})
|
})
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
|
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("uses the exact legacy Kilo header casing and set", () =>
|
it.effect("uses the exact legacy Kilo header casing and set", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, KiloPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("kilo", {
|
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
provider.api = {
|
||||||
})
|
type: "aisdk",
|
||||||
catalog.provider.update(item.id, (draft) => {
|
package: "@ai-sdk/openai-compatible",
|
||||||
draft.api = item.api
|
url: "https://api.kilo.ai/api/gateway",
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("kilo")))
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
|
||||||
expect(result.request.headers).toEqual({
|
|
||||||
"HTTP-Referer": "https://opencode.ai/",
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
})
|
})
|
||||||
expect(result.request.headers).not.toHaveProperty("http-referer")
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty(
|
||||||
expect(result.request.headers).not.toHaveProperty("x-title")
|
"http-referer",
|
||||||
expect(result.request.headers).not.toHaveProperty("X-Source")
|
)
|
||||||
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("x-title")
|
||||||
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("X-Source")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
|
it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, KiloPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const kilo = provider("kilo", {
|
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
provider.api = {
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
url: "https://api.kilo.ai/api/gateway",
|
||||||
|
}
|
||||||
})
|
})
|
||||||
catalog.provider.update(kilo.id, (draft) => {
|
catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => {
|
||||||
draft.api = kilo.api
|
provider.api = { type: "aisdk", package: "kilo" }
|
||||||
})
|
|
||||||
const custom = provider("custom-kilo", {
|
|
||||||
api: { type: "aisdk", package: "kilo" },
|
|
||||||
})
|
|
||||||
catalog.provider.update(custom.id, (draft) => {
|
|
||||||
draft.api = custom.api
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
|
||||||
"HTTP-Referer": "https://opencode.ai/",
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
})
|
})
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({})
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.request.headers).toEqual({})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,36 +3,28 @@ import { Effect } from "effect"
|
|||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||||
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
|
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { expectPluginRegistered, it, provider, required } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
import { catalogHost, host, integrationHost } from "./host"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: LLMGatewayPlugin.id, effect: LLMGatewayPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
describe("LLMGatewayPlugin", () => {
|
describe("LLMGatewayPlugin", () => {
|
||||||
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
|
|
||||||
const integrations = yield* Integration.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
yield* plugin.add({
|
|
||||||
...LLMGatewayPlugin,
|
|
||||||
effect: LLMGatewayPlugin.effect(
|
|
||||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||||
Effect.sync(() =>
|
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))),
|
||||||
expectPluginRegistered(
|
|
||||||
ProviderPlugins.map((item) => item.id),
|
|
||||||
"llmgateway",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
|
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
yield* integrations.transform((editor) => {
|
yield* integrations.transform((editor) => {
|
||||||
@@ -40,43 +32,48 @@ describe("LLMGatewayPlugin", () => {
|
|||||||
editor.update(Integration.ID.make("openrouter"), () => {})
|
editor.update(Integration.ID.make("openrouter"), () => {})
|
||||||
})
|
})
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const llmgateway = provider("llmgateway", {
|
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
provider.api = {
|
||||||
request: { headers: { Existing: "value" }, body: {} },
|
type: "aisdk",
|
||||||
})
|
package: "@ai-sdk/openai-compatible",
|
||||||
catalog.provider.update(llmgateway.id, (draft) => {
|
url: "https://api.llmgateway.io/v1",
|
||||||
draft.api = llmgateway.api
|
}
|
||||||
draft.request = llmgateway.request
|
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||||
})
|
})
|
||||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||||
})
|
})
|
||||||
yield* add(plugin)
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({
|
||||||
Existing: "value",
|
Existing: "value",
|
||||||
"HTTP-Referer": "https://opencode.ai/",
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
"X-Source": "opencode",
|
"X-Source": "opencode",
|
||||||
})
|
})
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
|
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("does not apply legacy headers to a disabled llmgateway provider", () =>
|
it.effect("does not apply legacy headers to a disabled llmgateway provider", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* add(plugin)
|
const integrations = yield* Integration.Service
|
||||||
|
yield* integrations.transform((editor) => {
|
||||||
|
editor.update(Integration.ID.make("llmgateway"), () => {})
|
||||||
|
})
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("llmgateway", {
|
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
provider.disabled = true
|
||||||
})
|
provider.api = {
|
||||||
catalog.provider.update(item.id, (draft) => {
|
type: "aisdk",
|
||||||
draft.api = item.api
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
url: "https://api.llmgateway.io/v1",
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true)
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
|
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: MistralPlugin.id, effect: MistralPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
describe("MistralPlugin", () => {
|
describe("MistralPlugin", () => {
|
||||||
it.effect("creates a Mistral SDK for @ai-sdk/mistral", () =>
|
it.effect("creates a Mistral SDK for @ai-sdk/mistral", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, MistralPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
|
||||||
|
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/mistral",
|
||||||
|
options: { name: "mistral" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -22,11 +41,14 @@ describe("MistralPlugin", () => {
|
|||||||
it.effect("ignores non-Mistral SDK packages", () =>
|
it.effect("ignores non-Mistral SDK packages", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, MistralPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("mistral", "mistral-large"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
|
||||||
|
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "mistral" },
|
options: { name: "mistral" },
|
||||||
},
|
},
|
||||||
@@ -40,19 +62,22 @@ describe("MistralPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const providers: string[] = []
|
const providers: string[] = []
|
||||||
yield* addPlugin(plugin, MistralPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.add({
|
yield* plugin.hook("aisdk.sdk", (event) =>
|
||||||
id: PluginV2.ID.make("mistral-sdk-inspector"),
|
Effect.sync(() => {
|
||||||
effect: Effect.succeed({
|
providers.push(event.sdk.languageModel("mistral-large").provider)
|
||||||
"aisdk.sdk": (evt) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
providers.push(evt.sdk.languageModel("mistral-large").provider)
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
})
|
)
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
|
||||||
|
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/mistral",
|
||||||
|
options: { name: "mistral" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -64,20 +89,19 @@ describe("MistralPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const providers: string[] = []
|
const providers: string[] = []
|
||||||
yield* addPlugin(plugin, MistralPlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.add({
|
yield* plugin.hook("aisdk.sdk", (event) =>
|
||||||
id: PluginV2.ID.make("mistral-sdk-inspector"),
|
Effect.sync(() => {
|
||||||
effect: Effect.succeed({
|
providers.push(event.sdk.languageModel("mistral-large").provider)
|
||||||
"aisdk.sdk": (evt) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
providers.push(evt.sdk.languageModel("mistral-large").provider)
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
})
|
)
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-mistral", "mistral-large"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")),
|
||||||
|
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/mistral",
|
package: "@ai-sdk/mistral",
|
||||||
options: { name: "custom-mistral" },
|
options: { name: "custom-mistral" },
|
||||||
},
|
},
|
||||||
@@ -91,11 +115,23 @@ describe("MistralPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const sdk = fakeSelectorSdk(calls)
|
const sdk = {
|
||||||
yield* addPlugin(plugin, MistralPlugin)
|
languageModel: (id: string) => {
|
||||||
|
calls.push(`languageModel:${id}`)
|
||||||
|
return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")),
|
||||||
|
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
const language = result.language ?? sdk.languageModel(result.model.api.id)
|
const language = result.language ?? sdk.languageModel(result.model.api.id)
|
||||||
|
|||||||
@@ -2,64 +2,66 @@ import { describe, expect } from "bun:test"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||||
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
|
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: NvidiaPlugin.id, effect: NvidiaPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
describe("NvidiaPlugin", () => {
|
describe("NvidiaPlugin", () => {
|
||||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||||
Effect.sync(() =>
|
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))),
|
||||||
expectPluginRegistered(
|
|
||||||
ProviderPlugins.map((item) => item.id),
|
|
||||||
"nvidia",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
|
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, NvidiaPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const nvidia = provider("nvidia", {
|
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
provider.api = {
|
||||||
request: { headers: { Existing: "value" }, body: {} },
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
url: "https://integrate.api.nvidia.com/v1",
|
||||||
|
}
|
||||||
|
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||||
})
|
})
|
||||||
catalog.provider.update(nvidia.id, (draft) => {
|
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||||
draft.api = nvidia.api
|
|
||||||
draft.request = nvidia.request
|
|
||||||
})
|
|
||||||
catalog.provider.update(provider("openrouter").id, () => {})
|
|
||||||
})
|
})
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
|
yield* addPlugin()
|
||||||
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
|
||||||
Existing: "value",
|
Existing: "value",
|
||||||
"HTTP-Referer": "https://opencode.ai/",
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||||
})
|
})
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
|
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("adds billing origin for custom NVIDIA endpoints", () =>
|
it.effect("adds billing origin for custom NVIDIA endpoints", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, NvidiaPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("nvidia", {
|
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
provider.api = {
|
||||||
request: { headers: {}, body: {} },
|
type: "aisdk",
|
||||||
})
|
package: "@ai-sdk/openai-compatible",
|
||||||
catalog.provider.update(item.id, (draft) => {
|
url: "https://integrate.api.nvidia.com/v1",
|
||||||
draft.api = item.api
|
}
|
||||||
draft.request = item.request
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
|
||||||
"HTTP-Referer": "https://opencode.ai/",
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||||
@@ -69,24 +71,23 @@ describe("NvidiaPlugin", () => {
|
|||||||
|
|
||||||
it.effect("preserves an explicit NVIDIA billing origin header", () =>
|
it.effect("preserves an explicit NVIDIA billing origin header", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, NvidiaPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("nvidia", {
|
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
provider.api = {
|
||||||
request: {
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
url: "https://integrate.api.nvidia.com/v1",
|
||||||
|
}
|
||||||
|
provider.request = {
|
||||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||||
body: { baseURL: "https://integrate.api.nvidia.com/v1" },
|
body: { baseURL: "https://integrate.api.nvidia.com/v1" },
|
||||||
},
|
}
|
||||||
})
|
|
||||||
catalog.provider.update(item.id, (draft) => {
|
|
||||||
draft.api = item.api
|
|
||||||
draft.request = item.request
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
|
||||||
"HTTP-Referer": "https://opencode.ai/",
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
|
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
|
||||||
|
|||||||
@@ -1,23 +1,45 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible"
|
import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible"
|
||||||
import { addPlugin, it, model } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: OpenAICompatiblePlugin.id, effect: OpenAICompatiblePlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
describe("OpenAICompatiblePlugin", () => {
|
describe("OpenAICompatiblePlugin", () => {
|
||||||
it.effect("preserves explicit includeUsage false and defaults it to true", () =>
|
it.effect("preserves explicit includeUsage false and defaults it to true", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, OpenAICompatiblePlugin)
|
yield* addPlugin()
|
||||||
const defaulted = yield* plugin.trigger(
|
const defaulted = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
options: { name: "custom" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
const disabled = yield* plugin.trigger(
|
const disabled = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom", "model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "custom", includeUsage: false },
|
options: { name: "custom", includeUsage: false },
|
||||||
},
|
},
|
||||||
@@ -31,11 +53,14 @@ describe("OpenAICompatiblePlugin", () => {
|
|||||||
it.effect("defaults includeUsage for OpenAI-compatible package matches", () =>
|
it.effect("defaults includeUsage for OpenAI-compatible package matches", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, OpenAICompatiblePlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom", "model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "file:///tmp/@ai-sdk/openai-compatible-provider.js",
|
package: "file:///tmp/@ai-sdk/openai-compatible-provider.js",
|
||||||
options: { name: "custom" },
|
options: { name: "custom" },
|
||||||
},
|
},
|
||||||
@@ -49,20 +74,19 @@ describe("OpenAICompatiblePlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const observed: string[] = []
|
const observed: string[] = []
|
||||||
yield* addPlugin(plugin, OpenAICompatiblePlugin)
|
yield* addPlugin()
|
||||||
yield* plugin.add({
|
yield* plugin.hook("aisdk.sdk", (event) =>
|
||||||
id: PluginV2.ID.make("inspector"),
|
Effect.sync(() => {
|
||||||
effect: Effect.succeed({
|
observed.push(event.sdk.languageModel("model").provider)
|
||||||
"aisdk.sdk": (evt) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
observed.push(evt.sdk.languageModel("model").provider)
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
})
|
)
|
||||||
yield* plugin.trigger(
|
yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-provider", "model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "custom-provider", baseURL: "https://example.com/v1" },
|
options: { name: "custom-provider", baseURL: "https://example.com/v1" },
|
||||||
},
|
},
|
||||||
@@ -85,11 +109,14 @@ describe("OpenAICompatiblePlugin", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
yield* addPlugin(plugin, OpenAICompatiblePlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("cloudflare-workers-ai", "model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "cloudflare-workers-ai" },
|
options: { name: "cloudflare-workers-ai" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,28 +1,50 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { fakeSelectorSdk, it, model, provider, required } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
import { host, integrationHost } from "./host"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
function add(plugin: PluginV2.Interface, integrations: Integration.Interface) {
|
const it = testEffect(PluginTestLayer)
|
||||||
return plugin.add({
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
yield* plugin.add({
|
||||||
id: OpenAIPlugin.id,
|
id: OpenAIPlugin.id,
|
||||||
effect: OpenAIPlugin.effect(host({ integration: integrationHost(integrations) })).pipe(
|
effect: OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)),
|
||||||
Effect.provideService(Integration.Service, integrations),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("OpenAIPlugin", () => {
|
describe("OpenAIPlugin", () => {
|
||||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
yield* addPlugin()
|
||||||
yield* add(plugin, yield* Integration.Service)
|
|
||||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
|
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
|
||||||
{
|
{
|
||||||
id: Integration.MethodID.make("chatgpt-browser"),
|
id: Integration.MethodID.make("chatgpt-browser"),
|
||||||
@@ -41,11 +63,14 @@ describe("OpenAIPlugin", () => {
|
|||||||
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
|
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* add(plugin, yield* Integration.Service)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-openai", "gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai",
|
package: "@ai-sdk/openai",
|
||||||
options: { name: "custom-openai", apiKey: "test" },
|
options: { name: "custom-openai", apiKey: "test" },
|
||||||
},
|
},
|
||||||
@@ -58,10 +83,17 @@ describe("OpenAIPlugin", () => {
|
|||||||
it.effect("ignores non-OpenAI SDK packages", () =>
|
it.effect("ignores non-OpenAI SDK packages", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* add(plugin, yield* Integration.Service)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
options: { name: "openai" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeUndefined()
|
expect(result.sdk).toBeUndefined()
|
||||||
@@ -72,11 +104,12 @@ describe("OpenAIPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* add(plugin, yield* Integration.Service)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("openai", "alias", {
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")),
|
||||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
}),
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
@@ -93,10 +126,17 @@ describe("OpenAIPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* add(plugin, yield* Integration.Service)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(calls).toEqual([])
|
expect(calls).toEqual([])
|
||||||
@@ -106,17 +146,19 @@ describe("OpenAIPlugin", () => {
|
|||||||
|
|
||||||
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
|
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* add(plugin, yield* Integration.Service)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } })
|
const item = new ProviderV2.Info({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||||
|
api: { type: "aisdk", package: "@ai-sdk/openai" },
|
||||||
|
})
|
||||||
catalog.provider.update(item.id, (draft) => {
|
catalog.provider.update(item.id, (draft) => {
|
||||||
draft.api = item.api
|
draft.api = item.api
|
||||||
})
|
})
|
||||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
|
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
|
||||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true)
|
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true)
|
||||||
expect(
|
expect(
|
||||||
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
|
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
|
||||||
@@ -126,14 +168,18 @@ describe("OpenAIPlugin", () => {
|
|||||||
|
|
||||||
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
|
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* add(plugin, yield* Integration.Service)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("custom-openai")
|
const item = new ProviderV2.Info({
|
||||||
catalog.provider.update(item.id, () => {})
|
...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")),
|
||||||
|
api: { type: "aisdk", package: "test-provider" },
|
||||||
|
})
|
||||||
|
catalog.provider.update(item.id, (draft) => {
|
||||||
|
draft.api = item.api
|
||||||
|
})
|
||||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(
|
expect(
|
||||||
required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest")))
|
required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest")))
|
||||||
.enabled,
|
.enabled,
|
||||||
|
|||||||
@@ -1,45 +1,72 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer, Option } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { testEffect } from "../lib/effect"
|
||||||
import { location } from "../fixture/location"
|
import { PluginTestLayer } from "./fixture"
|
||||||
import { it, model, provider, required, withEnv } from "./provider-helper"
|
|
||||||
import { catalogHost, host, integrationHost } from "./host"
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: OpencodePlugin.id, effect: OpencodePlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
effect,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() =>
|
||||||
|
Object.entries(previous).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
|
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
|
||||||
const locationLayer = Layer.succeed(
|
|
||||||
Location.Service,
|
|
||||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const pluginWithIntegrations = (catalog: Catalog.Interface, integrations: Integration.Interface) => ({
|
|
||||||
...OpencodePlugin,
|
|
||||||
effect: OpencodePlugin.effect(host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) })),
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("OpencodePlugin", () => {
|
describe("OpencodePlugin", () => {
|
||||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("opencode")
|
const provider = new ProviderV2.Info({
|
||||||
catalog.provider.update(item.id, () => {})
|
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
api: { type: "aisdk", package: "test-provider" },
|
||||||
catalog.model.update(item.id, paid.id, (draft) => {
|
})
|
||||||
draft.cost = [...paid.cost]
|
const model = new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||||
|
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||||
|
cost: cost(1),
|
||||||
|
})
|
||||||
|
catalog.provider.update(provider.id, () => {})
|
||||||
|
catalog.model.update(provider.id, model.id, (draft) => {
|
||||||
|
draft.cost = [...model.cost]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
|
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
|
||||||
}),
|
}),
|
||||||
@@ -49,17 +76,23 @@ describe("OpencodePlugin", () => {
|
|||||||
it.effect("keeps free models without credentials", () =>
|
it.effect("keeps free models without credentials", () =>
|
||||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("opencode")
|
const provider = new ProviderV2.Info({
|
||||||
catalog.provider.update(item.id, () => {})
|
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||||
const free = model("opencode", "free", { cost: cost(0) })
|
api: { type: "aisdk", package: "test-provider" },
|
||||||
catalog.model.update(item.id, free.id, (draft) => {
|
})
|
||||||
draft.cost = [...free.cost]
|
const model = new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
|
||||||
|
api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" },
|
||||||
|
cost: cost(0),
|
||||||
|
})
|
||||||
|
catalog.provider.update(provider.id, () => {})
|
||||||
|
catalog.model.update(provider.id, model.id, (draft) => {
|
||||||
|
draft.cost = [...model.cost]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
|
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
@@ -69,17 +102,23 @@ describe("OpencodePlugin", () => {
|
|||||||
it.effect("treats output-only cost as free without credentials", () =>
|
it.effect("treats output-only cost as free without credentials", () =>
|
||||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("opencode")
|
const provider = new ProviderV2.Info({
|
||||||
catalog.provider.update(item.id, () => {})
|
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||||
const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
|
api: { type: "aisdk", package: "test-provider" },
|
||||||
catalog.model.update(item.id, outputOnly.id, (draft) => {
|
})
|
||||||
draft.cost = [...outputOnly.cost]
|
const model = new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
|
||||||
|
api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" },
|
||||||
|
cost: cost(0, 1),
|
||||||
|
})
|
||||||
|
catalog.provider.update(provider.id, () => {})
|
||||||
|
catalog.model.update(provider.id, model.id, (draft) => {
|
||||||
|
draft.cost = [...model.cost]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
|
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
|
||||||
true,
|
true,
|
||||||
@@ -91,17 +130,23 @@ describe("OpencodePlugin", () => {
|
|||||||
it.effect("uses OPENCODE_API_KEY as credentials", () =>
|
it.effect("uses OPENCODE_API_KEY as credentials", () =>
|
||||||
withEnv({ OPENCODE_API_KEY: "secret" }, () =>
|
withEnv({ OPENCODE_API_KEY: "secret" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("opencode")
|
const provider = new ProviderV2.Info({
|
||||||
catalog.provider.update(item.id, () => {})
|
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
api: { type: "aisdk", package: "test-provider" },
|
||||||
catalog.model.update(item.id, paid.id, (draft) => {
|
})
|
||||||
draft.cost = [...paid.cost]
|
const model = new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||||
|
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||||
|
cost: cost(1),
|
||||||
|
})
|
||||||
|
catalog.provider.update(provider.id, () => {})
|
||||||
|
catalog.model.update(provider.id, model.id, (draft) => {
|
||||||
|
draft.cost = [...model.cost]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
@@ -111,10 +156,8 @@ describe("OpencodePlugin", () => {
|
|||||||
it.effect("uses configured provider env vars as credentials", () =>
|
it.effect("uses configured provider env vars as credentials", () =>
|
||||||
withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
|
withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
yield* plugin.add(pluginWithIntegrations(catalog, integrations))
|
|
||||||
yield* integrations.transform((editor) => {
|
yield* integrations.transform((editor) => {
|
||||||
editor.method.update({
|
editor.method.update({
|
||||||
integrationID: Integration.ID.make("opencode"),
|
integrationID: Integration.ID.make("opencode"),
|
||||||
@@ -122,13 +165,21 @@ describe("OpencodePlugin", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("opencode")
|
const provider = new ProviderV2.Info({
|
||||||
catalog.provider.update(item.id, () => {})
|
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
api: { type: "aisdk", package: "test-provider" },
|
||||||
catalog.model.update(item.id, paid.id, (draft) => {
|
})
|
||||||
draft.cost = [...paid.cost]
|
const model = new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||||
|
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||||
|
cost: cost(1),
|
||||||
|
})
|
||||||
|
catalog.provider.update(provider.id, () => {})
|
||||||
|
catalog.model.update(provider.id, model.id, (draft) => {
|
||||||
|
draft.cost = [...model.cost]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
@@ -138,24 +189,29 @@ describe("OpencodePlugin", () => {
|
|||||||
it.effect("uses configured apiKey as credentials", () =>
|
it.effect("uses configured apiKey as credentials", () =>
|
||||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("opencode", {
|
const provider = new ProviderV2.Info({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||||
|
api: { type: "aisdk", package: "test-provider" },
|
||||||
request: {
|
request: {
|
||||||
headers: {},
|
headers: {},
|
||||||
body: { apiKey: "configured" },
|
body: { apiKey: "configured" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
catalog.provider.update(item.id, (draft) => {
|
const model = new ModelV2.Info({
|
||||||
draft.request = item.request
|
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||||
|
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||||
|
cost: cost(1),
|
||||||
})
|
})
|
||||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
catalog.provider.update(provider.id, (draft) => {
|
||||||
catalog.model.update(item.id, paid.id, (draft) => {
|
draft.request = provider.request
|
||||||
draft.cost = [...paid.cost]
|
})
|
||||||
|
catalog.model.update(provider.id, model.id, (draft) => {
|
||||||
|
draft.cost = [...model.cost]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
@@ -165,17 +221,23 @@ describe("OpencodePlugin", () => {
|
|||||||
it.effect("ignores non-opencode providers and models", () =>
|
it.effect("ignores non-opencode providers and models", () =>
|
||||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service))
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("openai")
|
const provider = new ProviderV2.Info({
|
||||||
catalog.provider.update(item.id, () => {})
|
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||||
const paid = model("openai", "paid", { cost: cost(1) })
|
api: { type: "aisdk", package: "test-provider" },
|
||||||
catalog.model.update(item.id, paid.id, (draft) => {
|
})
|
||||||
draft.cost = [...paid.cost]
|
const model = new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||||
|
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||||
|
cost: cost(1),
|
||||||
|
})
|
||||||
|
catalog.provider.update(provider.id, () => {})
|
||||||
|
catalog.model.update(provider.id, model.id, (draft) => {
|
||||||
|
draft.cost = [...model.cost]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
@@ -206,8 +268,6 @@ describe("OpencodePlugin", () => {
|
|||||||
const selected = yield* catalog.model.small(providerID)
|
const selected = yield* catalog.model.small(providerID)
|
||||||
|
|
||||||
expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
|
expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
|
||||||
}).pipe(
|
}),
|
||||||
Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,56 +3,59 @@ import { Effect } from "effect"
|
|||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||||
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
|
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, expectPluginRegistered, it, model, provider, required } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: OpenRouterPlugin.id, effect: OpenRouterPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
describe("OpenRouterPlugin", () => {
|
describe("OpenRouterPlugin", () => {
|
||||||
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
|
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
|
||||||
Effect.sync(() =>
|
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))),
|
||||||
expectPluginRegistered(
|
|
||||||
ProviderPlugins.map((item) => item.id),
|
|
||||||
"openrouter",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("applies legacy referer headers only to openrouter", () =>
|
it.effect("applies legacy referer headers only to openrouter", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, OpenRouterPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const openrouter = provider("openrouter", {
|
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||||
api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
|
||||||
request: { headers: { Existing: "value" }, body: {} },
|
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||||
})
|
|
||||||
catalog.provider.update(openrouter.id, (item) => {
|
|
||||||
item.api = openrouter.api
|
|
||||||
item.request = openrouter.request
|
|
||||||
})
|
})
|
||||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({
|
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({
|
||||||
Existing: "value",
|
Existing: "value",
|
||||||
"HTTP-Referer": "https://opencode.ai/",
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
})
|
})
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({})
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("creates an SDK only for the OpenRouter package", () =>
|
it.effect("creates an SDK only for the OpenRouter package", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, OpenRouterPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
const ignored = yield* plugin.trigger(
|
const ignored = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("openrouter", "openai/gpt-5"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "openrouter" },
|
options: { name: "openrouter" },
|
||||||
},
|
},
|
||||||
@@ -62,7 +65,14 @@ describe("OpenRouterPlugin", () => {
|
|||||||
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")),
|
||||||
|
api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@openrouter/ai-sdk-provider",
|
||||||
|
options: { name: "custom" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -71,52 +81,37 @@ describe("OpenRouterPlugin", () => {
|
|||||||
|
|
||||||
it.effect("filters OpenRouter's gpt-5 chat alias", () =>
|
it.effect("filters OpenRouter's gpt-5 chat alias", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, OpenRouterPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const openrouter = provider("openrouter", {
|
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||||
api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
|
||||||
})
|
|
||||||
catalog.provider.update(openrouter.id, (item) => {
|
|
||||||
item.api = openrouter.api
|
|
||||||
})
|
})
|
||||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||||
for (const item of [
|
catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {})
|
||||||
model("openrouter", "openai/gpt-5-chat"),
|
catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5"), () => {})
|
||||||
model("openrouter", "openai/gpt-5"),
|
catalog.model.update(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"), () => {})
|
||||||
model("openai", "openai/gpt-5-chat"),
|
|
||||||
]) {
|
|
||||||
catalog.model.update(item.providerID, item.id, () => {})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
expect(
|
expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(
|
||||||
required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat")))
|
false,
|
||||||
.enabled,
|
)
|
||||||
).toBe(false)
|
expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")))?.enabled).toBe(true)
|
||||||
expect(
|
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(true)
|
||||||
required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled,
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled,
|
|
||||||
).toBe(true)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () =>
|
it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, OpenRouterPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
|
catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
|
||||||
catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
expect(
|
expect(
|
||||||
required(
|
(yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")))
|
||||||
yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")),
|
?.enabled,
|
||||||
).enabled,
|
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,50 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
|
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: PerplexityPlugin.id, effect: PerplexityPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("PerplexityPlugin", () => {
|
describe("PerplexityPlugin", () => {
|
||||||
it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () =>
|
it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, PerplexityPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
|
||||||
|
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/perplexity",
|
||||||
|
options: { name: "perplexity" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -22,11 +54,14 @@ describe("PerplexityPlugin", () => {
|
|||||||
it.effect("ignores packages that are not the bundled Perplexity package", () =>
|
it.effect("ignores packages that are not the bundled Perplexity package", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, PerplexityPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("perplexity", "sonar"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
|
||||||
|
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/perplexity-compatible",
|
package: "@ai-sdk/perplexity-compatible",
|
||||||
options: { name: "perplexity" },
|
options: { name: "perplexity" },
|
||||||
},
|
},
|
||||||
@@ -39,50 +74,40 @@ describe("PerplexityPlugin", () => {
|
|||||||
it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () =>
|
it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const providers: string[] = []
|
yield* addPlugin()
|
||||||
yield* addPlugin(plugin, PerplexityPlugin)
|
const result = yield* plugin.trigger(
|
||||||
yield* plugin.add({
|
|
||||||
id: PluginV2.ID.make("perplexity-sdk-inspector"),
|
|
||||||
effect: Effect.succeed({
|
|
||||||
"aisdk.sdk": (evt) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
providers.push(evt.sdk.languageModel("sonar").provider)
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
|
||||||
|
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/perplexity",
|
||||||
|
options: { name: "perplexity" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(providers).toEqual(["perplexity"])
|
expect(result.sdk.languageModel("sonar").provider).toBe("perplexity")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("creates bundled Perplexity SDKs for custom provider IDs", () =>
|
it.effect("creates bundled Perplexity SDKs for custom provider IDs", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const providers: string[] = []
|
yield* addPlugin()
|
||||||
yield* addPlugin(plugin, PerplexityPlugin)
|
const result = yield* plugin.trigger(
|
||||||
yield* plugin.add({
|
|
||||||
id: PluginV2.ID.make("custom-perplexity-sdk-inspector"),
|
|
||||||
effect: Effect.succeed({
|
|
||||||
"aisdk.sdk": (evt) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
providers.push(evt.sdk.languageModel("sonar").provider)
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-perplexity", "sonar"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")),
|
||||||
|
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/perplexity",
|
package: "@ai-sdk/perplexity",
|
||||||
options: { name: "custom-perplexity" },
|
options: { name: "custom-perplexity" },
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(providers).toEqual(["perplexity"])
|
expect(result.sdk.languageModel("sonar").provider).toBe("perplexity")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,11 +115,14 @@ describe("PerplexityPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, PerplexityPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("perplexity", "alias", { api: { id: ModelV2.ID.make("sonar") } }),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")),
|
||||||
|
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,54 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core"
|
import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core"
|
||||||
import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { host } from "./host"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
const pluginWithNpm = {
|
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||||
id: SapAICorePlugin.id,
|
const it = testEffect(PluginTestLayer)
|
||||||
effect: Effect.gen(function* () {
|
const npm = Npm.Service.of({
|
||||||
yield* SapAICorePlugin.effect(host({ npm: yield* Npm.Service }))
|
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||||
}).pipe(Effect.provide(npmLayer)),
|
install: () => Effect.void,
|
||||||
|
which: () => Effect.succeed(undefined),
|
||||||
|
})
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: SapAICorePlugin.id, effect: SapAICorePlugin.effect({ ...host, npm }) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
}
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
effect,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
for (const [key, value] of Object.entries(previous)) {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function model(providerID: string) {
|
||||||
|
return new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")),
|
||||||
|
api: { id: ModelV2.ID.make("sap-model"), type: "aisdk", package: fixtureProvider },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SapAICorePlugin", () => {
|
describe("SapAICorePlugin", () => {
|
||||||
@@ -20,11 +58,11 @@ describe("SapAICorePlugin", () => {
|
|||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* plugin.add(pluginWithNpm)
|
yield* addPlugin()
|
||||||
const sdk = yield* plugin.trigger(
|
const sdk = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("sap-ai-core", "sap-model"),
|
model: model("sap-ai-core"),
|
||||||
package: fixtureProvider,
|
package: fixtureProvider,
|
||||||
options: { name: "sap-ai-core", serviceKey: "service-key" },
|
options: { name: "sap-ai-core", serviceKey: "service-key" },
|
||||||
},
|
},
|
||||||
@@ -46,11 +84,11 @@ describe("SapAICorePlugin", () => {
|
|||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* plugin.add(pluginWithNpm)
|
yield* addPlugin()
|
||||||
const sdk = yield* plugin.trigger(
|
const sdk = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("sap-ai-core", "sap-model"),
|
model: model("sap-ai-core"),
|
||||||
package: fixtureProvider,
|
package: fixtureProvider,
|
||||||
options: { name: "sap-ai-core", serviceKey: "option-service-key" },
|
options: { name: "sap-ai-core", serviceKey: "option-service-key" },
|
||||||
},
|
},
|
||||||
@@ -68,10 +106,10 @@ describe("SapAICorePlugin", () => {
|
|||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* plugin.add(pluginWithNpm)
|
yield* addPlugin()
|
||||||
const sdk = yield* plugin.trigger(
|
const sdk = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } },
|
{ model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core" } },
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(process.env.AICORE_SERVICE_KEY).toBeUndefined()
|
expect(process.env.AICORE_SERVICE_KEY).toBeUndefined()
|
||||||
@@ -83,17 +121,13 @@ describe("SapAICorePlugin", () => {
|
|||||||
it.effect("uses the callable SDK for language selection", () =>
|
it.effect("uses the callable SDK for language selection", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* plugin.add(pluginWithNpm)
|
yield* addPlugin()
|
||||||
const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), {
|
const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), {
|
||||||
languageModel() {
|
languageModel() {
|
||||||
throw new Error("SAP AI Core should call the SDK directly")
|
throw new Error("SAP AI Core should call the SDK directly")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const language = yield* plugin.trigger(
|
const language = yield* plugin.trigger("aisdk.language", { model: model("sap-ai-core"), sdk, options: {} }, {})
|
||||||
"aisdk.language",
|
|
||||||
{ model: model("sap-ai-core", "sap-model"), sdk, options: {} },
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" })
|
expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -104,11 +138,11 @@ describe("SapAICorePlugin", () => {
|
|||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* plugin.add(pluginWithNpm)
|
yield* addPlugin()
|
||||||
const sdk = yield* plugin.trigger(
|
const sdk = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("openai", "sap-model"),
|
model: model("openai"),
|
||||||
package: fixtureProvider,
|
package: fixtureProvider,
|
||||||
options: { name: "openai", serviceKey: "service-key" },
|
options: { name: "openai", serviceKey: "service-key" },
|
||||||
},
|
},
|
||||||
@@ -117,7 +151,7 @@ describe("SapAICorePlugin", () => {
|
|||||||
const language = yield* plugin.trigger(
|
const language = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("openai", "sap-model"),
|
model: model("openai"),
|
||||||
sdk: () => {
|
sdk: () => {
|
||||||
throw new Error("SAP AI Core should ignore other providers")
|
throw new Error("SAP AI Core should ignore other providers")
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,48 @@
|
|||||||
import { describe, expect, it as bun_it } from "bun:test"
|
import { describe, expect, it as bun_it } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
|
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
|
||||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||||
import { addPlugin, expectPluginRegistered, it, model, withEnv } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: SnowflakeCortexPlugin.id, effect: SnowflakeCortexPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
effect,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
Object.entries(previous).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = value
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
describe("SnowflakeCortexPlugin", () => {
|
describe("SnowflakeCortexPlugin", () => {
|
||||||
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
|
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expectPluginRegistered(
|
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex"))
|
||||||
ProviderPlugins.map((item) => item.id),
|
const ids = ProviderPlugins.map((p) => p.id)
|
||||||
"snowflake-cortex",
|
|
||||||
)
|
|
||||||
const ids = ProviderPlugins.map((p) => p.id as string)
|
|
||||||
expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible"))
|
expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible"))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -20,10 +50,17 @@ describe("SnowflakeCortexPlugin", () => {
|
|||||||
it.effect("ignores non-snowflake-cortex providers", () =>
|
it.effect("ignores non-snowflake-cortex providers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")),
|
||||||
|
api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/openai",
|
||||||
|
options: { name: "openai" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeUndefined()
|
expect(result.sdk).toBeUndefined()
|
||||||
@@ -34,11 +71,14 @@ describe("SnowflakeCortexPlugin", () => {
|
|||||||
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
|
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||||
},
|
},
|
||||||
@@ -53,11 +93,14 @@ describe("SnowflakeCortexPlugin", () => {
|
|||||||
withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: {
|
options: {
|
||||||
name: "snowflake-cortex",
|
name: "snowflake-cortex",
|
||||||
@@ -76,11 +119,14 @@ describe("SnowflakeCortexPlugin", () => {
|
|||||||
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||||
},
|
},
|
||||||
@@ -95,11 +141,14 @@ describe("SnowflakeCortexPlugin", () => {
|
|||||||
withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: {
|
options: {
|
||||||
name: "snowflake-cortex",
|
name: "snowflake-cortex",
|
||||||
@@ -118,27 +167,20 @@ describe("SnowflakeCortexPlugin", () => {
|
|||||||
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
|
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const captured: Record<string, unknown>[] = []
|
yield* addPlugin()
|
||||||
yield* addPlugin(plugin, SnowflakeCortexPlugin)
|
const result = yield* plugin.trigger(
|
||||||
yield* plugin.add({
|
|
||||||
id: PluginV2.ID.make("inspector"),
|
|
||||||
effect: Effect.succeed({
|
|
||||||
"aisdk.sdk": (evt) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
captured.push({ ...evt.options })
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||||
|
api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(captured[0]?.includeUsage).toBe(true)
|
expect(result.options.includeUsage).toBe(true)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,50 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai"
|
import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: TogetherAIPlugin.id, effect: TogetherAIPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("TogetherAIPlugin", () => {
|
describe("TogetherAIPlugin", () => {
|
||||||
it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () =>
|
it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, TogetherAIPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/togetherai",
|
||||||
|
options: { name: "togetherai" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -21,12 +54,15 @@ describe("TogetherAIPlugin", () => {
|
|||||||
it.effect("matches the old bundled provider package exactly", () =>
|
it.effect("matches the old bundled provider package exactly", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, TogetherAIPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
const ignored = yield* plugin.trigger(
|
const ignored = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("togetherai", "model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "file:///tmp/@ai-sdk/togetherai-provider.js",
|
package: "file:///tmp/@ai-sdk/togetherai-provider.js",
|
||||||
options: { name: "togetherai" },
|
options: { name: "togetherai" },
|
||||||
},
|
},
|
||||||
@@ -36,7 +72,14 @@ describe("TogetherAIPlugin", () => {
|
|||||||
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/togetherai",
|
||||||
|
options: { name: "togetherai" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -46,29 +89,22 @@ describe("TogetherAIPlugin", () => {
|
|||||||
it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () =>
|
it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const observed: string[] = []
|
yield* addPlugin()
|
||||||
yield* addPlugin(plugin, TogetherAIPlugin)
|
|
||||||
yield* plugin.add({
|
|
||||||
id: PluginV2.ID.make("inspector"),
|
|
||||||
effect: Effect.succeed({
|
|
||||||
"aisdk.sdk": (evt) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
observed.push(evt.sdk.languageModel("model").provider)
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-togetherai", "model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/togetherai",
|
package: "@ai-sdk/togetherai",
|
||||||
options: { name: "custom-togetherai" },
|
options: { name: "custom-togetherai" },
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(observed).toEqual(["togetherai.chat"])
|
expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,12 +112,22 @@ describe("TogetherAIPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, TogetherAIPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(
|
||||||
|
ProviderV2.ID.make("togetherai"),
|
||||||
|
ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
||||||
|
),
|
||||||
|
api: {
|
||||||
|
id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
||||||
|
type: "aisdk",
|
||||||
|
package: "test-provider",
|
||||||
|
},
|
||||||
|
}),
|
||||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,17 +1,50 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
|
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
|
||||||
import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: VenicePlugin.id, effect: VenicePlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("VenicePlugin", () => {
|
describe("VenicePlugin", () => {
|
||||||
it.effect("creates a Venice SDK for venice-ai-sdk-provider", () =>
|
it.effect("creates a Venice SDK for venice-ai-sdk-provider", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, VenicePlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "venice-ai-sdk-provider",
|
||||||
|
options: { name: "venice" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
@@ -21,39 +54,35 @@ describe("VenicePlugin", () => {
|
|||||||
it.effect("uses the model provider ID as the bundled Venice SDK name", () =>
|
it.effect("uses the model provider ID as the bundled Venice SDK name", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const observed: string[] = []
|
yield* addPlugin()
|
||||||
yield* addPlugin(plugin, VenicePlugin)
|
|
||||||
yield* plugin.add({
|
|
||||||
id: PluginV2.ID.make("inspector"),
|
|
||||||
effect: Effect.succeed({
|
|
||||||
"aisdk.sdk": (evt) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
observed.push(evt.sdk.languageModel("model").provider)
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("custom-venice", "model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "venice-ai-sdk-provider",
|
package: "venice-ai-sdk-provider",
|
||||||
options: { name: "custom-venice", apiKey: "test" },
|
options: { name: "custom-venice", apiKey: "test" },
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(result.sdk).toBeDefined()
|
expect(result.sdk).toBeDefined()
|
||||||
expect(observed).toEqual(["custom-venice.chat"])
|
expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("only handles the bundled venice-ai-sdk-provider package", () =>
|
it.effect("only handles the bundled venice-ai-sdk-provider package", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, VenicePlugin)
|
yield* addPlugin()
|
||||||
const similar = yield* plugin.trigger(
|
const similar = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: model("venice", "model"),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
package: "file:///tmp/venice-ai-sdk-provider.js",
|
package: "file:///tmp/venice-ai-sdk-provider.js",
|
||||||
options: { name: "venice" },
|
options: { name: "venice" },
|
||||||
},
|
},
|
||||||
@@ -61,7 +90,14 @@ describe("VenicePlugin", () => {
|
|||||||
)
|
)
|
||||||
const other = yield* plugin.trigger(
|
const other = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("venice", "model"), package: "@ai-sdk/openai-compatible", options: { name: "venice" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
|
||||||
|
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
options: { name: "venice" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(similar.sdk).toBeUndefined()
|
expect(similar.sdk).toBeUndefined()
|
||||||
@@ -73,10 +109,17 @@ describe("VenicePlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
yield* addPlugin(plugin, VenicePlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{ model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")),
|
||||||
|
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" },
|
||||||
|
}),
|
||||||
|
sdk: fakeSelectorSdk(calls),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(calls).toEqual([])
|
expect(calls).toEqual([])
|
||||||
|
|||||||
@@ -1,28 +1,34 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
|
import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, it, model, provider, required } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: VercelPlugin.id, effect: VercelPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
describe("VercelPlugin", () => {
|
describe("VercelPlugin", () => {
|
||||||
it.effect("applies legacy lower-case referer headers", () =>
|
it.effect("applies legacy lower-case referer headers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, VercelPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("vercel", {
|
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/vercel" },
|
provider.api = { type: "aisdk", package: "@ai-sdk/vercel" }
|
||||||
request: { headers: { Existing: "1" }, body: {} },
|
provider.request.headers.Existing = "1"
|
||||||
})
|
|
||||||
catalog.provider.update(item.id, (draft) => {
|
|
||||||
draft.api = item.api
|
|
||||||
draft.request = item.request
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).toEqual({
|
yield* addPlugin()
|
||||||
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).toEqual({
|
||||||
Existing: "1",
|
Existing: "1",
|
||||||
"http-referer": "https://opencode.ai/",
|
"http-referer": "https://opencode.ai/",
|
||||||
"x-title": "opencode",
|
"x-title": "opencode",
|
||||||
@@ -32,31 +38,34 @@ describe("VercelPlugin", () => {
|
|||||||
|
|
||||||
it.effect("does not add legacy upper-case referer headers", () =>
|
it.effect("does not add legacy upper-case referer headers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, VercelPlugin)
|
yield* catalog.transform((catalog) =>
|
||||||
yield* catalog.transform((catalog) => {
|
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
|
||||||
const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } })
|
provider.api = { type: "aisdk", package: "@ai-sdk/vercel" }
|
||||||
catalog.provider.update(item.id, (draft) => {
|
}),
|
||||||
draft.api = item.api
|
)
|
||||||
})
|
yield* addPlugin()
|
||||||
})
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty(
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty(
|
|
||||||
"HTTP-Referer",
|
"HTTP-Referer",
|
||||||
)
|
)
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty(
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty("X-Title")
|
||||||
"X-Title",
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () =>
|
it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, VercelPlugin)
|
yield* addPlugin()
|
||||||
const event = yield* plugin.trigger(
|
const event = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")),
|
||||||
|
api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/vercel",
|
||||||
|
options: { name: "custom-vercel" },
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
expect(event.sdk).toBeDefined()
|
expect(event.sdk).toBeDefined()
|
||||||
@@ -66,11 +75,10 @@ describe("VercelPlugin", () => {
|
|||||||
|
|
||||||
it.effect("ignores non-Vercel providers", () =>
|
it.effect("ignores non-Vercel providers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, VercelPlugin)
|
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {}))
|
||||||
yield* catalog.transform((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({})
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,37 +1,66 @@
|
|||||||
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect } from "effect"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
|
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { addPlugin, fakeSelectorSdk } from "./provider-helper"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
const model = new ModelV2.Info({
|
const addPlugin = Effect.fn(function* () {
|
||||||
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
|
const plugin = yield* PluginV2.Service
|
||||||
api: {
|
const host = yield* PluginHost.make()
|
||||||
id: ModelV2.ID.make("grok-4"),
|
yield* plugin.add({ id: XAIPlugin.id, effect: XAIPlugin.effect(host) })
|
||||||
type: "aisdk",
|
|
||||||
package: "@ai-sdk/xai",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
|
const make = (method: string) => (id: string) => {
|
||||||
|
calls.push(`${method}:${id}`)
|
||||||
|
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responses: make("responses"),
|
||||||
|
messages: make("messages"),
|
||||||
|
chat: make("chat"),
|
||||||
|
languageModel: make("languageModel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("XAIPlugin", () => {
|
describe("XAIPlugin", () => {
|
||||||
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
|
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* addPlugin(plugin, XAIPlugin)
|
yield* addPlugin()
|
||||||
|
|
||||||
const ignored = yield* plugin.trigger(
|
const ignored = yield* plugin.trigger(
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{ model, package: "@ai-sdk/openai-compatible", options: {} },
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
|
||||||
|
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {})
|
const result = yield* plugin.trigger(
|
||||||
|
"aisdk.sdk",
|
||||||
|
{
|
||||||
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
|
||||||
|
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||||
|
}),
|
||||||
|
package: "@ai-sdk/xai",
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
expect(ignored.sdk).toBeUndefined()
|
expect(ignored.sdk).toBeUndefined()
|
||||||
expect(typeof result.sdk?.responses).toBe("function")
|
expect(typeof result.sdk?.responses).toBe("function")
|
||||||
@@ -41,32 +70,22 @@ describe("XAIPlugin", () => {
|
|||||||
it.effect("creates xAI SDKs for custom provider IDs", () =>
|
it.effect("creates xAI SDKs for custom provider IDs", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const providers: string[] = []
|
yield* addPlugin()
|
||||||
|
|
||||||
yield* addPlugin(plugin, XAIPlugin)
|
const result = yield* plugin.trigger(
|
||||||
yield* plugin.add({
|
|
||||||
id: PluginV2.ID.make("xai-sdk-name-observer"),
|
|
||||||
effect: Effect.gen(function* () {
|
|
||||||
return {
|
|
||||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
|
||||||
if (!evt.sdk) return
|
|
||||||
providers.push(evt.sdk.responses("grok-4").provider)
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"aisdk.sdk",
|
"aisdk.sdk",
|
||||||
{
|
{
|
||||||
model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")),
|
||||||
|
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||||
|
}),
|
||||||
package: "@ai-sdk/xai",
|
package: "@ai-sdk/xai",
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(providers).toEqual(["xai.responses"])
|
expect(result.sdk.responses("grok-4").provider).toBe("xai.responses")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,11 +94,14 @@ describe("XAIPlugin", () => {
|
|||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
|
|
||||||
yield* addPlugin(plugin, XAIPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias") }),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")),
|
||||||
|
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||||
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
@@ -96,11 +118,14 @@ describe("XAIPlugin", () => {
|
|||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
|
|
||||||
yield* addPlugin(plugin, XAIPlugin)
|
yield* addPlugin()
|
||||||
const result = yield* plugin.trigger(
|
const result = yield* plugin.trigger(
|
||||||
"aisdk.language",
|
"aisdk.language",
|
||||||
{
|
{
|
||||||
model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }),
|
model: new ModelV2.Info({
|
||||||
|
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")),
|
||||||
|
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||||
|
}),
|
||||||
sdk: fakeSelectorSdk(calls),
|
sdk: fakeSelectorSdk(calls),
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,34 +2,44 @@ import { describe, expect } from "bun:test"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||||
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
|
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make()
|
||||||
|
yield* plugin.add({ id: ZenmuxPlugin.id, effect: ZenmuxPlugin.effect(host) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function required<T>(value: T | undefined): T {
|
||||||
|
if (value === undefined) throw new Error("Expected value")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
describe("ZenmuxPlugin", () => {
|
describe("ZenmuxPlugin", () => {
|
||||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||||
Effect.sync(() =>
|
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))),
|
||||||
expectPluginRegistered(
|
|
||||||
ProviderPlugins.map((item) => item.id),
|
|
||||||
"zenmux",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("applies the exact legacy Zenmux headers", () =>
|
it.effect("applies the exact legacy Zenmux headers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, ZenmuxPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("zenmux", {
|
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
provider.api = {
|
||||||
})
|
type: "aisdk",
|
||||||
catalog.provider.update(item.id, (draft) => {
|
package: "@ai-sdk/openai-compatible",
|
||||||
draft.api = item.api
|
url: "https://zenmux.ai/api/v1",
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux")))
|
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux")))
|
||||||
expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
|
expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
|
||||||
expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
|
expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
|
||||||
@@ -38,19 +48,18 @@ describe("ZenmuxPlugin", () => {
|
|||||||
|
|
||||||
it.effect("merges legacy Zenmux headers with existing headers", () =>
|
it.effect("merges legacy Zenmux headers with existing headers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, ZenmuxPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("zenmux", {
|
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
provider.api = {
|
||||||
request: { headers: { Existing: "value" }, body: {} },
|
type: "aisdk",
|
||||||
})
|
package: "@ai-sdk/openai-compatible",
|
||||||
catalog.provider.update(item.id, (draft) => {
|
url: "https://zenmux.ai/api/v1",
|
||||||
draft.api = item.api
|
}
|
||||||
draft.request = item.request
|
provider.request.headers.Existing = "value"
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
|
||||||
Existing: "value",
|
Existing: "value",
|
||||||
@@ -62,22 +71,18 @@ describe("ZenmuxPlugin", () => {
|
|||||||
|
|
||||||
it.effect("lets configured Zenmux legacy headers override defaults", () =>
|
it.effect("lets configured Zenmux legacy headers override defaults", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, ZenmuxPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("zenmux", {
|
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
provider.api = {
|
||||||
request: {
|
type: "aisdk",
|
||||||
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
|
package: "@ai-sdk/openai-compatible",
|
||||||
body: {},
|
url: "https://zenmux.ai/api/v1",
|
||||||
},
|
}
|
||||||
})
|
provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
|
||||||
catalog.provider.update(item.id, (draft) => {
|
|
||||||
draft.api = item.api
|
|
||||||
draft.request = item.request
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
|
||||||
"HTTP-Referer": "https://example.com/",
|
"HTTP-Referer": "https://example.com/",
|
||||||
@@ -88,20 +93,13 @@ describe("ZenmuxPlugin", () => {
|
|||||||
|
|
||||||
it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
|
it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* addPlugin(plugin, ZenmuxPlugin)
|
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
const item = provider("openrouter", {
|
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||||
request: {
|
provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
|
||||||
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
|
|
||||||
body: {},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
catalog.provider.update(item.id, (draft) => {
|
|
||||||
draft.request = item.request
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({
|
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({
|
||||||
"HTTP-Referer": "https://example.com/",
|
"HTTP-Referer": "https://example.com/",
|
||||||
|
|||||||
@@ -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 { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const databaseLayer = Database.layerFromPath(":memory:")
|
|
||||||
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
|
|
||||||
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
|
|
||||||
const copyLayer = ProjectCopy.layer.pipe(
|
const copyLayer = ProjectCopy.layer.pipe(
|
||||||
Layer.provide(databaseLayer),
|
Layer.provide(Database.defaultLayer),
|
||||||
Layer.provide(directoriesLayer),
|
Layer.provide(ProjectDirectories.defaultLayer),
|
||||||
Layer.provide(eventLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
)
|
)
|
||||||
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer))
|
const it = testEffect(
|
||||||
|
Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
function abs(input: string) {
|
function abs(input: string) {
|
||||||
return AbsolutePath.make(input)
|
return AbsolutePath.make(input)
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const database = Database.layerFromPath(":memory:")
|
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer))
|
||||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
|
||||||
const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
|
|
||||||
const it = testEffect(Layer.mergeAll(database, events, directories))
|
|
||||||
|
|
||||||
const projectID = Project.ID.make("project-directories")
|
const projectID = Project.ID.make("project-directories")
|
||||||
const directory = AbsolutePath.make("/tmp/project-directories")
|
const directory = AbsolutePath.make("/tmp/project-directories")
|
||||||
|
|||||||
@@ -13,20 +13,7 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
|||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const databaseLayer = Database.layerFromPath(":memory:")
|
const it = testEffect(Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer))
|
||||||
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
|
|
||||||
const it = testEffect(
|
|
||||||
Layer.mergeAll(
|
|
||||||
ProjectV2.layer.pipe(
|
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
|
||||||
Layer.provide(Git.defaultLayer),
|
|
||||||
Layer.provide(directoriesLayer),
|
|
||||||
Layer.provide(databaseLayer),
|
|
||||||
),
|
|
||||||
databaseLayer,
|
|
||||||
directoriesLayer,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
function remoteID(remote: string) {
|
function remoteID(remote: string) {
|
||||||
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
|
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import fs from "fs/promises"
|
|
||||||
import path from "path"
|
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
|
import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(OpenCode.layer)
|
const it = testEffect(OpenCode.layer)
|
||||||
@@ -41,94 +38,24 @@ describe("public native OpenCode API", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("switches to an available model and variant", () =>
|
it.effect("records model selection without resolving the Location catalog", () =>
|
||||||
Effect.acquireRelease(
|
Effect.gen(function* () {
|
||||||
Effect.promise(() => tmpdir()),
|
const opencode = yield* OpenCode.Service
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
const sessionID = Session.ID.make("ses_public_switch_deferred")
|
||||||
).pipe(
|
const model = Schema.decodeUnknownSync(Model.Ref)({
|
||||||
Effect.flatMap((tmp) =>
|
id: "missing",
|
||||||
Effect.gen(function* () {
|
providerID: "missing",
|
||||||
yield* writeProvider(tmp.path)
|
variant: "unknown",
|
||||||
const opencode = yield* OpenCode.Service
|
})
|
||||||
const sessionID = Session.ID.make("ses_public_switch_available")
|
yield* opencode.sessions.create({
|
||||||
const model = ref({ variant: "fast" })
|
id: sessionID,
|
||||||
yield* opencode.sessions.create({
|
location: Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") }),
|
||||||
id: sessionID,
|
})
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* opencode.sessions.switchModel({ sessionID, model })
|
yield* opencode.sessions.switchModel({ sessionID, model })
|
||||||
|
|
||||||
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
|
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("rejects missing and Location-disabled models without changing the Session", () =>
|
|
||||||
Effect.acquireRelease(
|
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
|
||||||
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
|
||||||
).pipe(
|
|
||||||
Effect.flatMap(([available, disabled]) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* writeProvider(available.path)
|
|
||||||
yield* writeProvider(disabled.path, true)
|
|
||||||
const opencode = yield* OpenCode.Service
|
|
||||||
const availableID = Session.ID.make("ses_public_switch_exact_available")
|
|
||||||
const disabledID = Session.ID.make("ses_public_switch_exact_disabled")
|
|
||||||
yield* opencode.sessions.create({
|
|
||||||
id: availableID,
|
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }),
|
|
||||||
})
|
|
||||||
yield* opencode.sessions.create({
|
|
||||||
id: disabledID,
|
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }),
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
|
|
||||||
const disabledError = yield* opencode.sessions
|
|
||||||
.switchModel({ sessionID: disabledID, model: ref() })
|
|
||||||
.pipe(Effect.flip)
|
|
||||||
const missingError = yield* opencode.sessions
|
|
||||||
.switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) })
|
|
||||||
.pipe(Effect.flip)
|
|
||||||
|
|
||||||
expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError)
|
|
||||||
expect(missingError).toBeInstanceOf(Session.ModelUnavailableError)
|
|
||||||
expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" }))
|
|
||||||
expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined()
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("rejects an unavailable variant without changing the Session", () =>
|
|
||||||
Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
).pipe(
|
|
||||||
Effect.flatMap((tmp) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* writeProvider(tmp.path)
|
|
||||||
const opencode = yield* OpenCode.Service
|
|
||||||
const sessionID = Session.ID.make("ses_public_switch_variant")
|
|
||||||
const selected = ref({ variant: "fast" })
|
|
||||||
yield* opencode.sessions.create({
|
|
||||||
id: sessionID,
|
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
|
|
||||||
})
|
|
||||||
yield* opencode.sessions.switchModel({ sessionID, model: selected })
|
|
||||||
|
|
||||||
const error = yield* opencode.sessions
|
|
||||||
.switchModel({ sessionID, model: ref({ variant: "unknown" }) })
|
|
||||||
.pipe(Effect.flip)
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Session.VariantUnavailableError)
|
|
||||||
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves the typed not-found error for a missing Session", () =>
|
it.effect("preserves the typed not-found error for a missing Session", () =>
|
||||||
@@ -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 { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const database = Database.layerFromPath(":memory:")
|
const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
|
||||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, questions))
|
||||||
const questions = QuestionV2.layer.pipe(Layer.provide(events))
|
|
||||||
const it = testEffect(Layer.mergeAll(database, events, questions))
|
|
||||||
|
|
||||||
const sessionID = SessionV2.ID.make("ses_question_test")
|
const sessionID = SessionV2.ID.make("ses_question_test")
|
||||||
const question: QuestionV2.Info = {
|
const question: QuestionV2.Info = {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
|||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||||
@@ -25,8 +25,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
|||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
|
|
||||||
const database = Database.layerFromPath(":memory:")
|
|
||||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
|
||||||
const projects = Layer.succeed(
|
const projects = Layer.succeed(
|
||||||
ProjectV2.Service,
|
ProjectV2.Service,
|
||||||
ProjectV2.Service.of({
|
ProjectV2.Service.of({
|
||||||
@@ -35,17 +33,23 @@ const projects = Layer.succeed(
|
|||||||
commit: () => Effect.void,
|
commit: () => Effect.void,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
|
|
||||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
|
||||||
const sessions = SessionV2.layer.pipe(
|
const sessions = SessionV2.layer.pipe(
|
||||||
Layer.provide(events),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
Layer.provide(database),
|
Layer.provide(Database.defaultLayer),
|
||||||
Layer.provide(store),
|
Layer.provide(SessionStore.defaultLayer),
|
||||||
Layer.provide(projects),
|
Layer.provide(projects),
|
||||||
Layer.provide(SessionExecution.noopLayer),
|
Layer.provide(SessionExecution.noopLayer),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions),
|
Layer.mergeAll(
|
||||||
|
Database.defaultLayer,
|
||||||
|
EventV2.defaultLayer,
|
||||||
|
projects,
|
||||||
|
SessionProjector.defaultLayer,
|
||||||
|
SessionStore.defaultLayer,
|
||||||
|
SessionExecution.noopLayer,
|
||||||
|
sessions,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||||
const id = SessionV2.ID.create()
|
const id = SessionV2.ID.create()
|
||||||
@@ -220,8 +224,8 @@ describe("SessionV2.create", () => {
|
|||||||
expect(
|
expect(
|
||||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
|
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
|
||||||
).toMatchObject([
|
).toMatchObject([
|
||||||
{ cursor: 1, event: { type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } } },
|
{ durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } },
|
||||||
{ cursor: 2, event: { type: "session.next.prompt.promoted" } },
|
{ durable: { seq: 2 }, type: "session.next.prompt.promoted" },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -336,7 +340,34 @@ describe("SessionV2.create", () => {
|
|||||||
|
|
||||||
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
|
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
|
||||||
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
|
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
|
||||||
expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent")
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("switches the selected agent through the durable Session event", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const created = yield* session.create({ location })
|
||||||
|
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "plan" })
|
||||||
|
|
||||||
|
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||||
|
expect(
|
||||||
|
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
||||||
|
).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects an agent switch for a missing Session", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const missing = SessionV2.ID.make("ses_missing_agent_switch")
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe(
|
||||||
|
Effect.flip,
|
||||||
|
Effect.map((error) => error._tag),
|
||||||
|
),
|
||||||
|
).toBe("Session.NotFoundError")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -355,7 +386,7 @@ describe("SessionV2.create", () => {
|
|||||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
||||||
).toMatchObject([{ event: { type: "session.next.model.switched", data: { model } } }])
|
).toMatchObject([{ type: "session.next.model.switched", data: { model } }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user