Merge branch 'search-integration' into firecrawl-search
This commit is contained in:
commit
69bdd18b6f
671 changed files with 47037 additions and 20170 deletions
71
packages/core/test/aisdk.test.ts
Normal file
71
packages/core/test/aisdk.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { LLM } from "@opencode-ai/llm"
|
||||
import { LLMClient } from "@opencode-ai/llm/route"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AISDK.locationLayer)
|
||||
|
||||
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
||||
ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("catalog-model")),
|
||||
modelID: ModelV2.ID.make("api-model"),
|
||||
package: ProviderV2.aisdk(packageName),
|
||||
settings,
|
||||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
it.effect("keys language models by package and flattened overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const loaded: string[] = []
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
loaded.push(event.package)
|
||||
event.sdk = { languageModel: () => ({ package: event.package }) }
|
||||
})
|
||||
|
||||
const first = yield* aisdk.language(model("first", { region: "us-east-1" }))
|
||||
const second = yield* aisdk.language(model("second", { region: "us-east-1" }))
|
||||
const third = yield* aisdk.language(model("second", { region: "us-west-2" }))
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
expect(second).not.toBe(third)
|
||||
expect(loaded).toEqual(["first", "second", "second"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects request settings, headers, and body overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
let body: unknown
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
body = event.options.body
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const input = model("@ai-sdk/google", {
|
||||
apiKey: "secret",
|
||||
thinkingConfig: { thinkingBudget: 1024 },
|
||||
})
|
||||
const resolved = yield* aisdk.model(
|
||||
{
|
||||
...input,
|
||||
headers: { "x-test": "header" },
|
||||
body: { safety_setting: "strict" },
|
||||
},
|
||||
)
|
||||
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
)
|
||||
|
||||
expect(prepared.body.providerOptions).toEqual({
|
||||
google: { thinkingConfig: { thinkingBudget: 1024 } },
|
||||
})
|
||||
expect(prepared.body.headers).toEqual({ "x-test": "header" })
|
||||
expect(body).toEqual({ safety_setting: "strict" })
|
||||
}),
|
||||
)
|
||||
|
|
@ -61,14 +61,14 @@ describe("CatalogV2", () => {
|
|||
})
|
||||
|
||||
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"))).body).toBeUndefined()
|
||||
yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Second",
|
||||
value: Credential.Key.make({ type: "key", key: "second", metadata: { tenant: "two" } }),
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).body).toBeUndefined()
|
||||
}).pipe(Effect.provide(localCatalogLayer))
|
||||
})
|
||||
|
||||
|
|
@ -130,111 +130,91 @@ describe("CatalogV2", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.effect("normalizes provider baseURL into api url", () =>
|
||||
it.effect("stores provider package settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://default.example.com",
|
||||
}
|
||||
provider.request.body.baseURL = "https://override.example.com"
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://override.example.com" }
|
||||
}),
|
||||
)
|
||||
|
||||
expect(required(yield* catalog.provider.get(providerID)).api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://override.example.com",
|
||||
expect(required(yield* catalog.provider.get(providerID))).toMatchObject({
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://override.example.com" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes model baseURL into api url", () =>
|
||||
it.effect("uses model package settings over provider settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://provider.example.com",
|
||||
}
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://provider.example.com" }
|
||||
})
|
||||
catalog.model.update(providerID, modelID, (model) => {
|
||||
model.api = {
|
||||
id: modelID,
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://model.example.com",
|
||||
}
|
||||
model.request.body.baseURL = "https://override.example.com"
|
||||
model.modelID = ModelV2.ID.make("upstream-model")
|
||||
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
model.settings = { baseURL: "https://override.example.com" }
|
||||
})
|
||||
})
|
||||
|
||||
expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
|
||||
id: modelID,
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://override.example.com",
|
||||
settings: {},
|
||||
expect(required(yield* catalog.model.get(providerID, modelID))).toMatchObject({
|
||||
modelID: ModelV2.ID.make("upstream-model"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://override.example.com" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves default model api from provider api", () =>
|
||||
it.effect("resolves default model package settings from the provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://provider.example.com",
|
||||
}
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://provider.example.com" }
|
||||
})
|
||||
catalog.model.update(providerID, modelID, () => {})
|
||||
})
|
||||
|
||||
expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
|
||||
id: modelID,
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://provider.example.com",
|
||||
expect(required(yield* catalog.model.get(providerID, modelID))).toMatchObject({
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://provider.example.com" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves provider and model request merges", () =>
|
||||
it.effect("resolves provider and model overlay merges", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.request.headers.provider = "provider"
|
||||
provider.request.headers.shared = "provider"
|
||||
provider.request.body.provider = true
|
||||
provider.settings = { provider: true, shared: "provider" }
|
||||
provider.headers = { provider: "provider", shared: "provider" }
|
||||
provider.body = { provider: true, shared: "provider" }
|
||||
})
|
||||
catalog.model.update(providerID, modelID, (model) => {
|
||||
model.request.headers.model = "model"
|
||||
model.request.headers.shared = "model"
|
||||
model.request.body.model = true
|
||||
model.request.body.request = true
|
||||
model.request.body.shared = "model"
|
||||
model.settings = { model: true, shared: "model" }
|
||||
model.headers = { model: "model", shared: "model" }
|
||||
model.body = { model: true, shared: "model" }
|
||||
})
|
||||
})
|
||||
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
|
||||
expect(model.request.body).toEqual({ provider: true, model: true, request: true, shared: "model" })
|
||||
expect(model.settings).toEqual({ provider: true, shared: "model", model: true })
|
||||
expect(model.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
|
||||
expect(model.body).toEqual({ provider: true, shared: "model", model: true })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -76,5 +76,4 @@ describe("CommandV2", () => {
|
|||
expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output")
|
||||
}),
|
||||
)
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
info: decode({
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
agents: {
|
||||
reviewer: { variant: "high", hidden: true },
|
||||
reviewer: { model: "openrouter/openai/gpt-5#high", hidden: true },
|
||||
removed: { disabled: true },
|
||||
late: {
|
||||
permissions: [{ action: "edit", resource: "*", effect: "allow" }],
|
||||
|
|
@ -170,7 +170,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
hidden: true,
|
||||
color: "warning",
|
||||
steps: 12,
|
||||
model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
|
||||
model: { providerID: "anthropic", id: "claude-sonnet" },
|
||||
})
|
||||
expect(reviewer.request).toEqual({
|
||||
settings: {},
|
||||
|
|
|
|||
|
|
@ -44,8 +44,7 @@ describe("ConfigCommandPlugin.Plugin", () => {
|
|||
`---
|
||||
description: File review
|
||||
agent: reviewer
|
||||
model: anthropic/claude
|
||||
variant: high
|
||||
model: anthropic/claude#high
|
||||
subtask: true
|
||||
---
|
||||
Review files`,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
|
|||
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigModel } from "@opencode-ai/core/config/model"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -16,12 +17,14 @@ import { EventV2 } from "@opencode-ai/core/event"
|
|||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
|
||||
|
||||
function testLayer(
|
||||
directory: string,
|
||||
|
|
@ -47,11 +50,10 @@ function testLayer(
|
|||
}
|
||||
|
||||
const provider = {
|
||||
api: { type: "native", settings: {} },
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
},
|
||||
package: "native",
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: {},
|
||||
models: {},
|
||||
}
|
||||
|
||||
|
|
@ -105,13 +107,19 @@ describe("Config", () => {
|
|||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5" }) }),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
}),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new Config.Document({ type: "document", info: new Config.Info({}) }),
|
||||
new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5.5" }) }),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
}),
|
||||
]
|
||||
|
||||
expect(Config.latest(entries, "model")).toBe("openrouter/openai/gpt-5.5")
|
||||
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
|
||||
expect(Config.latest(entries, "default_agent")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
|
@ -134,7 +142,7 @@ describe("Config", () => {
|
|||
// V2 nests under `mcp.servers`, so it must not be misdetected and re-migrated.
|
||||
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
|
||||
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
|
||||
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { request: 1000 } } })).toBe(false)
|
||||
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -142,7 +150,12 @@ describe("Config", () => {
|
|||
Effect.sync(() => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => {
|
||||
Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(info), { errors: "all" })
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(
|
||||
Schema.encodeUnknownSync(Schema.UnknownFromJsonString)(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
|
|
@ -165,12 +178,9 @@ describe("Config", () => {
|
|||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.bedrock?.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
expect(migrated.providers?.bedrock).toMatchObject({
|
||||
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
settings: { region: "us-east-1", profile: "dev" },
|
||||
})
|
||||
expect(migrated.providers?.bedrock?.request).toEqual({
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
})
|
||||
|
|
@ -197,8 +207,7 @@ describe("Config", () => {
|
|||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subtask: true,
|
||||
},
|
||||
})
|
||||
|
|
@ -431,8 +440,7 @@ describe("Config", () => {
|
|||
],
|
||||
agents: {
|
||||
reviewer: {
|
||||
model: "openrouter/openai/gpt-5",
|
||||
variant: "high",
|
||||
model: "openrouter/openai/gpt-5#high",
|
||||
request: {
|
||||
headers: { "x-agent": "reviewer" },
|
||||
body: { reasoningEffort: "high" },
|
||||
|
|
@ -459,14 +467,14 @@ describe("Config", () => {
|
|||
},
|
||||
tool_output: { max_lines: 1000, max_bytes: 32768 },
|
||||
mcp: {
|
||||
timeout: { startup: 5000, request: 60000 },
|
||||
timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
|
||||
servers: {
|
||||
local: {
|
||||
type: "local",
|
||||
command: ["node", "./mcp/server.js"],
|
||||
environment: { API_KEY: "secret" },
|
||||
disabled: false,
|
||||
timeout: { request: 10000 },
|
||||
timeout: { catalog: 10000 },
|
||||
},
|
||||
remote: {
|
||||
type: "remote",
|
||||
|
|
@ -505,7 +513,7 @@ describe("Config", () => {
|
|||
|
||||
expect(documents).toHaveLength(1)
|
||||
expect(documents[0]?.info.shell).toBe("/bin/bash")
|
||||
expect(documents[0]?.info.model).toBe("anthropic/claude")
|
||||
expect(documents[0]?.info.model).toEqual(selection("anthropic/claude"))
|
||||
expect(documents[0]?.info.default_agent).toBe("reviewer")
|
||||
expect(documents[0]?.info.autoupdate).toBe("notify")
|
||||
expect(documents[0]?.info.share).toBe("disabled")
|
||||
|
|
@ -516,8 +524,7 @@ describe("Config", () => {
|
|||
{ action: "bash", resource: "git status", effect: "allow" },
|
||||
])
|
||||
const reviewer = documents[0]?.info.agents?.reviewer
|
||||
expect(reviewer?.model).toBe("openrouter/openai/gpt-5")
|
||||
expect(reviewer?.variant).toBe("high")
|
||||
expect(reviewer?.model).toEqual(selection("openrouter/openai/gpt-5#high"))
|
||||
expect(reviewer?.request).toEqual({
|
||||
headers: { "x-agent": "reviewer" },
|
||||
body: { reasoningEffort: "high" },
|
||||
|
|
@ -545,14 +552,14 @@ describe("Config", () => {
|
|||
})
|
||||
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
|
||||
expect(documents[0]?.info.mcp).toEqual({
|
||||
timeout: { startup: 5000, request: 60000 },
|
||||
timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
|
||||
servers: {
|
||||
local: {
|
||||
type: "local",
|
||||
command: ["node", "./mcp/server.js"],
|
||||
environment: { API_KEY: "secret" },
|
||||
disabled: false,
|
||||
timeout: { request: 10000 },
|
||||
timeout: { catalog: 10000 },
|
||||
},
|
||||
remote: {
|
||||
type: "remote",
|
||||
|
|
@ -748,34 +755,32 @@ describe("Config", () => {
|
|||
})
|
||||
expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
|
||||
expect(documents[0]?.info.providers?.custom).toMatchObject({
|
||||
request: { body: { apiKey: "secret" } },
|
||||
settings: { apiKey: "secret" },
|
||||
models: {
|
||||
model: {
|
||||
request: { body: { reasoningEffort: "high" } },
|
||||
variants: [{ id: "fast", body: { temperature: 0.2 } }],
|
||||
settings: { reasoningEffort: "high" },
|
||||
variants: [{ id: "fast", settings: { temperature: 0.2 } }],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(documents[0]?.info.providers?.openai).toMatchObject({
|
||||
api: { settings: {} },
|
||||
request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } },
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai"),
|
||||
settings: { apiKey: "secret", organization: "org" },
|
||||
models: {
|
||||
model: {
|
||||
request: {
|
||||
body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" },
|
||||
},
|
||||
variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }],
|
||||
settings: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
|
||||
variants: [{ id: "high", settings: { reasoningEffort: "high", reasoningSummary: "auto" } }],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(documents[0]?.info.providers?.anthropic).toMatchObject({
|
||||
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
|
||||
models: {
|
||||
model: {
|
||||
request: {
|
||||
body: {
|
||||
output_config: { effort: "high", task_budget: 4096 },
|
||||
metadata: { user_id: "user-1" },
|
||||
},
|
||||
settings: {
|
||||
effort: "high",
|
||||
taskBudget: 4096,
|
||||
metadata: { userId: "user-1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -787,19 +792,19 @@ describe("Config", () => {
|
|||
buffer: 10000,
|
||||
})
|
||||
expect(documents[0]?.info.mcp).toMatchObject({
|
||||
timeout: { request: 5000 },
|
||||
timeout: { catalog: 5000, execution: 5000 },
|
||||
servers: {
|
||||
local: {
|
||||
type: "local",
|
||||
command: ["node", "server.js"],
|
||||
disabled: true,
|
||||
timeout: { request: 10000 },
|
||||
timeout: { catalog: 10000, execution: 10000 },
|
||||
},
|
||||
remote: {
|
||||
type: "remote",
|
||||
url: "https://mcp.example.com",
|
||||
oauth: { client_id: "client", callback_port: 19876 },
|
||||
timeout: { request: 20000 },
|
||||
timeout: { catalog: 20000, execution: 20000 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
28
packages/core/test/config/model.test.ts
Normal file
28
packages/core/test/config/model.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigModel } from "@opencode-ai/core/config/model"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const decode = Schema.decodeUnknownSync(ConfigModel.Selection)
|
||||
|
||||
describe("ConfigModel.Selection", () => {
|
||||
test("normalizes short and explicit model selections", () => {
|
||||
expect(decode("openrouter/openai/gpt-5#high")).toEqual({
|
||||
providerID: Provider.ID.make("openrouter"),
|
||||
model: Model.ID.make("openai/gpt-5"),
|
||||
variant: Model.VariantID.make("high"),
|
||||
})
|
||||
expect(decode({ providerID: "anthropic", model: "claude-sonnet", variant: "high" })).toEqual({
|
||||
providerID: Provider.ID.make("anthropic"),
|
||||
model: Model.ID.make("claude-sonnet"),
|
||||
variant: Model.VariantID.make("high"),
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects malformed selections and reserved fragments", () => {
|
||||
expect(() => decode("gpt-5")).toThrow()
|
||||
expect(() => decode("openai/gpt-5#")).toThrow()
|
||||
expect(() => decode({ providerID: "openai", model: "gpt-5#high" })).toThrow()
|
||||
})
|
||||
})
|
||||
|
|
@ -2,223 +2,47 @@ import { describe, expect, test } from "bun:test"
|
|||
import { ConfigProviderOptionsV1 } from "@opencode-ai/core/v1/config/provider-options"
|
||||
|
||||
describe("ConfigProviderOptionsV1", () => {
|
||||
test("keeps raw provider and request options unchanged", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("custom-provider")
|
||||
|
||||
expect(lowerer.provider({ apiKey: "secret", headers: { "x-test": "1" }, nested: { camelCase: true } })).toEqual({
|
||||
body: { apiKey: "secret", headers: { "x-test": "1" }, nested: { camelCase: true } },
|
||||
})
|
||||
expect(lowerer.request({ nested: { camelCase: true } })).toEqual({ nested: { camelCase: true } })
|
||||
})
|
||||
|
||||
test("falls back to raw lowering for prototype property package names", () => {
|
||||
expect(ConfigProviderOptionsV1.get("toString").provider({ enabled: true })).toEqual({ body: { enabled: true } })
|
||||
})
|
||||
|
||||
test("lowers OpenAI provider and request options", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/openai")
|
||||
|
||||
test("splits provider overlays without changing package settings", () => {
|
||||
expect(
|
||||
lowerer.provider({
|
||||
ConfigProviderOptionsV1.provider({
|
||||
apiKey: "secret",
|
||||
baseURL: "https://openai.example/v1",
|
||||
organization: "org",
|
||||
project: "project",
|
||||
headers: { "x-test": "1" },
|
||||
headers: { "x-test": "1", invalid: true },
|
||||
body: { store: true },
|
||||
timeout: 1000,
|
||||
nested: { camelCase: true },
|
||||
}),
|
||||
).toEqual({
|
||||
url: "https://openai.example/v1",
|
||||
headers: {
|
||||
Authorization: "Bearer secret",
|
||||
"OpenAI-Organization": "org",
|
||||
"OpenAI-Project": "project",
|
||||
"x-test": "1",
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://openai.example/v1",
|
||||
organization: "org",
|
||||
nested: { camelCase: true },
|
||||
},
|
||||
headers: { "x-test": "1" },
|
||||
body: { store: true },
|
||||
settings: { timeout: 1000 },
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps model and variant options unchanged", () => {
|
||||
expect(
|
||||
lowerer.request({
|
||||
ConfigProviderOptionsV1.model({
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
reasoning: { encryptedContent: true },
|
||||
textVerbosity: "low",
|
||||
text: { outputFormat: "plain" },
|
||||
nestedValue: { camelCase: true },
|
||||
}),
|
||||
).toEqual({
|
||||
reasoning: { encrypted_content: true, effort: "high", summary: "auto" },
|
||||
text: { output_format: "plain", verbosity: "low" },
|
||||
nested_value: { camel_case: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers Anthropic provider and request options", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/anthropic")
|
||||
|
||||
expect(
|
||||
lowerer.provider({
|
||||
apiKey: "secret",
|
||||
authToken: "token",
|
||||
baseURL: "https://anthropic.example",
|
||||
headers: { "x-test": "1" },
|
||||
body: { beta: true },
|
||||
generateId: "custom",
|
||||
}),
|
||||
).toEqual({
|
||||
url: "https://anthropic.example",
|
||||
headers: { "x-api-key": "secret", Authorization: "Bearer token", "x-test": "1" },
|
||||
body: { beta: true },
|
||||
settings: { generateId: "custom" },
|
||||
})
|
||||
expect(
|
||||
lowerer.request({
|
||||
effort: "high",
|
||||
taskBudget: 1024,
|
||||
metadata: { userId: "user", traceId: "trace" },
|
||||
nestedValue: { camelCase: true },
|
||||
metadata: { userId: "user" },
|
||||
}),
|
||||
).toEqual({
|
||||
output_config: { effort: "high", task_budget: 1024 },
|
||||
metadata: { user_id: "user", trace_id: "trace" },
|
||||
nested_value: { camel_case: true },
|
||||
reasoningEffort: "high",
|
||||
taskBudget: 1024,
|
||||
metadata: { userId: "user" },
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers Google provider and request options", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/google")
|
||||
|
||||
expect(
|
||||
lowerer.provider({
|
||||
apiKey: "secret",
|
||||
baseURL: "https://google.example",
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
project: "project",
|
||||
}),
|
||||
).toEqual({
|
||||
url: "https://google.example",
|
||||
headers: { "x-goog-api-key": "secret", "x-test": "1" },
|
||||
body: { trace: true },
|
||||
settings: { project: "project" },
|
||||
})
|
||||
expect(
|
||||
lowerer.request({
|
||||
thinkingConfig: { thinkingBudget: 1024 },
|
||||
responseModalities: ["TEXT"],
|
||||
mediaResolution: "high",
|
||||
imageConfig: { aspectRatio: "16:9" },
|
||||
safetySettings: ["safe"],
|
||||
}),
|
||||
).toEqual({
|
||||
safetySettings: ["safe"],
|
||||
generationConfig: {
|
||||
thinkingConfig: { thinkingBudget: 1024 },
|
||||
responseModalities: ["TEXT"],
|
||||
mediaResolution: "high",
|
||||
imageConfig: { aspectRatio: "16:9" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers Azure provider options and uses OpenAI request lowering", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/azure")
|
||||
|
||||
expect(
|
||||
lowerer.provider({
|
||||
apiKey: "secret",
|
||||
baseURL: "https://azure.example",
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
resourceName: "resource",
|
||||
}),
|
||||
).toEqual({
|
||||
url: "https://azure.example",
|
||||
headers: { "api-key": "secret", "x-test": "1" },
|
||||
body: { trace: true },
|
||||
settings: { resourceName: "resource" },
|
||||
})
|
||||
expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
text: { verbosity: "low" },
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers Amazon Bedrock provider and request options", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/amazon-bedrock")
|
||||
|
||||
expect(
|
||||
lowerer.provider({
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
region: "us-east-1",
|
||||
profile: "dev",
|
||||
}),
|
||||
).toEqual({
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
settings: { region: "us-east-1", profile: "dev" },
|
||||
})
|
||||
expect(lowerer.request({ temperature: 0.2 })).toEqual({
|
||||
additionalModelRequestFields: { temperature: 0.2 },
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers OpenAI-compatible provider and request options", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/openai-compatible")
|
||||
|
||||
expect(
|
||||
lowerer.provider({
|
||||
baseURL: "https://compatible.example/v1",
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
apiKey: "secret",
|
||||
}),
|
||||
).toEqual({
|
||||
url: "https://compatible.example/v1",
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
settings: { apiKey: "secret" },
|
||||
})
|
||||
expect(lowerer.request({ reasoningEffort: "high", serviceTier: "priority" })).toEqual({
|
||||
reasoning_effort: "high",
|
||||
serviceTier: "priority",
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"@ai-sdk/cerebras",
|
||||
"@ai-sdk/deepinfra",
|
||||
"@ai-sdk/groq",
|
||||
"@ai-sdk/mistral",
|
||||
"@ai-sdk/togetherai",
|
||||
"@ai-sdk/xai",
|
||||
"@openrouter/ai-sdk-provider",
|
||||
"ai-gateway-provider",
|
||||
"venice-ai-sdk-provider",
|
||||
])("uses OpenAI-compatible lowering for %s", (packageName) => {
|
||||
const lowerer = ConfigProviderOptionsV1.get(packageName)
|
||||
|
||||
expect(lowerer.provider({ baseURL: "https://example.test", apiKey: "secret" })).toEqual({
|
||||
url: "https://example.test",
|
||||
test("uses mechanical lowering for custom provider options", () => {
|
||||
expect(ConfigProviderOptionsV1.provider({ enabled: true })).toEqual({
|
||||
settings: { enabled: true },
|
||||
headers: undefined,
|
||||
body: undefined,
|
||||
settings: { apiKey: "secret" },
|
||||
})
|
||||
expect(lowerer.request({ reasoningEffort: "high" })).toEqual({ reasoning_effort: "high" })
|
||||
})
|
||||
|
||||
test.each(["@ai-sdk/google-vertex", "@ai-sdk/google-vertex/anthropic"])(
|
||||
"uses provider family lowering for %s",
|
||||
(packageName) => {
|
||||
const lowerer = ConfigProviderOptionsV1.get(packageName)
|
||||
|
||||
expect(lowerer.provider({ baseURL: "https://example.test", profile: "dev" })).toMatchObject({
|
||||
url: "https://example.test",
|
||||
settings: { profile: "dev" },
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -45,13 +45,6 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
|||
)
|
||||
}
|
||||
|
||||
function request(headers: Record<string, string>, variant?: string) {
|
||||
return {
|
||||
headers,
|
||||
variant,
|
||||
}
|
||||
}
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
|
|
@ -68,7 +61,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
info: decode({
|
||||
providers: {
|
||||
opencode: {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" },
|
||||
package: "aisdk:@ai-sdk/openai",
|
||||
settings: { baseURL: "https://opencode.test/v1" },
|
||||
models: {
|
||||
"alpha-gpt-next": {
|
||||
variants: [
|
||||
|
|
@ -119,7 +113,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
info: decode({
|
||||
providers: {
|
||||
opencode: {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" },
|
||||
package: "aisdk:@ai-sdk/openai",
|
||||
settings: { baseURL: "https://opencode.test/v1" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -144,7 +139,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
yield* addPlugin(config)
|
||||
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect(model.variants[0]).toMatchObject({
|
||||
expect(model.variants?.[0]).toMatchObject({
|
||||
id: "high",
|
||||
body: { reasoningEffort: "high" },
|
||||
})
|
||||
|
|
@ -169,8 +164,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
custom: {
|
||||
name: "Configured",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
api: { type: "native", settings: {} },
|
||||
request: request({ first: "first", shared: "first" }),
|
||||
package: "native",
|
||||
headers: { first: "first", shared: "first" },
|
||||
models: {
|
||||
chat: {
|
||||
name: "First",
|
||||
|
|
@ -178,7 +173,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
disabled: true,
|
||||
limit: { context: 100, output: 50 },
|
||||
cost: { input: 1, output: 2 },
|
||||
request: request({ first: "first", shared: "first" }, "retained"),
|
||||
settings: { retained: true },
|
||||
headers: { first: "first", shared: "first" },
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
|
|
@ -197,17 +193,18 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
model: "custom/default",
|
||||
providers: {
|
||||
custom: {
|
||||
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
package: "aisdk:custom-sdk",
|
||||
settings: { baseURL: "https://example.test" },
|
||||
headers: { last: "last", shared: "last" },
|
||||
models: {
|
||||
default: {
|
||||
name: "Default",
|
||||
},
|
||||
chat: {
|
||||
api: { id: "api-chat" },
|
||||
modelID: "api-chat",
|
||||
name: "Last",
|
||||
limit: { output: 75 },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
headers: { last: "last", shared: "last" },
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
|
|
@ -247,22 +244,24 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
})
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
||||
expect(provider.disabled).toBeUndefined()
|
||||
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
||||
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(provider.package).toBe("aisdk:custom-sdk")
|
||||
expect(provider.settings).toEqual({ baseURL: "https://example.test" })
|
||||
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.id).toBe(modelID)
|
||||
expect(model.modelID).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
|
||||
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.request.variant).toBe("retained")
|
||||
expect(model.variants.map((variant) => variant.id)).toEqual([
|
||||
expect(model.settings).toEqual({ baseURL: "https://example.test", retained: true })
|
||||
expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants?.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("fast"),
|
||||
ModelV2.VariantID.make("slow"),
|
||||
])
|
||||
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
|
||||
expect(model.variants?.[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants?.[1]?.headers).toEqual({ slow: "slow" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
|
|||
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
||||
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
||||
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
|
||||
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
|
||||
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
|
||||
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
|
@ -38,6 +42,29 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
|||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
|
||||
describe("DatabaseMigration", () => {
|
||||
test("resets incompatible V2 Session event history", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session_input (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||
yield* db.run(sql`INSERT INTO session_input (id) VALUES ('input')`)
|
||||
yield* db.run(sql`INSERT INTO session_message (id) VALUES ('message')`)
|
||||
yield* db.run(sql`INSERT INTO event (id) VALUES ('event')`)
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 1)`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [resetSessionEventsMigration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT id FROM session_input`)).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT id FROM session_message`)).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT id FROM event`)).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT aggregate_id FROM event_sequence`)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("serializes concurrent embedded initialization for one database path", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "embedded.sqlite")
|
||||
|
|
@ -73,23 +100,24 @@ describe("DatabaseMigration", () => {
|
|||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
||||
).toEqual({ name: "session_input" })
|
||||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
|
||||
).toEqual({ name: "session_context_epoch" })
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`),
|
||||
).toEqual({ name: "instruction_checkpoint" })
|
||||
expect(
|
||||
yield* db.get(
|
||||
sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name IN ('agent', 'replacement_seq', 'revision')`,
|
||||
sql`SELECT name FROM pragma_table_info('instruction_checkpoint') WHERE name IN ('agent', 'replacement_seq', 'revision')`,
|
||||
),
|
||||
).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
|
||||
expect(
|
||||
yield* db.all(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "event_aggregate_seq_idx" },
|
||||
{ name: "event_aggregate_type_seq_idx" },
|
||||
{ name: "session_input_session_admitted_seq_idx" },
|
||||
{ name: "session_input_session_pending_delivery_seq_idx" },
|
||||
{ name: "session_input_session_pending_compaction_idx" },
|
||||
{ name: "session_input_session_pending_type_delivery_seq_idx" },
|
||||
{ name: "session_input_session_promoted_seq_idx" },
|
||||
{ name: "session_message_session_seq_idx" },
|
||||
{ name: "session_message_session_time_created_id_idx" },
|
||||
|
|
@ -131,6 +159,75 @@ describe("DatabaseMigration", () => {
|
|||
)
|
||||
})
|
||||
|
||||
test("separates existing fork provenance from subagent hierarchy", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, parent_id text)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE event (aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO session VALUES ('ses_source', NULL), ('ses_fork', 'ses_source')`)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO event VALUES ('ses_fork', 0, 'session.forked', '{"sessionID":"ses_fork","parentID":"ses_source","from":"msg_boundary"}')`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [addSessionForkMigration])
|
||||
|
||||
expect(
|
||||
yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_fork'`),
|
||||
).toEqual({
|
||||
parent_id: null,
|
||||
fork_session_id: "ses_source",
|
||||
fork_message_id: "msg_boundary",
|
||||
})
|
||||
expect(
|
||||
yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_source'`),
|
||||
).toEqual({
|
||||
parent_id: null,
|
||||
fork_session_id: null,
|
||||
fork_message_id: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("renames instruction state without losing rows or durable updates", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_context_entry (session_id text NOT NULL, key text NOT NULL, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, PRIMARY KEY(session_id, key))`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`,
|
||||
)
|
||||
yield* db.run(sql`CREATE TABLE event (type text NOT NULL)`)
|
||||
yield* db.run(sql`INSERT INTO session_context_entry VALUES ('ses_test', 'plan', '"ready"', 1, 2)`)
|
||||
yield* db.run(sql`INSERT INTO session_context_epoch VALUES ('ses_test', 'baseline', '{}', 7)`)
|
||||
yield* db.run(sql`INSERT INTO event VALUES ('session.context.updated.1')`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [renameInstructionsMigration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT * FROM instruction_entry`)).toEqual({
|
||||
session_id: "ses_test",
|
||||
key: "plan",
|
||||
value: '"ready"',
|
||||
time_created: 1,
|
||||
time_updated: 2,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({
|
||||
session_id: "ses_test",
|
||||
baseline: "baseline",
|
||||
snapshot: "{}",
|
||||
baseline_seq: 7,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT type FROM event`)).toEqual({ type: "session.instructions.updated.1" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps legacy credential fields nullable", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -258,16 +355,18 @@ describe("DatabaseMigration", () => {
|
|||
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`,
|
||||
sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'prompt', '{}', 'steer', 9, 1)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`,
|
||||
sql`INSERT INTO instruction_checkpoint (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`,
|
||||
)
|
||||
yield* db.run(sql`ALTER TABLE instruction_checkpoint RENAME TO session_context_epoch`)
|
||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionInputMigration.id}`)
|
||||
yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration])
|
||||
yield* db.run(sql`ALTER TABLE session_context_epoch RENAME TO instruction_checkpoint`)
|
||||
|
||||
const database = Layer.succeed(Database.Service, { db })
|
||||
yield* EventV2.Service.use((service) =>
|
||||
|
|
@ -299,7 +398,7 @@ describe("DatabaseMigration", () => {
|
|||
(SELECT COUNT(*) FROM workspace) AS workspaces,
|
||||
(SELECT COUNT(*) FROM session_input) AS sessionInputs,
|
||||
(SELECT COUNT(*) FROM session_message) AS sessionMessages,
|
||||
(SELECT COUNT(*) FROM session_context_epoch) AS contextEpochs,
|
||||
(SELECT COUNT(*) FROM instruction_checkpoint) AS instructionCheckpoints,
|
||||
(SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq,
|
||||
(SELECT type FROM event WHERE aggregate_id = 'session') AS eventType
|
||||
`),
|
||||
|
|
@ -311,7 +410,7 @@ describe("DatabaseMigration", () => {
|
|||
workspaces: 0,
|
||||
sessionInputs: 0,
|
||||
sessionMessages: 0,
|
||||
contextEpochs: 0,
|
||||
instructionCheckpoints: 0,
|
||||
seq: 0,
|
||||
eventType: "session.updated.1",
|
||||
})
|
||||
|
|
@ -319,6 +418,37 @@ describe("DatabaseMigration", () => {
|
|||
)
|
||||
})
|
||||
|
||||
test("preserves admitted prompts while generalizing the durable inbox", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', '{"text":"hello"}', 'steer', 4, NULL, 1)`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [durableSessionInboxMigration])
|
||||
|
||||
expect(
|
||||
yield* db.all(
|
||||
sql`SELECT id, type, prompt, delivery, admitted_seq, promoted_seq FROM session_input ORDER BY admitted_seq`,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: "input",
|
||||
type: "prompt",
|
||||
prompt: '{"text":"hello"}',
|
||||
delivery: "steer",
|
||||
admitted_seq: 4,
|
||||
promoted_seq: null,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ const GlobalMessage = EventV2.ephemeral({
|
|||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
const CountMessage = EventV2.ephemeral({
|
||||
type: "test.count",
|
||||
schema: {
|
||||
count: Schema.Number,
|
||||
},
|
||||
})
|
||||
|
||||
const VersionedMessage = EventV2.durable({
|
||||
type: "test.versioned",
|
||||
|
|
@ -90,6 +96,26 @@ const it = testEffect(
|
|||
const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node])))
|
||||
|
||||
describe("EventV2", () => {
|
||||
it.effect("subscribes to multiple event definitions with a discriminated payload union", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
// @ts-expect-error multi-definition subscriptions require at least one definition
|
||||
events.subscribe([])
|
||||
const fiber = yield* events
|
||||
.subscribe([Message, CountMessage])
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* events.publish(Message, { text: "hello" })
|
||||
yield* events.publish(CountMessage, { count: 2 })
|
||||
|
||||
const received = Array.from(yield* Fiber.join(fiber)).map((event) =>
|
||||
event.type === "test.message" ? event.data.text : event.data.count,
|
||||
)
|
||||
expect(received).toEqual(["hello", 2])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes events with the current location", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -151,7 +177,7 @@ describe("EventV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const typed = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
const wildcard = yield* events.live().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
const wildcard = yield* events.subscribe().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const event = yield* events.publish(Message, { text: "hello" })
|
||||
|
||||
|
|
@ -232,7 +258,7 @@ describe("EventV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<string>()
|
||||
const fiber = yield* events.live().pipe(
|
||||
const fiber = yield* events.subscribe().pipe(
|
||||
Stream.take(1),
|
||||
Stream.runForEach(() => Effect.sync(() => received.push("stream"))),
|
||||
Effect.forkScoped,
|
||||
|
|
@ -686,8 +712,8 @@ describe("EventV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
const received = new Array<typeof SessionEvent.ContextUpdated.Type>()
|
||||
yield* events.project(SessionEvent.ContextUpdated, (event) =>
|
||||
const received = new Array<typeof SessionEvent.InstructionsUpdated.Type>()
|
||||
yield* events.project(SessionEvent.InstructionsUpdated, (event) =>
|
||||
Effect.sync(() => {
|
||||
received.push(event)
|
||||
}),
|
||||
|
|
@ -696,7 +722,7 @@ describe("EventV2", () => {
|
|||
yield* events.replay({
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1),
|
||||
type: EventV2.versionedType(SessionEvent.InstructionsUpdated.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: { sessionID: aggregateID, text: "context" },
|
||||
|
|
@ -1289,52 +1315,6 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("changes emits sweep-required on subscribe then coalesced hints per aggregate", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const first = Session.ID.create()
|
||||
const second = Session.ID.create()
|
||||
const pull = yield* Stream.toPull(events.changes())
|
||||
|
||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.sweep_required" }])
|
||||
|
||||
yield* events.publish(DurableMessage, durableData(first, "zero"))
|
||||
yield* events.publish(DurableMessage, durableData(first, "one"))
|
||||
yield* events.publish(DurableMessage, durableData(first, "two"))
|
||||
yield* events.publish(DurableMessage, durableData(second, "zero"))
|
||||
|
||||
expect(Array.from(yield* pull)).toEqual([
|
||||
{ type: "log.hint", aggregateID: first, seq: EventV2.Seq.make(2) },
|
||||
{ type: "log.hint", aggregateID: second, seq: EventV2.Seq.make(0) },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("changes abandons the hint buffer for a sweep when key retention is exceeded", () =>
|
||||
Effect.gen(function* () {
|
||||
const eventLayer = EventV2.layerWith({ changesKeyCapacity: 2 }).pipe(
|
||||
Layer.provide(LayerNode.compile(Database.node)),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const pull = yield* Stream.toPull(events.changes())
|
||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.sweep_required" }])
|
||||
|
||||
yield* events.publish(DurableMessage, durableData(Session.ID.create(), "a"))
|
||||
yield* events.publish(DurableMessage, durableData(Session.ID.create(), "b"))
|
||||
yield* events.publish(DurableMessage, durableData(Session.ID.create(), "c"))
|
||||
|
||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.sweep_required" }])
|
||||
|
||||
const late = Session.ID.create()
|
||||
yield* events.publish(DurableMessage, durableData(late, "d"))
|
||||
|
||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.hint", aggregateID: late, seq: EventV2.Seq.make(0) }])
|
||||
}).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer)))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sequences returns the latest committed seq per aggregate and omits unknown aggregates", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
|
|||
26
packages/core/test/fixture/mcp-timeout.ts
Normal file
26
packages/core/test/fixture/mcp-timeout.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
GetPromptRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } })
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
|
||||
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
|
||||
})
|
||||
server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] }))
|
||||
server.setRequestHandler(CallToolRequestSchema, async () => {
|
||||
await Bun.sleep(100)
|
||||
return { content: [] }
|
||||
})
|
||||
server.setRequestHandler(GetPromptRequestSchema, async () => {
|
||||
await Bun.sleep(100)
|
||||
return { messages: [] }
|
||||
})
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
|
|
@ -6,10 +6,10 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { Instructions } from "@opencode-ai/core/instructions"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
|
@ -21,13 +21,13 @@ const instructionLayer = (input: {
|
|||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
}) =>
|
||||
AppNodeBuilder.build(InstructionContext.node, [
|
||||
AppNodeBuilder.build(InstructionDiscovery.node, [
|
||||
[Global.node, Global.layerWith({ config: input.config })],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
])
|
||||
|
||||
describe("InstructionContext", () => {
|
||||
describe("InstructionDiscovery", () => {
|
||||
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
@ -51,7 +51,7 @@ describe("InstructionContext", () => {
|
|||
await fs.writeFile(packageFile, "package")
|
||||
})
|
||||
|
||||
const load = InstructionContext.Service.pipe(
|
||||
const load = InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -69,7 +69,7 @@ describe("InstructionContext", () => {
|
|||
),
|
||||
)
|
||||
|
||||
const initialized = yield* SystemContext.initialize(yield* load)
|
||||
const initialized = yield* Instructions.initialize(yield* load)
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
|
|
@ -80,13 +80,13 @@ describe("InstructionContext", () => {
|
|||
expect(initialized.text).not.toContain("outside")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toMatchObject({
|
||||
expect(yield* Instructions.reconcile(yield* load, initialized.applied)).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => fs.rm(packageFile))
|
||||
const partial = yield* SystemContext.reconcile(yield* load, initialized.applied)
|
||||
const partial = yield* Instructions.reconcile(yield* load, initialized.applied)
|
||||
expect(partial).toEqual({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
|
|
@ -98,7 +98,7 @@ describe("InstructionContext", () => {
|
|||
})
|
||||
|
||||
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toEqual({
|
||||
expect(yield* Instructions.reconcile(yield* load, initialized.applied)).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Previously loaded instructions no longer apply.",
|
||||
applied: {},
|
||||
|
|
@ -117,7 +117,7 @@ describe("InstructionContext", () => {
|
|||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "AGENTS.md")
|
||||
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -130,7 +130,7 @@ describe("InstructionContext", () => {
|
|||
),
|
||||
)
|
||||
|
||||
expect((yield* SystemContext.initialize(context)).text).toBe(`Instructions from: ${file}\n`)
|
||||
expect((yield* Instructions.initialize(context)).text).toBe(`Instructions from: ${file}\n`)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
@ -146,7 +146,7 @@ describe("InstructionContext", () => {
|
|||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -161,7 +161,7 @@ describe("InstructionContext", () => {
|
|||
)
|
||||
|
||||
expect(
|
||||
yield* SystemContext.reconcile(context, {
|
||||
yield* Instructions.reconcile(context, {
|
||||
"core/instructions": {
|
||||
value: [{ path: "/repo/AGENTS.md", content: "old" }],
|
||||
removed: "Previously loaded instructions no longer apply.",
|
||||
|
|
@ -186,7 +186,7 @@ describe("InstructionContext", () => {
|
|||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -201,7 +201,7 @@ describe("InstructionContext", () => {
|
|||
)
|
||||
|
||||
expect(
|
||||
yield* SystemContext.reconcile(context, {
|
||||
yield* Instructions.reconcile(context, {
|
||||
"core/instructions": {
|
||||
value: [{ path: file, content: "old" }],
|
||||
removed: "Previously loaded instructions no longer apply.",
|
||||
|
|
@ -230,7 +230,7 @@ describe("InstructionContext", () => {
|
|||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
|
||||
yield* InstructionContext.Service.pipe(
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -260,7 +260,7 @@ describe("InstructionContext", () => {
|
|||
let scanned = false
|
||||
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
|
||||
|
||||
yield* InstructionContext.Service.pipe(
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
|
@ -292,7 +292,7 @@ describe("InstructionContext", () => {
|
|||
it.effect("does not discover project instructions outside the canonical project root", () =>
|
||||
Effect.gen(function* () {
|
||||
let scanned = false
|
||||
yield* InstructionContext.Service.pipe(
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
83
packages/core/test/instructions/builtins.test.ts
Normal file
83
packages/core/test/instructions/builtins.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Instructions } from "@opencode-ai/core/instructions"
|
||||
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
|
||||
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
|
||||
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
|
||||
const localDate = (time: number) => new Date(time).toDateString()
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory },
|
||||
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
|
||||
),
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(InstructionBuiltIns.node, [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: "/global" })],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("InstructionBuiltIns", () => {
|
||||
it.effect("loads location-scoped environment and host-local date instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* InstructionBuiltIns.Service
|
||||
const initialized = yield* Instructions.initialize(yield* context.load())
|
||||
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
` Working directory: ${directory}`,
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
].join("\n"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles the date without repeating unchanged environment instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* InstructionBuiltIns.Service
|
||||
const initialized = yield* Instructions.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
||||
const refreshed = yield* Instructions.reconcile(yield* context.load(), initialized.applied)
|
||||
|
||||
expect(refreshed).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not update again within the same local calendar day", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* InstructionBuiltIns.Service
|
||||
const initialized = yield* Instructions.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
|
||||
expect(yield* Instructions.reconcile(yield* context.load(), initialized.applied)).toEqual({ _tag: "Unchanged" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { Instructions } from "@opencode-ai/core/instructions"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const key = SystemContext.Key.make
|
||||
const key = Instructions.Key.make
|
||||
const stringContext = (input: {
|
||||
key: string
|
||||
value: string | SystemContext.Unavailable
|
||||
value: string | Instructions.Unavailable
|
||||
baseline?: (value: string) => string
|
||||
update?: (previous: string, current: string) => string
|
||||
removed?: (value: string) => string
|
||||
}) =>
|
||||
SystemContext.make({
|
||||
Instructions.make({
|
||||
key: key(input.key),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.succeed(input.value),
|
||||
|
|
@ -20,10 +20,10 @@ const stringContext = (input: {
|
|||
removed: input.removed,
|
||||
})
|
||||
|
||||
describe("SystemContext", () => {
|
||||
describe("Instructions", () => {
|
||||
it.effect("stores the canonical JSON encoding of the loaded value", () =>
|
||||
Effect.gen(function* () {
|
||||
const context = SystemContext.make({
|
||||
const context = Instructions.make({
|
||||
key: key("core/date"),
|
||||
codec: Schema.toCodecJson(Schema.DateFromString),
|
||||
load: Effect.succeed(new Date("2026-06-03T12:00:00.000Z")),
|
||||
|
|
@ -32,15 +32,15 @@ describe("SystemContext", () => {
|
|||
removed: () => "Date removed",
|
||||
})
|
||||
|
||||
expect((yield* SystemContext.initialize(context)).applied["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
|
||||
expect((yield* Instructions.initialize(context)).applied["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads once and initializes a baseline with the applied values", () =>
|
||||
Effect.gen(function* () {
|
||||
let loads = 0
|
||||
const context = SystemContext.combine([
|
||||
SystemContext.make({
|
||||
const context = Instructions.combine([
|
||||
Instructions.make({
|
||||
key: key("core/date"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.sync(() => {
|
||||
|
|
@ -54,7 +54,7 @@ describe("SystemContext", () => {
|
|||
stringContext({ key: "core/location", value: "/repo", baseline: (value) => `Directory: ${value}` }),
|
||||
])
|
||||
|
||||
expect(yield* SystemContext.initialize(context)).toEqual({
|
||||
expect(yield* Instructions.initialize(context)).toEqual({
|
||||
text: "Today's date is 2026-06-03.\n\nDirectory: /repo",
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-03", removed: "The date was removed." },
|
||||
|
|
@ -71,7 +71,7 @@ describe("SystemContext", () => {
|
|||
"core/date": { value: "2026-06-03", removed: "The date was removed." },
|
||||
"core/location": { value: "/repo", removed: "Removed: /repo" },
|
||||
}
|
||||
const changed = SystemContext.combine([
|
||||
const changed = Instructions.combine([
|
||||
stringContext({
|
||||
key: "core/date",
|
||||
value: "2026-06-04",
|
||||
|
|
@ -81,7 +81,7 @@ describe("SystemContext", () => {
|
|||
stringContext({ key: "core/location", value: "/repo" }),
|
||||
])
|
||||
|
||||
expect(yield* SystemContext.reconcile(changed, previous)).toEqual({
|
||||
expect(yield* Instructions.reconcile(changed, previous)).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "The date changed from 2026-06-03 to 2026-06-04.",
|
||||
applied: {
|
||||
|
|
@ -91,8 +91,8 @@ describe("SystemContext", () => {
|
|||
})
|
||||
|
||||
expect(
|
||||
yield* SystemContext.reconcile(
|
||||
SystemContext.combine([
|
||||
yield* Instructions.reconcile(
|
||||
Instructions.combine([
|
||||
stringContext({ key: "core/date", value: "2026-06-03", removed: () => "The date was removed." }),
|
||||
stringContext({ key: "core/location", value: "/repo" }),
|
||||
]),
|
||||
|
|
@ -110,7 +110,7 @@ describe("SystemContext", () => {
|
|||
baseline: (skill) => `Available skill: ${skill}`,
|
||||
})
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, {})).toEqual({
|
||||
expect(yield* Instructions.reconcile(context, {})).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Available skill: effect",
|
||||
applied: { "core/skills": { value: "effect" } },
|
||||
|
|
@ -121,30 +121,28 @@ describe("SystemContext", () => {
|
|||
it.effect("retains the belief while a source is temporarily unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } }
|
||||
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
|
||||
const context = stringContext({ key: "core/remote", value: Instructions.unavailable })
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
|
||||
expect(yield* Instructions.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("blocks initialization while a source is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* SystemContext.initialize(
|
||||
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
|
||||
const exit = yield* Instructions.initialize(
|
||||
stringContext({ key: "core/remote", value: Instructions.unavailable }),
|
||||
).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit))
|
||||
expect(Cause.squash(exit.cause)).toEqual(
|
||||
new SystemContext.InitializationBlocked({ keys: [key("core/remote")] }),
|
||||
)
|
||||
expect(Cause.squash(exit.cause)).toEqual(new Instructions.InitializationBlocked({ keys: [key("core/remote")] }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits the previously stored removal message", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* SystemContext.reconcile(SystemContext.empty, {
|
||||
yield* Instructions.reconcile(Instructions.empty, {
|
||||
"core/instructions": { value: "contents", removed: "Instructions removed; stop applying them." },
|
||||
}),
|
||||
).toEqual({
|
||||
|
|
@ -157,13 +155,13 @@ describe("SystemContext", () => {
|
|||
|
||||
it.effect("retains an unannounced removal silently", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toEqual({
|
||||
expect(yield* Instructions.reconcile(Instructions.empty, { "core/date": { value: "2026-06-04" } })).toEqual({
|
||||
_tag: "Unchanged",
|
||||
})
|
||||
|
||||
// The retained belief survives alongside other updates.
|
||||
expect(
|
||||
yield* SystemContext.reconcile(stringContext({ key: "core/skills", value: "effect" }), {
|
||||
yield* Instructions.reconcile(stringContext({ key: "core/skills", value: "effect" }), {
|
||||
"core/date": { value: "2026-06-04" },
|
||||
}),
|
||||
).toEqual({
|
||||
|
|
@ -180,7 +178,7 @@ describe("SystemContext", () => {
|
|||
it.effect("renders multiple removals in stable key order", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* SystemContext.reconcile(SystemContext.empty, {
|
||||
yield* Instructions.reconcile(Instructions.empty, {
|
||||
"core/z": { value: "z", removed: "Removed z" },
|
||||
"core/a": { value: "a", removed: "Removed a" },
|
||||
}),
|
||||
|
|
@ -190,7 +188,7 @@ describe("SystemContext", () => {
|
|||
|
||||
it.effect("rejects empty model-visible renderings", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* SystemContext.initialize(
|
||||
const exit = yield* Instructions.initialize(
|
||||
stringContext({ key: "core/empty", value: "value", baseline: () => "" }),
|
||||
).pipe(Effect.exit)
|
||||
|
||||
|
|
@ -202,7 +200,7 @@ describe("SystemContext", () => {
|
|||
it.effect("re-announces the baseline when a stored value no longer decodes", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
|
||||
yield* Instructions.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
|
||||
"core/date": { value: 42, removed: "Date removed" },
|
||||
}),
|
||||
).toEqual({
|
||||
|
|
@ -215,7 +213,7 @@ describe("SystemContext", () => {
|
|||
|
||||
it.effect("renders undecodable re-announcements alongside other updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const context = SystemContext.combine([
|
||||
const context = Instructions.combine([
|
||||
stringContext({
|
||||
key: "core/date",
|
||||
value: "2026-06-04",
|
||||
|
|
@ -225,7 +223,7 @@ describe("SystemContext", () => {
|
|||
])
|
||||
|
||||
expect(
|
||||
yield* SystemContext.reconcile(context, {
|
||||
yield* Instructions.reconcile(context, {
|
||||
"core/date": { value: "2026-06-03" },
|
||||
"core/location": { value: 42 },
|
||||
}),
|
||||
|
|
@ -243,7 +241,7 @@ describe("SystemContext", () => {
|
|||
it.effect("rebaselines from one coherent source observation", () =>
|
||||
Effect.gen(function* () {
|
||||
let loads = 0
|
||||
const context = SystemContext.make({
|
||||
const context = Instructions.make({
|
||||
key: key("core/date"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.sync(() => {
|
||||
|
|
@ -254,7 +252,7 @@ describe("SystemContext", () => {
|
|||
update: (_previous, current) => current,
|
||||
})
|
||||
|
||||
expect(yield* SystemContext.rebaseline(context, { "core/date": { value: "2026-06-03" } })).toEqual({
|
||||
expect(yield* Instructions.rebaseline(context, { "core/date": { value: "2026-06-03" } })).toEqual({
|
||||
text: "2026-06-04",
|
||||
applied: { "core/date": { value: "2026-06-04" } },
|
||||
})
|
||||
|
|
@ -264,17 +262,17 @@ describe("SystemContext", () => {
|
|||
|
||||
it.effect("rebaselines an unavailable source from the last-applied belief", () =>
|
||||
Effect.gen(function* () {
|
||||
const context = SystemContext.combine([
|
||||
const context = Instructions.combine([
|
||||
stringContext({ key: "core/date", value: "2026-06-04" }),
|
||||
stringContext({
|
||||
key: "core/remote",
|
||||
value: SystemContext.unavailable,
|
||||
value: Instructions.unavailable,
|
||||
baseline: (value) => `Instructions: ${value}`,
|
||||
}),
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* SystemContext.rebaseline(context, {
|
||||
yield* Instructions.rebaseline(context, {
|
||||
"core/remote": { value: "contents", removed: "Instructions removed" },
|
||||
}),
|
||||
).toEqual({
|
||||
|
|
@ -289,11 +287,11 @@ describe("SystemContext", () => {
|
|||
|
||||
it.effect("drops undecodable beliefs and removed sources at rebaseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
|
||||
const context = stringContext({ key: "core/remote", value: Instructions.unavailable })
|
||||
|
||||
// Undecodable belief cannot be restated; removed source entries self-clean.
|
||||
expect(
|
||||
yield* SystemContext.rebaseline(context, {
|
||||
yield* Instructions.rebaseline(context, {
|
||||
"core/remote": { value: 42 },
|
||||
"core/gone": { value: "gone" },
|
||||
}),
|
||||
|
|
@ -315,7 +313,7 @@ describe("SystemContext", () => {
|
|||
]
|
||||
|
||||
expect(
|
||||
SystemContext.diffByKey(
|
||||
Instructions.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(value) => value.name,
|
||||
|
|
@ -337,19 +335,19 @@ describe("SystemContext", () => {
|
|||
it.effect("rejects duplicate source keys", () =>
|
||||
Effect.sync(() => {
|
||||
expect(() =>
|
||||
SystemContext.combine([
|
||||
Instructions.combine([
|
||||
stringContext({ key: "core/date", value: "one" }),
|
||||
stringContext({ key: "core/date", value: "two" }),
|
||||
]),
|
||||
).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") }))
|
||||
).toThrow(new Instructions.DuplicateKeyError({ key: key("core/date") }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("combines contexts in order", () =>
|
||||
it.effect("combines instructions in order", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
(yield* SystemContext.initialize(
|
||||
SystemContext.combine([
|
||||
(yield* Instructions.initialize(
|
||||
Instructions.combine([
|
||||
stringContext({ key: "core/date", value: "date" }),
|
||||
stringContext({ key: "core/location", value: "location" }),
|
||||
]),
|
||||
|
|
@ -360,7 +358,7 @@ describe("SystemContext", () => {
|
|||
|
||||
it.effect("requires namespaced source keys", () =>
|
||||
Effect.sync(() => {
|
||||
const decodeKey = Schema.decodeUnknownSync(SystemContext.Key)
|
||||
const decodeKey = Schema.decodeUnknownSync(Instructions.Key)
|
||||
|
||||
expect(decodeKey("core/date")).toBe(key("core/date"))
|
||||
expect(() => decodeKey("date")).toThrow()
|
||||
|
|
@ -369,7 +367,7 @@ describe("SystemContext", () => {
|
|||
|
||||
it.effect("requires namespaced applied keys", () =>
|
||||
Effect.sync(() => {
|
||||
const decodeApplied = Schema.decodeUnknownSync(SystemContext.Applied)
|
||||
const decodeApplied = Schema.decodeUnknownSync(Instructions.Applied)
|
||||
|
||||
expect(Object.keys(decodeApplied({ "core/date": { value: "date" } }))).toEqual(["core/date"])
|
||||
expect(() => decodeApplied({ date: { value: "date" } })).toThrow()
|
||||
|
|
@ -2,6 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
|
|||
import type { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { Tools } from "@opencode-ai/core/tool/tools"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, type Scope } from "effect"
|
||||
|
|
@ -49,7 +50,24 @@ export const registerToolPlugin = <R>(plugin: {
|
|||
const tools = yield* Tools.Service
|
||||
const context: Pick<PluginContext, "tool"> = {
|
||||
tool: {
|
||||
register: tools.register,
|
||||
transform: (callback) =>
|
||||
Effect.gen(function* () {
|
||||
const registrations: Array<{
|
||||
readonly name: string
|
||||
readonly tool: Tool.AnyTool
|
||||
readonly options?: Tool.RegisterOptions
|
||||
}> = []
|
||||
callback({
|
||||
add: (name, tool, options) => {
|
||||
registrations.push({ name, tool, ...(options ? { options } : {}) })
|
||||
},
|
||||
})
|
||||
yield* Effect.forEach(
|
||||
registrations,
|
||||
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
execute: {
|
||||
before: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
after: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ describe("LocationServiceMap", () => {
|
|||
providers: {
|
||||
unavailable: {
|
||||
name: "Unavailable",
|
||||
api: { type: "native", settings: {} },
|
||||
package: "test-provider",
|
||||
models: { chat: { disabled: true } },
|
||||
},
|
||||
},
|
||||
|
|
@ -306,7 +306,7 @@ describe("LocationServiceMap", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("preserves the selected catalog identity when the api model id differs", () =>
|
||||
it.live("preserves the selected catalog identity when the package model id differs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
|
|
@ -318,12 +318,12 @@ describe("LocationServiceMap", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.provider.update(ProviderV2.ID.make("aliased"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/openai", settings: {} }
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai")
|
||||
})
|
||||
editor.model.update(ProviderV2.ID.make("aliased"), ModelV2.ID.make("fast"), (model) => {
|
||||
// Catalog id and provider API id intentionally differ, like gpt-5.5-fast -> gpt-5.5.
|
||||
model.api = { ...model.api, id: ModelV2.ID.make("base") }
|
||||
model.variants.push({ id: ModelV2.VariantID.make("high"), settings: {}, headers: {}, body: {} })
|
||||
// Catalog id and package model id intentionally differ, like gpt-5.5-fast -> gpt-5.5.
|
||||
model.modelID = ModelV2.ID.make("base")
|
||||
model.variants = [{ id: ModelV2.VariantID.make("high") }]
|
||||
})
|
||||
})
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
|
|
|
|||
|
|
@ -71,9 +71,9 @@ const it = testEffect(
|
|||
describe("MCP errors", () => {
|
||||
test("expose useful messages", () => {
|
||||
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
|
||||
expect(new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message).toBe(
|
||||
"failed",
|
||||
)
|
||||
expect(
|
||||
new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
|
||||
).toBe("failed")
|
||||
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
|
||||
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
||||
})
|
||||
|
|
@ -177,13 +177,77 @@ test("retains output schemas across paginated MCP discovery", async () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("applies the configured MCP catalog timeout", async () => {
|
||||
const result = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* MCPClient.connect(
|
||||
"catalog-timeout",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
|
||||
environment: { MCP_TIMEOUT_TARGET: "catalog" },
|
||||
timeout: new ConfigMCP.Timeout({ catalog: 10 }),
|
||||
}),
|
||||
import.meta.dir,
|
||||
)
|
||||
return yield* connection.tools()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(result).rejects.toThrow("Request timed out")
|
||||
})
|
||||
|
||||
test("applies the configured MCP execution timeout", async () => {
|
||||
const result = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* MCPClient.connect(
|
||||
"execution-timeout",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
|
||||
timeout: new ConfigMCP.Timeout({ execution: 10 }),
|
||||
}),
|
||||
import.meta.dir,
|
||||
)
|
||||
return yield* connection.callTool({ name: "slow" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(result).rejects.toThrow("Request timed out")
|
||||
})
|
||||
|
||||
test("applies the configured MCP execution timeout to prompts", async () => {
|
||||
const result = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* MCPClient.connect(
|
||||
"prompt-timeout",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
|
||||
timeout: new ConfigMCP.Timeout({ execution: 10 }),
|
||||
}),
|
||||
import.meta.dir,
|
||||
)
|
||||
return yield* connection.prompt({ name: "slow" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(result).rejects.toThrow("Request timed out")
|
||||
})
|
||||
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute")
|
||||
|
||||
expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{ ok: boolean }>")
|
||||
expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{\n ok: boolean,\n}>")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -227,20 +291,20 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("does not call MCP when permission is rejected", () =>
|
||||
it.effect("does not call MCP when permission is blocked", () =>
|
||||
Effect.gen(function* () {
|
||||
calls = 0
|
||||
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
||||
decision = Effect.fail(new PermissionV2.RejectedError())
|
||||
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
|
||||
const settlement = yield* settleTool(registry, {
|
||||
sessionID: SessionV2.ID.make("ses_mcp_rejected"),
|
||||
sessionID: SessionV2.ID.make("ses_mcp_blocked"),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call_mcp_rejected",
|
||||
id: "call_mcp_blocked",
|
||||
name: "execute",
|
||||
input: { code: "return await tools.demo.search({})" },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -148,8 +148,8 @@ describe("PermissionV2", () => {
|
|||
const service = yield* PermissionV2.Service
|
||||
yield* service.assert(assertion())
|
||||
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
|
||||
const denied = yield* service.assert(assertion()).pipe(Effect.flip)
|
||||
expect(denied).toBeInstanceOf(PermissionV2.DeniedError)
|
||||
const blocked = yield* service.assert(assertion()).pipe(Effect.flip)
|
||||
expect(blocked).toBeInstanceOf(PermissionV2.BlockedError)
|
||||
expect(yield* service.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
|
@ -266,6 +266,24 @@ describe("PermissionV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("defects when an asked permission is declined", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const { service, fiber, request } = yield* waitForRequest()
|
||||
yield* service.reply({ requestID: request.id, reply: "reject" })
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
if (exit._tag === "Failure")
|
||||
expect(
|
||||
exit.cause.reasons.some(
|
||||
(reason) => Cause.isDieReason(reason) && reason.defect instanceof PermissionV2.DeclinedError,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(yield* service.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores and removes saved resources for a project", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
|
|
|
|||
|
|
@ -165,14 +165,17 @@ describe("PluginV2", () => {
|
|||
id: "tool-plugin",
|
||||
effect: (ctx) =>
|
||||
ctx.tool
|
||||
.register({
|
||||
plugin_tool: Tool.make({
|
||||
description: "Plugin tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
})
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
"plugin_tool",
|
||||
Tool.make({
|
||||
description: "Plugin tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
|
|
@ -202,13 +205,13 @@ describe("PluginV2", () => {
|
|||
const plugin = define({
|
||||
id: "grouped-tools",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool.register({ plain: tool("Plain") }).pipe(Effect.orDie)
|
||||
yield* ctx.tool.register({ "look/up": tool("Lookup") }, { group: "context 7" }).pipe(Effect.orDie)
|
||||
yield* ctx.tool
|
||||
.register({ search: tool("Search") }, { group: "context 7", deferred: true })
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
ctx.tool
|
||||
.transform((draft) => {
|
||||
draft.add("plain", tool("Plain"))
|
||||
draft.add("look/up", tool("Lookup"), { group: "context 7" })
|
||||
draft.add("search", tool("Search"), { group: "context 7", deferred: true })
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
yield* plugins.activate([{ plugin }])
|
||||
|
|
@ -236,14 +239,17 @@ describe("PluginV2", () => {
|
|||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool
|
||||
.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||
}),
|
||||
})
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
"echo",
|
||||
Tool.make({
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.tool.execute
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default define({
|
||||
|
|
@ -7,14 +8,11 @@ export default define({
|
|||
ctx.catalog
|
||||
.transform((catalog) => {
|
||||
catalog.provider.update("configured", (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
})
|
||||
catalog.model.update("configured", "glm-5.2", (model) => {
|
||||
model.api = {
|
||||
id: "glm-5.2",
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
}
|
||||
model.modelID = "glm-5.2"
|
||||
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
model.variants = [
|
||||
{
|
||||
id: "high",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
reload: () => Effect.die("unused skill.reload"),
|
||||
},
|
||||
tool: overrides.tool ?? {
|
||||
register: () => Effect.die("unused tool.register"),
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
execute: {
|
||||
before: () => Effect.die("unused tool.execute.before"),
|
||||
after: () => Effect.die("unused tool.execute.after"),
|
||||
|
|
@ -163,7 +163,7 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
|
|||
id: ModelV2.ID.make(current.id),
|
||||
providerID: ProviderV2.ID.make(current.providerID),
|
||||
family: current.family === undefined ? undefined : ModelV2.Family.make(current.family),
|
||||
variants: current.variants.map((variant) => ({
|
||||
variants: current.variants?.map((variant) => ({
|
||||
...variant,
|
||||
id: ModelV2.VariantID.make(variant.id),
|
||||
})),
|
||||
|
|
@ -341,35 +341,28 @@ function agentInfo(value: AgentV2.Info) {
|
|||
function providerInfo(value: ProviderV2.MutableInfo) {
|
||||
return {
|
||||
...value,
|
||||
api: { ...value.api, settings: value.api.settings && { ...value.api.settings } },
|
||||
request: {
|
||||
settings: { ...value.request.settings },
|
||||
headers: { ...value.request.headers },
|
||||
body: { ...value.request.body },
|
||||
},
|
||||
settings: value.settings && { ...value.settings },
|
||||
headers: value.headers && { ...value.headers },
|
||||
body: value.body && { ...value.body },
|
||||
}
|
||||
}
|
||||
|
||||
function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) {
|
||||
return {
|
||||
...value,
|
||||
api: { ...value.api, settings: value.api.settings && { ...value.api.settings } },
|
||||
settings: value.settings && { ...value.settings },
|
||||
headers: value.headers && { ...value.headers },
|
||||
body: value.body && { ...value.body },
|
||||
capabilities: {
|
||||
...value.capabilities,
|
||||
input: [...value.capabilities.input],
|
||||
output: [...value.capabilities.output],
|
||||
},
|
||||
request: {
|
||||
...value.request,
|
||||
settings: { ...value.request.settings },
|
||||
headers: { ...value.request.headers },
|
||||
body: { ...value.request.body },
|
||||
},
|
||||
variants: value.variants.map((variant) => ({
|
||||
variants: value.variants?.map((variant) => ({
|
||||
...variant,
|
||||
settings: { ...variant.settings },
|
||||
headers: { ...variant.headers },
|
||||
body: { ...variant.body },
|
||||
settings: variant.settings && { ...variant.settings },
|
||||
headers: variant.headers && { ...variant.headers },
|
||||
body: variant.body && { ...variant.body },
|
||||
})),
|
||||
time: { ...value.time },
|
||||
cost: value.cost.map((cost) => ({ ...cost, tier: cost.tier && { ...cost.tier }, cache: { ...cost.cache } })),
|
||||
|
|
|
|||
|
|
@ -94,16 +94,16 @@ describe("ModelsDevPlugin", () => {
|
|||
const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
|
||||
|
||||
expect(base?.variants).toEqual([])
|
||||
expect(base?.request.body).toEqual({})
|
||||
expect(base?.body).toEqual({})
|
||||
expect(fast).toMatchObject({
|
||||
id: "gpt-5.4-fast",
|
||||
modelID: "gpt-5.4",
|
||||
providerID: "acme",
|
||||
name: "GPT-5.4 Fast",
|
||||
api: { id: "gpt-5.4" },
|
||||
request: {
|
||||
headers: { "x-mode": "fast" },
|
||||
body: { service_tier: "priority" },
|
||||
},
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://api.acme.test/v1" },
|
||||
headers: { "x-mode": "fast" },
|
||||
body: { service_tier: "priority" },
|
||||
variants: [],
|
||||
})
|
||||
expect(fast?.cost).toEqual([
|
||||
|
|
@ -192,7 +192,7 @@ describe("ModelsDevPlugin", () => {
|
|||
)
|
||||
|
||||
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
|
||||
expect(model?.variants.map((variant) => variant.id)).toEqual([
|
||||
expect(model?.variants?.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("low"),
|
||||
ModelV2.VariantID.make("high"),
|
||||
])
|
||||
|
|
@ -203,8 +203,6 @@ describe("ModelsDevPlugin", () => {
|
|||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
expect(model?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
|
|
@ -213,20 +211,16 @@ describe("ModelsDevPlugin", () => {
|
|||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
|
||||
expect(mode).toMatchObject({
|
||||
id: "gpt-reasoning-high",
|
||||
name: "GPT Reasoning High",
|
||||
request: {
|
||||
headers: { "x-mode": "high" },
|
||||
body: { service_tier: "priority" },
|
||||
},
|
||||
headers: { "x-mode": "high" },
|
||||
body: { service_tier: "priority" },
|
||||
})
|
||||
expect(mode?.variants.map((variant) => variant.id)).toEqual([
|
||||
expect(mode?.variants?.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("low"),
|
||||
ModelV2.VariantID.make("high"),
|
||||
])
|
||||
|
|
@ -235,22 +229,19 @@ describe("ModelsDevPlugin", () => {
|
|||
expect(budgetModel?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
expect(budgetModel?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("max"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 64000 } },
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const anthropicEffortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-effort"))
|
||||
const anthropicEffortModel = yield* catalog.model.get(
|
||||
ProviderV2.ID.anthropic,
|
||||
ModelV2.ID.make("claude-effort"),
|
||||
)
|
||||
expect(anthropicEffortModel?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("low"),
|
||||
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
|
||||
(previous) =>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ describe("AlibabaPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
|
||||
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("qwen"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/alibaba",
|
||||
options: { name: "alibaba" },
|
||||
|
|
@ -45,7 +46,8 @@ describe("AlibabaPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
|
||||
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("qwen"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "alibaba" },
|
||||
|
|
@ -62,7 +64,8 @@ describe("AlibabaPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")),
|
||||
api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("qwen"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/alibaba",
|
||||
options: { name: "custom-alibaba", apiKey: "test" },
|
||||
|
|
@ -74,17 +77,18 @@ describe("AlibabaPlugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the old default languageModel(api.id) behavior", () =>
|
||||
it.effect("uses the default languageModel(modelID) behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const item = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("qwen-plus"),
|
||||
package: "aisdk:test-provider",
|
||||
})
|
||||
const result = yield* aisdk.runSDK({ model: item, package: "@ai-sdk/alibaba", options: {} })
|
||||
const language = result.sdk?.languageModel(item.api.id)
|
||||
const language = result.sdk?.languageModel(item.modelID ?? item.id)
|
||||
expect(language?.modelId).toBe("qwen-plus")
|
||||
expect(language?.provider).toBe("alibaba.chat")
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -79,31 +79,24 @@ function openAIUrl(language: unknown, path: string, modelId: string) {
|
|||
}
|
||||
|
||||
describe("AmazonBedrockPlugin", () => {
|
||||
it.effect("moves endpoint option to api URL", () =>
|
||||
it.effect("moves endpoint setting to baseURL", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const bedrock = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock),
|
||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
|
||||
request: {
|
||||
headers: {},
|
||||
body: { endpoint: "https://bedrock.example" },
|
||||
},
|
||||
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
settings: { endpoint: "https://bedrock.example" },
|
||||
})
|
||||
catalog.provider.update(bedrock.id, (item) => {
|
||||
item.api = bedrock.api
|
||||
item.request = { settings: {}, headers: {}, body: { endpoint: "https://bedrock.example" } }
|
||||
item.package = bedrock.package
|
||||
item.settings = { endpoint: "https://bedrock.example" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock))
|
||||
expect(result.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
url: "https://bedrock.example",
|
||||
})
|
||||
expect(result.request.body.endpoint).toBeUndefined()
|
||||
expect(result.package).toBe(ProviderV2.aisdk("@ai-sdk/amazon-bedrock"))
|
||||
expect(result.settings).toEqual({ baseURL: "https://bedrock.example" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -116,7 +109,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
|
|
@ -141,7 +135,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
|
|
@ -175,11 +170,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
|
|
@ -199,7 +191,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock", region: "eu-west-1" },
|
||||
|
|
@ -218,7 +211,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
|
|
@ -237,7 +231,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: { name: "amazon-bedrock" },
|
||||
|
|
@ -257,7 +252,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
|
|
@ -286,7 +282,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
|
|
@ -314,11 +311,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("openai.gpt-5.5"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||
options: {
|
||||
|
|
@ -345,11 +339,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("openai.gpt-5.5"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" },
|
||||
|
|
@ -357,11 +348,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { region: "us-east-1" },
|
||||
|
|
@ -378,11 +366,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/anthropic"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock/anthropic",
|
||||
options: { name: "amazon-bedrock" },
|
||||
|
|
@ -409,11 +394,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
|
|
@ -444,7 +426,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
|
|
@ -452,7 +435,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
|
|
@ -460,11 +444,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
|
|
@ -472,7 +453,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "ap-northeast-1" },
|
||||
|
|
@ -480,7 +462,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "ap-southeast-2" },
|
||||
|
|
@ -505,7 +488,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
|
|
@ -591,7 +575,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)),
|
||||
api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make(item.modelID),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: item.region },
|
||||
|
|
@ -610,7 +595,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: { region: "eu-west-1" },
|
||||
|
|
|
|||
|
|
@ -31,19 +31,19 @@ describe("AnthropicPlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const item = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.anthropic),
|
||||
api: { type: "aisdk", package: "@ai-sdk/anthropic" },
|
||||
request: { headers: { Existing: "1" }, body: {} },
|
||||
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
|
||||
headers: { Existing: "1" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.request = { settings: {}, headers: { Existing: "1" }, body: {} }
|
||||
draft.package = item.package
|
||||
draft.headers = { Existing: "1" }
|
||||
})
|
||||
})
|
||||
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)).headers?.["anthropic-beta"]).toBe(
|
||||
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
||||
)
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers.Existing).toBe("1")
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).headers?.Existing).toBe("1")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -52,9 +52,7 @@ describe("AnthropicPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"],
|
||||
).toBeUndefined()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).headers?.["anthropic-beta"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -66,7 +64,8 @@ describe("AnthropicPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
|
||||
}),
|
||||
package: "@ai-sdk/anthropic",
|
||||
options: { name: "custom-anthropic", apiKey: "test" },
|
||||
|
|
@ -83,7 +82,8 @@ describe("AnthropicPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
|
||||
}),
|
||||
package: "@ai-sdk/anthropic",
|
||||
options: { name: "anthropic", apiKey: "test" },
|
||||
|
|
|
|||
|
|
@ -66,18 +66,16 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
|
||||
item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
item.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
|
||||
expect(result.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://cognitive.cognitiveservices.azure.com/openai",
|
||||
expect(result).toMatchObject({
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://cognitive.cognitiveservices.azure.com/openai" },
|
||||
})
|
||||
expect(result.request.body.baseURL).toBeUndefined()
|
||||
expect(result.request.body.resourceName).toBeUndefined()
|
||||
expect(result.settings?.resourceName).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -89,26 +87,28 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const azure = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")),
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
})
|
||||
const openai = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
package: "aisdk:test-provider",
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.package = azure.package
|
||||
item.package = azure.package
|
||||
})
|
||||
catalog.provider.update(openai.id, (item) => {
|
||||
item.api = openai.api
|
||||
item.package = openai.package
|
||||
item.package = openai.package
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
|
||||
const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai))
|
||||
expect(azure.request.body.baseURL).toBeUndefined()
|
||||
expect(azure.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" })
|
||||
expect(openai.request.body.baseURL).toBeUndefined()
|
||||
expect(openai.api).toEqual({ type: "aisdk", package: "test-provider" })
|
||||
expect(azure.settings?.baseURL).toBeUndefined()
|
||||
expect(azure).toMatchObject({ package: "aisdk:@ai-sdk/openai-compatible" })
|
||||
expect(openai.settings?.baseURL).toBeUndefined()
|
||||
expect(openai).toMatchObject({ package: "aisdk:test-provider" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -122,7 +122,8 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { useCompletionUrls: true },
|
||||
|
|
@ -140,7 +141,8 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -148,7 +150,8 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
const ignored = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -168,7 +171,8 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("messages-deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel },
|
||||
options: {},
|
||||
|
|
@ -176,7 +180,8 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("chat-deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { chat: sdk.chat, languageModel: sdk.languageModel },
|
||||
options: {},
|
||||
|
|
@ -184,7 +189,8 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("language-deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { languageModel: sdk.languageModel },
|
||||
options: {},
|
||||
|
|
|
|||
|
|
@ -66,11 +66,11 @@ describe("AzurePlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.azure, (item) => {
|
||||
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
|
||||
item.package = ProviderV2.aisdk("@ai-sdk/azure")
|
||||
})
|
||||
})
|
||||
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)).settings?.resourceName).toBe("from-env")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -82,18 +82,18 @@ describe("AzurePlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const azure = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.azure),
|
||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
request: { headers: {}, body: { resourceName: "from-config" } },
|
||||
package: ProviderV2.aisdk("@ai-sdk/azure"),
|
||||
settings: { resourceName: "from-config" },
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: "from-config" } }
|
||||
item.package = azure.package
|
||||
item.settings = { resourceName: "from-config" }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config")
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).settings?.resourceName).toBe("from-config")
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).settings?.resourceName).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -105,16 +105,16 @@ describe("AzurePlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const azure = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.azure),
|
||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
request: { headers: {}, body: { resourceName: "" } },
|
||||
package: ProviderV2.aisdk("@ai-sdk/azure"),
|
||||
settings: { resourceName: "" },
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: "" } }
|
||||
item.package = azure.package
|
||||
item.settings = { resourceName: "" }
|
||||
})
|
||||
})
|
||||
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)).settings?.resourceName).toBe("from-env")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -126,16 +126,16 @@ describe("AzurePlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const azure = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.azure),
|
||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
request: { headers: {}, body: { resourceName: " " } },
|
||||
package: ProviderV2.aisdk("@ai-sdk/azure"),
|
||||
settings: { resourceName: " " },
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: " " } }
|
||||
item.package = azure.package
|
||||
item.settings = { resourceName: " " }
|
||||
})
|
||||
})
|
||||
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)).settings?.resourceName).toBe("from-env")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -149,7 +149,8 @@ describe("AzurePlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/azure",
|
||||
options: { name: "azure", baseURL: "https://proxy.example.com/openai" },
|
||||
|
|
@ -168,7 +169,8 @@ describe("AzurePlugin", () => {
|
|||
.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/azure",
|
||||
options: { name: "azure" },
|
||||
|
|
@ -188,7 +190,8 @@ describe("AzurePlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { useCompletionUrls: true },
|
||||
|
|
@ -206,7 +209,8 @@ describe("AzurePlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { useCompletionUrls: true },
|
||||
|
|
@ -224,8 +228,9 @@ describe("AzurePlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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 } },
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
body: { useCompletionUrls: true },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -243,7 +248,8 @@ describe("AzurePlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -251,7 +257,8 @@ describe("AzurePlugin", () => {
|
|||
const ignored = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
|
||||
api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -274,7 +281,8 @@ describe("AzurePlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")),
|
||||
api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("messages-deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") },
|
||||
options: {},
|
||||
|
|
@ -282,7 +290,8 @@ describe("AzurePlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")),
|
||||
api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("language-deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: { languageModel: make("languageModel") },
|
||||
options: {},
|
||||
|
|
|
|||
|
|
@ -36,12 +36,12 @@ describe("CerebrasPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
|
||||
item.api = { type: "aisdk", package: "@ai-sdk/cerebras" }
|
||||
item.request.headers.Existing = "1"
|
||||
item.package = ProviderV2.aisdk("@ai-sdk/cerebras")
|
||||
item.headers = { ...item.headers, Existing: "1" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.headers).toEqual({
|
||||
Existing: "1",
|
||||
"X-Cerebras-3rd-Party-Integration": "opencode",
|
||||
})
|
||||
|
|
@ -53,7 +53,7 @@ describe("CerebrasPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -69,11 +69,8 @@ describe("CerebrasPlugin", () => {
|
|||
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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/cerebras",
|
||||
options: { name: "custom-cerebras", apiKey: "test" },
|
||||
|
|
@ -95,11 +92,8 @@ describe("CerebrasPlugin", () => {
|
|||
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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/cerebras",
|
||||
options: { name: "configured-cerebras", apiKey: "test" },
|
||||
|
|
@ -120,11 +114,8 @@ describe("CerebrasPlugin", () => {
|
|||
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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/groq",
|
||||
options: { name: "custom-cerebras", apiKey: "test" },
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
|
|
@ -139,7 +140,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: {
|
||||
|
|
@ -183,7 +185,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: {
|
||||
|
|
@ -212,7 +215,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: {
|
||||
|
|
@ -249,7 +253,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: {
|
||||
|
|
@ -280,7 +285,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
|
|
@ -302,7 +308,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
|
|
@ -325,7 +332,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
|
|
@ -354,7 +362,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" },
|
||||
|
|
@ -380,11 +389,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||
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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "ai-gateway-provider",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
|
|
@ -412,7 +418,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-ai-gateway" },
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "test-provider" }
|
||||
provider.package = ProviderV2.aisdk("test-provider")
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
|
|
@ -95,15 +95,16 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const sdk = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
api: { id: ModelV2.ID.make("@cf/model"), ...provider.api },
|
||||
modelID: ModelV2.ID.make("@cf/model"),
|
||||
package: provider.package,
|
||||
settings: provider.settings,
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
|
||||
})
|
||||
expect(provider.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1",
|
||||
expect(provider).toMatchObject({
|
||||
package: "aisdk:test-provider",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
|
||||
})
|
||||
expect(sdk.sdk).toBeDefined()
|
||||
}),
|
||||
|
|
@ -116,14 +117,14 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
|
||||
provider.package = ProviderV2.aisdk("test-provider")
|
||||
provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
url: "https://proxy.example/v1",
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))).toMatchObject({
|
||||
package: "aisdk:test-provider",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -138,12 +139,9 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
|
||||
|
|
@ -159,15 +157,14 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "test-provider" }
|
||||
provider.request.body.accountId = "configured-acct"
|
||||
provider.package = ProviderV2.aisdk("test-provider")
|
||||
provider.settings = { ...provider.settings, accountId: "configured-acct" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))).toMatchObject({
|
||||
package: "aisdk:test-provider",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -182,12 +179,9 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
|
|
@ -214,12 +208,9 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("@cf/model"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
},
|
||||
modelID: ModelV2.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
|
|
@ -243,7 +234,8 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("@cf/api-model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -262,12 +254,9 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/anthropic",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
}),
|
||||
package: "@ai-sdk/anthropic",
|
||||
options: { name: "cloudflare-workers-ai" },
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ describe("CoherePlugin", () => {
|
|||
const ignored = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
|
||||
api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("command"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cohere" },
|
||||
|
|
@ -66,7 +67,8 @@ describe("CoherePlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
|
||||
api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("command"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/cohere",
|
||||
options: { name: "cohere" },
|
||||
|
|
@ -83,7 +85,8 @@ describe("CoherePlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("command-r-plus"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/cohere",
|
||||
options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" },
|
||||
|
|
@ -108,7 +111,8 @@ describe("CoherePlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("command-r-plus"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk,
|
||||
options: {},
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ describe("DeepInfraPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:@ai-sdk/deepinfra",
|
||||
}),
|
||||
package: "@ai-sdk/deepinfra",
|
||||
options: { name: "deepinfra" },
|
||||
|
|
@ -66,7 +67,8 @@ describe("DeepInfraPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:@ai-sdk/deepinfra",
|
||||
}),
|
||||
package: "@ai-sdk/deepinfra",
|
||||
options: { name: "custom-deepinfra", apiKey: "test" },
|
||||
|
|
@ -85,7 +87,8 @@ describe("DeepInfraPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:@ai-sdk/deepinfra",
|
||||
}),
|
||||
package: "@ai-sdk/deepinfra",
|
||||
options: { name: "deepinfra", apiKey: "test" },
|
||||
|
|
@ -111,7 +114,8 @@ describe("DeepInfraPlugin", () => {
|
|||
const ignored = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:@ai-sdk/deepinfra",
|
||||
}),
|
||||
package: item,
|
||||
options: { name: "deepinfra" },
|
||||
|
|
@ -122,7 +126,8 @@ describe("DeepInfraPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:@ai-sdk/deepinfra",
|
||||
}),
|
||||
package: "@ai-sdk/deepinfra",
|
||||
options: { name: "deepinfra" },
|
||||
|
|
@ -141,17 +146,14 @@ describe("DeepInfraPlugin", () => {
|
|||
const sdkEvent = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"),
|
||||
package: "aisdk:@ai-sdk/deepinfra",
|
||||
}),
|
||||
package: "@ai-sdk/deepinfra",
|
||||
options: { name: "deepinfra" },
|
||||
})
|
||||
const result = yield* aisdk.runLanguage({ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options })
|
||||
const language = result.language ?? result.sdk.languageModel(result.model.api.id)
|
||||
const language = result.language ?? result.sdk.languageModel(result.model.modelID ?? result.model.id)
|
||||
expect(language.provider).toBe("deepinfra.chat")
|
||||
expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"])
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ describe("DynamicProviderPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
|
||||
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
package: ProviderV2.aisdk(fixtureProvider),
|
||||
}),
|
||||
package: fixtureProvider,
|
||||
options: { name: "custom", marker: "dynamic" },
|
||||
|
|
@ -72,7 +73,8 @@ describe("DynamicProviderPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
|
||||
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
package: ProviderV2.aisdk(fixtureProvider),
|
||||
}),
|
||||
package: fixtureProvider,
|
||||
options: { name: "custom", marker: "dynamic" },
|
||||
|
|
@ -89,7 +91,8 @@ describe("DynamicProviderPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
|
||||
api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
package: ProviderV2.aisdk(fixtureProvider),
|
||||
}),
|
||||
package: fixtureProvider,
|
||||
options: { name: "custom-provider", marker: "dynamic" },
|
||||
|
|
@ -105,7 +108,8 @@ describe("DynamicProviderPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
package: "aisdk:fixture-provider",
|
||||
}),
|
||||
package: "fixture-provider",
|
||||
options: { name: "npm-provider", marker: "npm" },
|
||||
|
|
@ -122,7 +126,8 @@ describe("DynamicProviderPlugin", () => {
|
|||
.language(
|
||||
ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
|
||||
modelID: ModelV2.ID.make("alias"),
|
||||
package: "aisdk:fixture-provider",
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
|
|
@ -139,7 +144,8 @@ describe("DynamicProviderPlugin", () => {
|
|||
.language(
|
||||
ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("alias"),
|
||||
package: "aisdk:file:///missing/provider-factory.js",
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
|
|
@ -158,7 +164,8 @@ describe("DynamicProviderPlugin", () => {
|
|||
.language(
|
||||
ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
|
||||
modelID: ModelV2.ID.make("alias"),
|
||||
package: "aisdk:fixture-provider",
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
|
|
@ -167,7 +174,7 @@ describe("DynamicProviderPlugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
itWithAISDK.effect("uses the model api.id for the default language model", () =>
|
||||
itWithAISDK.effect("uses the model modelID for the default language model", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
|
@ -175,7 +182,8 @@ describe("DynamicProviderPlugin", () => {
|
|||
const language = yield* aisdk.language(
|
||||
ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider },
|
||||
modelID: ModelV2.ID.make("test-model-api"),
|
||||
package: ProviderV2.aisdk(fixtureProvider),
|
||||
}),
|
||||
)
|
||||
expect(language).toMatchObject({ modelID: "test-model-api", options: { name: "custom" } })
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ describe("GatewayPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/gateway",
|
||||
options: { name: "gateway" },
|
||||
|
|
@ -65,11 +66,8 @@ describe("GatewayPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("anthropic/claude-sonnet-4"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/gateway",
|
||||
options: { name: "vercel", apiKey: "test-key" },
|
||||
|
|
@ -91,7 +89,8 @@ describe("GatewayPlugin", () => {
|
|||
const ignored = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
|
||||
api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make(modelID),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/vercel",
|
||||
options: { name: "vercel" },
|
||||
|
|
@ -101,7 +100,8 @@ describe("GatewayPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
|
||||
api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make(modelID),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/gateway",
|
||||
options: { name: "vercel" },
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
const ignored = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "github-copilot" },
|
||||
|
|
@ -55,7 +56,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/github-copilot",
|
||||
options: { name: "github-copilot" },
|
||||
|
|
@ -74,7 +76,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
|
|
@ -92,7 +95,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
|
|
@ -110,7 +114,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -118,7 +123,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("gpt-5.1-codex"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -126,7 +132,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("gpt-4o"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -134,7 +141,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("gpt-5-mini"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -142,7 +150,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("gpt-5-mini-2025-08-07"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -157,6 +166,36 @@ describe("GithubCopilotPlugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")),
|
||||
modelID: ModelV2.ID.make("mai-code-1-flash-picker"),
|
||||
package: "aisdk:test-provider",
|
||||
settings: { endpoint: "responses" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { endpoint: "responses" },
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
settings: { endpoint: "chat" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { endpoint: "chat" },
|
||||
})
|
||||
expect(calls).toEqual(["responses:mai-code-1-flash-picker", "chat:gpt-5"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the API model ID when selecting responses or chat", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
|
|
@ -166,7 +205,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -174,7 +214,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("gpt-5-mini"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -182,7 +223,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -230,7 +272,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ describe("GitLabPlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("claude"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "gitlab-ai-provider",
|
||||
options: { name: "gitlab" },
|
||||
|
|
@ -107,7 +108,8 @@ describe("GitLabPlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("claude"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "gitlab-ai-provider",
|
||||
options: { name: "gitlab" },
|
||||
|
|
@ -132,7 +134,8 @@ describe("GitLabPlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("claude"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "gitlab-ai-provider",
|
||||
options: {
|
||||
|
|
@ -173,7 +176,8 @@ describe("GitLabPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("claude"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai",
|
||||
options: { name: "gitlab" },
|
||||
|
|
@ -192,11 +196,10 @@ describe("GitLabPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
|
||||
api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" },
|
||||
request: {
|
||||
headers: {},
|
||||
body: { workflowRef: "ref", workflowDefinition: "definition" },
|
||||
},
|
||||
modelID: ModelV2.ID.make("duo-workflow-custom"),
|
||||
package: "aisdk:test-provider",
|
||||
headers: {},
|
||||
settings: { workflowRef: "ref", workflowDefinition: "definition" },
|
||||
}),
|
||||
sdk: {
|
||||
workflowChat: (id: string, options: unknown) => {
|
||||
|
|
@ -227,7 +230,8 @@ describe("GitLabPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("duo-workflow-exact"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: {
|
||||
workflowChat: (id: string, options: unknown) => {
|
||||
|
|
@ -245,7 +249,7 @@ describe("GitLabPlugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("uses provider feature flags instead of request feature flags", () =>
|
||||
it.effect("uses provider feature flags instead of model settings feature flags", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
|
@ -254,11 +258,10 @@ describe("GitLabPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
|
||||
api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" },
|
||||
request: {
|
||||
headers: {},
|
||||
body: { featureFlags: { request_flag: true } },
|
||||
},
|
||||
modelID: ModelV2.ID.make("duo-workflow-custom"),
|
||||
package: "aisdk:test-provider",
|
||||
headers: {},
|
||||
settings: { featureFlags: { request_flag: true } },
|
||||
}),
|
||||
sdk: {
|
||||
workflowChat: (id: string, options: unknown) => {
|
||||
|
|
@ -282,8 +285,10 @@ describe("GitLabPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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: {} },
|
||||
modelID: ModelV2.ID.make("claude"),
|
||||
package: "aisdk:test-provider",
|
||||
headers: { h: "v" },
|
||||
settings: {},
|
||||
}),
|
||||
sdk: {
|
||||
workflowChat: () => undefined,
|
||||
|
|
|
|||
|
|
@ -64,16 +64,16 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/google-vertex/anthropic")
|
||||
}),
|
||||
)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
expect(
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project,
|
||||
).toBe("cloud-project")
|
||||
expect(
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location,
|
||||
).toBe("cloud-location")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.project).toBe(
|
||||
"cloud-project",
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.location).toBe(
|
||||
"cloud-location",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -84,18 +84,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
provider.request.body.project = "configured-project"
|
||||
provider.request.body.location = "configured-location"
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/google-vertex/anthropic")
|
||||
provider.settings = { ...provider.settings, project: "configured-project", location: "configured-location" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe(
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.project).toBe(
|
||||
"configured-project",
|
||||
)
|
||||
expect(
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location,
|
||||
).toBe("configured-location")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.location).toBe(
|
||||
"configured-location",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -121,7 +120,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex-anthropic" },
|
||||
|
|
@ -147,7 +147,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex-anthropic" },
|
||||
|
|
@ -167,7 +168,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "eu" },
|
||||
|
|
@ -186,7 +188,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" },
|
||||
|
|
@ -204,7 +207,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
const sdkResult = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { name: "google-vertex", project: "project", location: "us" },
|
||||
|
|
@ -212,7 +216,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
const languageResult = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: sdkResult.sdk,
|
||||
options: {},
|
||||
|
|
@ -234,7 +239,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { languageModel: selector(calls) },
|
||||
options: {},
|
||||
|
|
@ -252,7 +258,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { languageModel: selector(calls) },
|
||||
options: {},
|
||||
|
|
|
|||
|
|
@ -92,17 +92,14 @@ describe("GoogleVertexPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://opencode.ai/zen/v1",
|
||||
}
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { ...provider.settings, baseURL: "https://opencode.ai/zen/v1" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode))
|
||||
expect(provider.request.body).toEqual({})
|
||||
expect(provider.settings).toEqual({ baseURL: "https://opencode.ai/zen/v1" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -121,21 +118,24 @@ describe("GoogleVertexPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
baseURL:
|
||||
"https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||
expect(provider.request.body.project).toBe("google-cloud-project")
|
||||
expect(provider.request.body.location).toBe("google-vertex-location")
|
||||
expect(provider.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location",
|
||||
expect(provider.settings?.project).toBe("google-cloud-project")
|
||||
expect(provider.settings?.location).toBe("google-vertex-location")
|
||||
expect(provider).toMatchObject({
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: {
|
||||
baseURL:
|
||||
"https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location",
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -160,10 +160,11 @@ describe("GoogleVertexPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
baseURL:
|
||||
"https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
|
@ -172,21 +173,20 @@ describe("GoogleVertexPlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google-vertex",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: { name: "google-vertex" },
|
||||
})
|
||||
|
||||
expect(provider.request.body.project).toBe("vertex-project")
|
||||
expect(provider.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4",
|
||||
expect(provider.settings?.project).toBe("vertex-project")
|
||||
expect(provider).toMatchObject({
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: {
|
||||
baseURL:
|
||||
"https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4",
|
||||
},
|
||||
})
|
||||
expect(vertexOptions[0].project).toBe("vertex-project")
|
||||
expect(vertexOptions[0].location).toBe("europe-west4")
|
||||
|
|
@ -209,23 +209,22 @@ describe("GoogleVertexPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
baseURL:
|
||||
"https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
}
|
||||
provider.request.body.project = "config-project"
|
||||
provider.request.body.location = "global"
|
||||
provider.settings = { ...provider.settings, project: "config-project", location: "global" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||
expect(provider.request.body.project).toBe("config-project")
|
||||
expect(provider.request.body.location).toBe("global")
|
||||
expect(provider.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global",
|
||||
expect(provider.settings?.project).toBe("config-project")
|
||||
expect(provider.settings?.location).toBe("global")
|
||||
expect(provider).toMatchObject({
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -236,21 +235,20 @@ describe("GoogleVertexPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
baseURL:
|
||||
"https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
}
|
||||
provider.request.body.project = "config-project"
|
||||
provider.request.body.location = "eu"
|
||||
provider.settings = { ...provider.settings, project: "config-project", location: "eu" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||
expect(provider.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu",
|
||||
expect(provider).toMatchObject({
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -270,14 +268,14 @@ describe("GoogleVertexPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" }
|
||||
provider.request.body.project = "config-project"
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/google-vertex")
|
||||
provider.settings = { ...provider.settings, project: "config-project" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||
expect(provider.request.body.project).toBe("config-project")
|
||||
expect(provider.request.body.location).toBe("us-central1")
|
||||
expect(provider.settings?.project).toBe("config-project")
|
||||
expect(provider.settings?.location).toBe("us-central1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -297,11 +295,8 @@ describe("GoogleVertexPlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google-vertex",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: { name: "google-vertex" },
|
||||
|
|
@ -345,11 +340,8 @@ describe("GoogleVertexPlugin", () => {
|
|||
aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "google-vertex" },
|
||||
|
|
@ -359,11 +351,11 @@ describe("GoogleVertexPlugin", () => {
|
|||
;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = originalFetch
|
||||
}),
|
||||
)
|
||||
expect(fetchCalls).toHaveLength(1)
|
||||
const vertexCalls = fetchCalls.filter((call) => call.input === "https://vertex.example")
|
||||
expect(vertexCalls).toHaveLength(1)
|
||||
expect(googleAuthOptions).toEqual([{ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }])
|
||||
expect(fetchCalls[0].input).toBe("https://vertex.example")
|
||||
expect(new Headers(fetchCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token")
|
||||
expect(new Headers(fetchCalls[0].init?.headers).get("x-test")).toBe("1")
|
||||
expect(new Headers(vertexCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token")
|
||||
expect(new Headers(vertexCalls[0].init?.headers).get("x-test")).toBe("1")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -376,7 +368,8 @@ describe("GoogleVertexPlugin", () => {
|
|||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make(" gemini-2.5-pro "),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ describe("GooglePlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")),
|
||||
api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" },
|
||||
modelID: ModelV2.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google",
|
||||
}),
|
||||
package: "@ai-sdk/google",
|
||||
options: { name: "custom-google", apiKey: "test" },
|
||||
|
|
@ -45,7 +46,8 @@ describe("GooglePlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")),
|
||||
api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" },
|
||||
modelID: ModelV2.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: { name: "google" },
|
||||
|
|
@ -62,7 +64,8 @@ describe("GooglePlugin", () => {
|
|||
const sdkEvent = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" },
|
||||
modelID: ModelV2.ID.make("gemini-api"),
|
||||
package: "aisdk:@ai-sdk/google",
|
||||
}),
|
||||
package: "@ai-sdk/google",
|
||||
options: { name: "custom-google", apiKey: "test" },
|
||||
|
|
@ -72,9 +75,29 @@ describe("GooglePlugin", () => {
|
|||
sdk: sdkEvent.sdk,
|
||||
options: sdkEvent.options,
|
||||
})
|
||||
const language = result.language ?? result.sdk.languageModel(result.model.api.id)
|
||||
const language = result.language ?? result.sdk.languageModel(result.model.modelID ?? result.model.id)
|
||||
expect(language.modelId).toBe("gemini-api")
|
||||
expect(language.provider).toBe("custom-google")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("wraps AI SDK language models for the native runner", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
const resolved = yield* aisdk.model(
|
||||
ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
|
||||
modelID: ModelV2.ID.make("gemini-api"),
|
||||
package: "aisdk:@ai-sdk/google",
|
||||
settings: { apiKey: "test" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(String(resolved.id)).toBe("gemini-api")
|
||||
expect(String(resolved.provider)).toBe("custom-google")
|
||||
expect(resolved.route.id).toBe("ai-sdk:@ai-sdk/google")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ describe("GroqPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
|
||||
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||
modelID: ModelV2.ID.make("llama"),
|
||||
package: "aisdk:@ai-sdk/groq",
|
||||
}),
|
||||
package: "@ai-sdk/groq",
|
||||
options: { name: "groq" },
|
||||
|
|
@ -45,7 +46,8 @@ describe("GroqPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
|
||||
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||
modelID: ModelV2.ID.make("llama"),
|
||||
package: "aisdk:@ai-sdk/groq",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "groq" },
|
||||
|
|
@ -62,7 +64,8 @@ describe("GroqPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
|
||||
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||
modelID: ModelV2.ID.make("llama"),
|
||||
package: "aisdk:@ai-sdk/groq",
|
||||
}),
|
||||
package: "@ai-sdk/groq/compat",
|
||||
options: { name: "groq" },
|
||||
|
|
@ -79,7 +82,8 @@ describe("GroqPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")),
|
||||
api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" },
|
||||
modelID: ModelV2.ID.make("llama"),
|
||||
package: "aisdk:@ai-sdk/groq",
|
||||
}),
|
||||
package: "@ai-sdk/groq",
|
||||
options: { name: "custom-groq", apiKey: "test" },
|
||||
|
|
@ -93,7 +97,7 @@ describe("GroqPlugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the default languageModel(api.id) behavior", () =>
|
||||
it.effect("uses the default languageModel(modelID) behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
|
@ -104,16 +108,13 @@ describe("GroqPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("llama-api"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/groq",
|
||||
},
|
||||
modelID: ModelV2.ID.make("llama-api"),
|
||||
package: "aisdk:@ai-sdk/groq",
|
||||
}),
|
||||
sdk,
|
||||
options: { name: "groq", apiKey: "test" },
|
||||
})
|
||||
const language = result.language ?? sdk.languageModel(result.model.api.id)
|
||||
const language = result.language ?? sdk.languageModel(result.model.modelID ?? result.model.id)
|
||||
expect(language.modelId).toBe("llama-api")
|
||||
expect(language.provider).toBe("groq.chat")
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -22,27 +22,24 @@ describe("KiloPlugin", () => {
|
|||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to kilo", () =>
|
||||
it.effect("applies legacy referer headers only to Kilo endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.kilo.ai/api/gateway",
|
||||
}
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" }
|
||||
provider.headers = { Existing: "value" }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -51,49 +48,41 @@ describe("KiloPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.kilo.ai/api/gateway",
|
||||
}
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty(
|
||||
"http-referer",
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("x-title")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("X-Source")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).not.toHaveProperty("http-referer")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).not.toHaveProperty("x-title")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).not.toHaveProperty("X-Source")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
|
||||
it.effect("uses endpoint package matching instead of a provider ID guard", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.kilo.ai/api/gateway",
|
||||
}
|
||||
provider.package = ProviderV2.aisdk("kilo")
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "kilo" }
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -34,23 +34,20 @@ describe("LLMGatewayPlugin", () => {
|
|||
})
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.llmgateway.io/v1",
|
||||
}
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
|
||||
provider.headers = { Existing: "value" }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-Source": "opencode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -64,17 +61,14 @@ describe("LLMGatewayPlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
|
||||
provider.disabled = true
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.llmgateway.io/v1",
|
||||
}
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ describe("MistralPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
|
||||
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("mistral-large"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/mistral",
|
||||
options: { name: "mistral" },
|
||||
|
|
@ -45,7 +46,8 @@ describe("MistralPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
|
||||
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("mistral-large"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "mistral" },
|
||||
|
|
@ -68,7 +70,8 @@ describe("MistralPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
|
||||
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("mistral-large"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/mistral",
|
||||
options: { name: "mistral" },
|
||||
|
|
@ -92,7 +95,8 @@ describe("MistralPlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("mistral-large"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/mistral",
|
||||
options: { name: "custom-mistral" },
|
||||
|
|
@ -101,7 +105,7 @@ describe("MistralPlugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("leaves Mistral language selection on the default sdk.languageModel(api.id) path", () =>
|
||||
it.effect("leaves Mistral language selection on the default sdk.languageModel(modelID) path", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
|
@ -116,12 +120,13 @@ describe("MistralPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("mistral-large"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk,
|
||||
options: {},
|
||||
})
|
||||
const language = result.language ?? sdk.languageModel(result.model.api.id)
|
||||
const language = result.language ?? sdk.languageModel(result.model.modelID ?? result.model.id)
|
||||
expect(calls).toEqual(["languageModel:mistral-large"])
|
||||
expect(language).toBeDefined()
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -27,23 +27,20 @@ describe("NvidiaPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" }
|
||||
provider.headers = { Existing: "value" }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -52,16 +49,13 @@ describe("NvidiaPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
|
|
@ -74,21 +68,14 @@ describe("NvidiaPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
provider.request = {
|
||||
settings: {},
|
||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||
body: { baseURL: "https://integrate.api.nvidia.com/v1" },
|
||||
}
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" }
|
||||
provider.headers = { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ describe("OpenAICompatiblePlugin", () => {
|
|||
const defaulted = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "custom" },
|
||||
|
|
@ -35,7 +36,8 @@ describe("OpenAICompatiblePlugin", () => {
|
|||
const disabled = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "custom", includeUsage: false },
|
||||
|
|
@ -53,7 +55,8 @@ describe("OpenAICompatiblePlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "file:///tmp/@ai-sdk/openai-compatible-provider.js",
|
||||
options: { name: "custom" },
|
||||
|
|
@ -76,7 +79,8 @@ describe("OpenAICompatiblePlugin", () => {
|
|||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "custom-provider", baseURL: "https://example.com/v1" },
|
||||
|
|
@ -96,7 +100,8 @@ describe("OpenAICompatiblePlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-workers-ai" },
|
||||
|
|
|
|||
|
|
@ -82,7 +82,8 @@ describe("OpenAIPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/openai",
|
||||
options: { name: "custom-openai", apiKey: "test" },
|
||||
|
|
@ -99,7 +100,8 @@ describe("OpenAIPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "openai" },
|
||||
|
|
@ -117,7 +119,8 @@ describe("OpenAIPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -136,7 +139,8 @@ describe("OpenAIPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -152,10 +156,10 @@ describe("OpenAIPlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const item = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai" },
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai"),
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
|
|
@ -175,10 +179,10 @@ describe("OpenAIPlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const item = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai" },
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai"),
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
|
||||
model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }]
|
||||
|
|
@ -220,10 +224,10 @@ describe("OpenAIPlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const item = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai" },
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai"),
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
||||
|
|
@ -248,10 +252,10 @@ describe("OpenAIPlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const item = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -172,13 +172,10 @@ describe("OpencodePlugin", () => {
|
|||
expect(provider).toMatchObject({
|
||||
name: "Remote",
|
||||
integrationID: "opencode",
|
||||
api: {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: `${server.url.origin}/v1`,
|
||||
},
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
|
||||
headers: { "x-org-id": "org" },
|
||||
})
|
||||
expect(provider.request).toEqual({ settings: {}, headers: { "x-org-id": "org" }, body: { custom: "value" } })
|
||||
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
|
||||
|
||||
const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))
|
||||
|
|
@ -188,8 +185,10 @@ describe("OpencodePlugin", () => {
|
|||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }],
|
||||
limit: { context: 1000, output: 100 },
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 },
|
||||
headers: { "x-org-id": "org" },
|
||||
})
|
||||
expect(model.request.body).toEqual({ custom: "value", temperature: 0.5 })
|
||||
expect(model.variants).toEqual([
|
||||
{
|
||||
id: ModelV2.VariantID.make("custom"),
|
||||
|
|
@ -199,9 +198,8 @@ describe("OpencodePlugin", () => {
|
|||
},
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: {},
|
||||
settings: { temperature: 0.2 },
|
||||
headers: {},
|
||||
body: { temperature: 0.2 },
|
||||
},
|
||||
])
|
||||
expect(
|
||||
|
|
@ -221,11 +219,12 @@ describe("OpencodePlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const provider = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("paid"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
|
|
@ -234,7 +233,7 @@ describe("OpencodePlugin", () => {
|
|||
})
|
||||
})
|
||||
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)).settings?.apiKey).toBe("public")
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
|
||||
}),
|
||||
),
|
||||
|
|
@ -247,11 +246,12 @@ describe("OpencodePlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const provider = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
|
||||
api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("free"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(0),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
|
|
@ -260,7 +260,7 @@ describe("OpencodePlugin", () => {
|
|||
})
|
||||
})
|
||||
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)).settings?.apiKey).toBe("public")
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -273,11 +273,12 @@ describe("OpencodePlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const provider = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
|
||||
api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("output-only"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(0, 1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
|
|
@ -286,7 +287,7 @@ describe("OpencodePlugin", () => {
|
|||
})
|
||||
})
|
||||
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)).settings?.apiKey).toBe("public")
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
|
||||
true,
|
||||
)
|
||||
|
|
@ -301,11 +302,12 @@ describe("OpencodePlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const provider = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("paid"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
|
|
@ -314,7 +316,7 @@ describe("OpencodePlugin", () => {
|
|||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBeUndefined()
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -334,11 +336,12 @@ describe("OpencodePlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const provider = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("paid"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
|
|
@ -347,7 +350,7 @@ describe("OpencodePlugin", () => {
|
|||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBeUndefined()
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -360,27 +363,25 @@ describe("OpencodePlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const provider = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
request: {
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: { apiKey: "configured" },
|
||||
},
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
settings: { apiKey: "configured" },
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("paid"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, (draft) => {
|
||||
draft.request = { settings: {}, headers: {}, body: { apiKey: "configured" } }
|
||||
draft.package = provider.package
|
||||
draft.settings = { apiKey: "configured" }
|
||||
})
|
||||
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)).settings?.apiKey).toBe("configured")
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -393,11 +394,12 @@ describe("OpencodePlugin", () => {
|
|||
yield* catalog.transform((catalog) => {
|
||||
const provider = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("paid"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
|
|
@ -406,7 +408,7 @@ describe("OpencodePlugin", () => {
|
|||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).settings?.apiKey).toBeUndefined()
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -30,19 +30,19 @@ describe("OpenRouterPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
provider.package = ProviderV2.aisdk("@openrouter/ai-sdk-provider")
|
||||
provider.headers = { Existing: "value" }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -55,7 +55,8 @@ describe("OpenRouterPlugin", () => {
|
|||
const ignored = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "openrouter" },
|
||||
|
|
@ -65,7 +66,8 @@ describe("OpenRouterPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@openrouter/ai-sdk-provider",
|
||||
options: { name: "custom" },
|
||||
|
|
@ -79,7 +81,7 @@ describe("OpenRouterPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
|
||||
provider.package = ProviderV2.aisdk("@openrouter/ai-sdk-provider")
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||
catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {})
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ describe("PerplexityPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
|
||||
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("sonar"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/perplexity",
|
||||
options: { name: "perplexity" },
|
||||
|
|
@ -58,7 +59,8 @@ describe("PerplexityPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
|
||||
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("sonar"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/perplexity-compatible",
|
||||
options: { name: "perplexity" },
|
||||
|
|
@ -75,7 +77,8 @@ describe("PerplexityPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
|
||||
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("sonar"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/perplexity",
|
||||
options: { name: "perplexity" },
|
||||
|
|
@ -92,7 +95,8 @@ describe("PerplexityPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")),
|
||||
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("sonar"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/perplexity",
|
||||
options: { name: "custom-perplexity" },
|
||||
|
|
@ -110,7 +114,8 @@ describe("PerplexityPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("sonar"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
|||
function model(providerID: string) {
|
||||
return ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")),
|
||||
api: { id: ModelV2.ID.make("sap-model"), type: "aisdk", package: fixtureProvider },
|
||||
modelID: ModelV2.ID.make("sap-model"),
|
||||
package: ProviderV2.aisdk(fixtureProvider),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ describe("SnowflakeCortexPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")),
|
||||
api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("gpt-4"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai",
|
||||
options: { name: "openai" },
|
||||
|
|
@ -77,7 +78,8 @@ describe("SnowflakeCortexPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||
|
|
@ -96,7 +98,8 @@ describe("SnowflakeCortexPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
|
|
@ -119,7 +122,8 @@ describe("SnowflakeCortexPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||
|
|
@ -138,7 +142,8 @@ describe("SnowflakeCortexPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
|
|
@ -161,7 +166,8 @@ describe("SnowflakeCortexPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ describe("TogetherAIPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/togetherai",
|
||||
options: { name: "togetherai" },
|
||||
|
|
@ -59,7 +60,8 @@ describe("TogetherAIPlugin", () => {
|
|||
const ignored = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "file:///tmp/@ai-sdk/togetherai-provider.js",
|
||||
options: { name: "togetherai" },
|
||||
|
|
@ -69,7 +71,8 @@ describe("TogetherAIPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/togetherai",
|
||||
options: { name: "togetherai" },
|
||||
|
|
@ -87,7 +90,8 @@ describe("TogetherAIPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/togetherai",
|
||||
options: { name: "custom-togetherai" },
|
||||
|
|
@ -110,11 +114,8 @@ describe("TogetherAIPlugin", () => {
|
|||
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",
|
||||
},
|
||||
modelID: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
|
|
@ -122,7 +123,9 @@ describe("TogetherAIPlugin", () => {
|
|||
|
||||
expect(result.language).toBeUndefined()
|
||||
expect(calls).toEqual([])
|
||||
expect(result.language ?? fakeSelectorSdk(calls).languageModel(result.model.api.id)).toBeDefined()
|
||||
expect(
|
||||
result.language ?? fakeSelectorSdk(calls).languageModel(result.model.modelID ?? result.model.id),
|
||||
).toBeDefined()
|
||||
expect(calls).toEqual(["languageModel:meta-llama/Llama-3.3-70B-Instruct-Turbo"])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ describe("VenicePlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "venice-ai-sdk-provider",
|
||||
options: { name: "venice" },
|
||||
|
|
@ -58,7 +59,8 @@ describe("VenicePlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "venice-ai-sdk-provider",
|
||||
options: { name: "custom-venice", apiKey: "test" },
|
||||
|
|
@ -76,7 +78,8 @@ describe("VenicePlugin", () => {
|
|||
const similar = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "file:///tmp/venice-ai-sdk-provider.js",
|
||||
options: { name: "venice" },
|
||||
|
|
@ -84,7 +87,8 @@ describe("VenicePlugin", () => {
|
|||
const other = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
|
||||
api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "venice" },
|
||||
|
|
@ -103,7 +107,8 @@ describe("VenicePlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" },
|
||||
modelID: ModelV2.ID.make("alias"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@ describe("VercelPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/vercel" }
|
||||
provider.request.headers.Existing = "1"
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/vercel")
|
||||
provider.headers = { ...provider.headers, Existing: "1" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.headers).toEqual({
|
||||
Existing: "1",
|
||||
"http-referer": "https://opencode.ai/",
|
||||
"x-title": "opencode",
|
||||
|
|
@ -43,14 +43,12 @@ describe("VercelPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/vercel" }
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/vercel")
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty(
|
||||
"HTTP-Referer",
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty("X-Title")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.headers).not.toHaveProperty("HTTP-Referer")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.headers).not.toHaveProperty("X-Title")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -62,7 +60,8 @@ describe("VercelPlugin", () => {
|
|||
const event = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("v0-1.0-md"),
|
||||
package: "aisdk:@ai-sdk/vercel",
|
||||
}),
|
||||
package: "@ai-sdk/vercel",
|
||||
options: { name: "custom-vercel" },
|
||||
|
|
@ -77,7 +76,7 @@ describe("VercelPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {}))
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ describe("XAIPlugin", () => {
|
|||
const ignored = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("grok-4"),
|
||||
package: "aisdk:@ai-sdk/xai",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {},
|
||||
|
|
@ -51,7 +52,8 @@ describe("XAIPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("grok-4"),
|
||||
package: "aisdk:@ai-sdk/xai",
|
||||
}),
|
||||
package: "@ai-sdk/xai",
|
||||
options: {},
|
||||
|
|
@ -71,7 +73,8 @@ describe("XAIPlugin", () => {
|
|||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...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" },
|
||||
modelID: ModelV2.ID.make("grok-4"),
|
||||
package: "aisdk:@ai-sdk/xai",
|
||||
}),
|
||||
package: "@ai-sdk/xai",
|
||||
options: {},
|
||||
|
|
@ -81,7 +84,7 @@ describe("XAIPlugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("uses responses with the model api.id for xAI language models", () =>
|
||||
it.effect("uses responses with the model modelID for xAI language models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
|
@ -91,7 +94,8 @@ describe("XAIPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||
modelID: ModelV2.ID.make("grok-4"),
|
||||
package: "aisdk:@ai-sdk/xai",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
@ -112,7 +116,8 @@ describe("XAIPlugin", () => {
|
|||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")),
|
||||
api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" },
|
||||
modelID: ModelV2.ID.make("grok-4"),
|
||||
package: "aisdk:@ai-sdk/xai",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
|
|||
|
|
@ -32,17 +32,14 @@ describe("ZenmuxPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://zenmux.ai/api/v1",
|
||||
}
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux")))
|
||||
expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
|
||||
expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
|
||||
expect(result.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
|
||||
expect(Object.keys(required(result.headers)).sort()).toEqual(["HTTP-Referer", "X-Title"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -51,17 +48,14 @@ describe("ZenmuxPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://zenmux.ai/api/v1",
|
||||
}
|
||||
provider.request.headers.Existing = "value"
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" }
|
||||
provider.headers = { ...provider.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"))).headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
|
|
@ -74,17 +68,14 @@ describe("ZenmuxPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://zenmux.ai/api/v1",
|
||||
}
|
||||
provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" }
|
||||
provider.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).headers).toEqual({
|
||||
"HTTP-Referer": "https://example.com/",
|
||||
"X-Title": "custom-title",
|
||||
})
|
||||
|
|
@ -96,12 +87,12 @@ describe("ZenmuxPlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||
provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
|
||||
provider.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).headers).toEqual({
|
||||
"HTTP-Referer": "https://example.com/",
|
||||
"X-Title": "custom-title",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ describe("SkillPlugin.Plugin", () => {
|
|||
|
||||
expect(skills).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "customize-opencode",
|
||||
description: expect.stringContaining("opencode's own configuration"),
|
||||
name: "opencode",
|
||||
description: expect.stringContaining("any question about OpenCode itself"),
|
||||
}),
|
||||
)
|
||||
expect(skills).toContainEqual(
|
||||
|
|
|
|||
|
|
@ -24,14 +24,11 @@ describe("VariantPlugin", () => {
|
|||
const service = yield* Catalog.Service
|
||||
yield* service.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
})
|
||||
catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2"), (model) => {
|
||||
model.api = {
|
||||
id: ModelV2.ID.make("glm-5.2"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
}
|
||||
model.modelID = ModelV2.ID.make("glm-5.2")
|
||||
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
})
|
||||
})
|
||||
yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) }))
|
||||
|
|
@ -48,11 +45,8 @@ describe("VariantPlugin", () => {
|
|||
const service = yield* Catalog.Service
|
||||
yield* service.transform((catalog) => {
|
||||
catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2"), (model) => {
|
||||
model.api = {
|
||||
id: ModelV2.ID.make("glm-5.2"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
}
|
||||
model.modelID = ModelV2.ID.make("glm-5.2")
|
||||
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
model.variants = [{ id: ModelV2.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }]
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@ import { Hash } from "@opencode-ai/core/util/hash"
|
|||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(AppNodeBuilder.build(ProjectV2.node), AppNodeBuilder.build(Database.node)),
|
||||
)
|
||||
const it = testEffect(Layer.merge(AppNodeBuilder.build(ProjectV2.node), AppNodeBuilder.build(Database.node)))
|
||||
|
||||
describe("ProjectV2.list", () => {
|
||||
it.effect("returns complete projects ordered by recent update", () =>
|
||||
|
|
@ -262,8 +260,14 @@ describe("ProjectV2.resolve", () => {
|
|||
yield* Effect.promise(async () => {
|
||||
await $`hg init`.cwd(tmp.path).quiet()
|
||||
await Bun.write(path.join(tmp.path, "file.txt"), "one\n")
|
||||
await $`hg addremove -q`.cwd(tmp.path).env({ ...process.env, HGPLAIN: "1" }).quiet()
|
||||
await $`hg commit -q -m initial -u test`.cwd(tmp.path).env({ ...process.env, HGPLAIN: "1" }).quiet()
|
||||
await $`hg addremove -q`
|
||||
.cwd(tmp.path)
|
||||
.env({ ...process.env, HGPLAIN: "1" })
|
||||
.quiet()
|
||||
await $`hg commit -q -m initial -u test`
|
||||
.cwd(tmp.path)
|
||||
.env({ ...process.env, HGPLAIN: "1" })
|
||||
.quiet()
|
||||
await fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true })
|
||||
})
|
||||
const project = yield* ProjectV2.Service
|
||||
|
|
|
|||
|
|
@ -4,17 +4,17 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context/index"
|
||||
import { Instructions } from "@opencode-ai/core/instructions/index"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const guidanceLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
|
||||
AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]])
|
||||
|
||||
describe("ReferenceGuidance", () => {
|
||||
it.effect("lists available references in the system context", () =>
|
||||
it.effect("lists available references in the instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
const generation = yield* Instructions.initialize(yield* guidance.load())
|
||||
|
||||
expect(generation.text).toContain("<available_references>")
|
||||
expect(generation.text).toContain("<name>docs</name>")
|
||||
|
|
@ -46,7 +46,7 @@ describe("ReferenceGuidance", () => {
|
|||
it.effect("omits guidance when no references are available", () =>
|
||||
Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
const generation = yield* Instructions.initialize(yield* guidance.load())
|
||||
expect(generation.text).toBe("")
|
||||
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
|
||||
)
|
||||
|
|
@ -54,7 +54,7 @@ describe("ReferenceGuidance", () => {
|
|||
it.effect("omits references without descriptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
const generation = yield* Instructions.initialize(yield* guidance.load())
|
||||
expect(generation.text).toBe("")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
|
|
@ -85,10 +85,10 @@ describe("ReferenceGuidance", () => {
|
|||
let references = [reference("docs", "Use for product documentation")]
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* guidance.load())
|
||||
const initialized = yield* Instructions.initialize(yield* guidance.load())
|
||||
|
||||
references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")]
|
||||
const added = yield* SystemContext.reconcile(yield* guidance.load(), initialized.applied)
|
||||
const added = yield* Instructions.reconcile(yield* guidance.load(), initialized.applied)
|
||||
expect(added).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
|
|
@ -103,7 +103,7 @@ describe("ReferenceGuidance", () => {
|
|||
|
||||
references = [reference("examples", "Use for examples")]
|
||||
expect(
|
||||
yield* SystemContext.reconcile(yield* guidance.load(), added._tag === "Updated" ? added.applied : {}),
|
||||
yield* Instructions.reconcile(yield* guidance.load(), added._tag === "Updated" ? added.applied : {}),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: "The following project references are no longer available and must not be used: docs.",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
|
|
@ -75,7 +75,7 @@ const it = testEffect(
|
|||
)
|
||||
|
||||
describe("SessionV2.compact", () => {
|
||||
it.effect("manually compacts the active session context", () =>
|
||||
it.effect("durably admits and coalesces manual compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -95,13 +95,22 @@ describe("SessionV2.compact", () => {
|
|||
inputID: messageID,
|
||||
})
|
||||
|
||||
yield* session.compact({ sessionID: created.id })
|
||||
expect(yield* session.compact({ id: messageID, sessionID: created.id }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.CompactionConflictError",
|
||||
inputID: messageID,
|
||||
})
|
||||
const first = yield* session.compact({ sessionID: created.id })
|
||||
const second = yield* session.compact({ sessionID: created.id })
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Please compact this session history.")
|
||||
expect(yield* session.context(created.id)).toMatchObject([
|
||||
{ type: "compaction", reason: "manual", summary: "manual session summary", recent: "" },
|
||||
])
|
||||
expect(second.id).toBe(first.id)
|
||||
expect(requests).toHaveLength(0)
|
||||
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "queued",
|
||||
reason: "manual",
|
||||
summary: "",
|
||||
recent: "",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -68,11 +68,30 @@ test("compaction describes tool media without embedding base64", () => {
|
|||
expect(serialized).not.toContain(base64)
|
||||
})
|
||||
|
||||
test("compaction prompt requires the checkpoint headings in order", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
|
||||
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
|
||||
"## Objective",
|
||||
"## Important Details",
|
||||
"## Work State",
|
||||
"## Next Move",
|
||||
])
|
||||
expect(prompt).toContain("one or two brief sentences")
|
||||
expect(prompt).toContain("constraints/preferences, decisions and why")
|
||||
expect(prompt).toContain("Completed:")
|
||||
expect(prompt).toContain("Active:")
|
||||
expect(prompt).toContain("Blocked:")
|
||||
expect(prompt).toContain("immediate concrete action")
|
||||
expect(prompt).toContain("next action if known")
|
||||
expect(prompt).toContain("Keep every section, even when empty.")
|
||||
})
|
||||
|
||||
it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const events = yield* EventV2.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionID = SessionV2.ID.make("ses_manual_compaction")
|
||||
const userMessage = {
|
||||
|
|
@ -108,7 +127,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
|||
),
|
||||
)
|
||||
|
||||
const delta = yield* events
|
||||
.subscribe(SessionEvent.Compaction.Delta)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true)
|
||||
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
|
|
@ -192,7 +192,7 @@ describe("SessionV2.create", () => {
|
|||
const parent = yield* session.create({ location, title: "Parent" })
|
||||
const admitted = yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
prompt: Prompt.make({ text: "First" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "First" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
|
|
@ -206,7 +206,8 @@ describe("SessionV2.create", () => {
|
|||
const forkContext = yield* session.context(forked.id)
|
||||
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
|
||||
|
||||
expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" })
|
||||
expect(forked).toMatchObject({ title: "Parent (fork #1)", fork: { sessionID: parent.id } })
|
||||
expect(forked.parentID).toBeUndefined()
|
||||
expect(forkContext).toMatchObject([
|
||||
{ type: "user", text: "First" },
|
||||
{ type: "synthetic", text: "parent note", sessionID: forked.id },
|
||||
|
|
@ -224,9 +225,9 @@ describe("SessionV2.create", () => {
|
|||
promotedSeq: 2,
|
||||
})
|
||||
|
||||
yield* session.prompt({ sessionID: parent.id, prompt: Prompt.make({ text: "Parent changed" }), resume: false })
|
||||
yield* session.prompt({ sessionID: parent.id, prompt: PromptInput.Prompt.make({ text: "Parent changed" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
yield* session.prompt({ sessionID: forked.id, prompt: Prompt.make({ text: "Child continues" }), resume: false })
|
||||
yield* session.prompt({ sessionID: forked.id, prompt: PromptInput.Prompt.make({ text: "Child continues" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, forked.id)
|
||||
|
||||
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||
|
|
@ -249,13 +250,13 @@ describe("SessionV2.create", () => {
|
|||
const parent = yield* session.create({ location })
|
||||
const first = yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
prompt: Prompt.make({ text: "First" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "First" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
const second = yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
prompt: Prompt.make({ text: "Second" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Second" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
|
|
@ -264,6 +265,7 @@ describe("SessionV2.create", () => {
|
|||
|
||||
const context = yield* session.context(forked.id)
|
||||
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
|
||||
expect(forked.fork).toEqual({ sessionID: parent.id, messageID: second.id })
|
||||
expect(context).toMatchObject([{ text: "First" }])
|
||||
expect(context[0]?.id).not.toBe(first.id)
|
||||
expect(history[0]).toMatchObject({ data: { from: second.id } })
|
||||
|
|
@ -373,7 +375,7 @@ describe("SessionV2.create", () => {
|
|||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false })
|
||||
yield* session.prompt({ sessionID: created.id, prompt: PromptInput.Prompt.make({ text: "Hello" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, created.id)
|
||||
|
||||
expect(
|
||||
|
|
@ -393,7 +395,7 @@ describe("SessionV2.create", () => {
|
|||
const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location })
|
||||
const admitted = yield* session.prompt({
|
||||
sessionID: created.id,
|
||||
prompt: Prompt.make({ text: "Replay lifecycle" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Replay lifecycle" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id)
|
||||
|
|
|
|||
88
packages/core/test/session-error.test.ts
Normal file
88
packages/core/test/session-error.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
NoRouteReason,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
ToolFailure,
|
||||
} from "@opencode-ai/llm"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
|
||||
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
|
||||
|
||||
describe("toSessionError", () => {
|
||||
test("maps every LLM reason to the open wire type", () => {
|
||||
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
|
||||
type: "provider.rate-limit",
|
||||
message: "rate",
|
||||
})
|
||||
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
|
||||
"provider.auth",
|
||||
)
|
||||
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
|
||||
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
|
||||
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
|
||||
"provider.internal",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
|
||||
"provider.invalid-output",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
|
||||
expect(
|
||||
toSessionError(
|
||||
llm(
|
||||
new NoRouteReason({
|
||||
route: "route",
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
),
|
||||
).type,
|
||||
).toBe("provider.no-route")
|
||||
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
|
||||
})
|
||||
|
||||
test("preserves the permission rejection type without exposing internal fields", () => {
|
||||
const blocked = new PermissionV2.BlockedError({ rules: [], permission: "external_directory", resources: [] })
|
||||
expect(toSessionError(blocked)).toEqual({
|
||||
type: "permission.rejected",
|
||||
message: "Permission denied: external_directory",
|
||||
})
|
||||
expect(toSessionError(new ToolFailure({ message: blocked.message, error: blocked }))).toEqual({
|
||||
type: "permission.rejected",
|
||||
message: "Permission denied: external_directory",
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
||||
llm(new TransportReason({ message: "transport" })),
|
||||
]
|
||||
const ineligible = [
|
||||
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
||||
llm(new QuotaExceededReason({ message: "quota" })),
|
||||
llm(new ContentPolicyReason({ message: "blocked" })),
|
||||
llm(new InvalidProviderOutputReason({ message: "output" })),
|
||||
llm(new InvalidRequestReason({ message: "request" })),
|
||||
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
|
||||
llm(new UnknownProviderReason({ message: "unknown" })),
|
||||
]
|
||||
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||
})
|
||||
})
|
||||
36
packages/core/test/session-execution-local.test.ts
Normal file
36
packages/core/test/session-execution-local.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { LLMError, TransportReason } from "@opencode-ai/llm"
|
||||
import { terminal } from "@opencode-ai/core/session/execution/local"
|
||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Effect, Exit } from "effect"
|
||||
|
||||
describe("SessionExecutionLocal lifecycle", () => {
|
||||
test("classifies success and typed failure terminals", () => {
|
||||
expect(terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
|
||||
expect(
|
||||
terminal(
|
||||
Exit.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Disconnected" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
|
||||
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
|
||||
expect(terminal(Exit.fail(storage))).toEqual({
|
||||
type: "failed",
|
||||
error: { type: "unknown", message: storage.message },
|
||||
})
|
||||
})
|
||||
|
||||
test("defaults owner-scope interruption to shutdown and preserves explicit reasons", () => {
|
||||
const interrupted = Effect.runSyncExit(Effect.interrupt)
|
||||
expect(terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" })
|
||||
expect(terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" })
|
||||
expect(terminal(interrupted, "superseded")).toEqual({ type: "interrupted", reason: "superseded" })
|
||||
expect(terminal(Exit.fail(new UserInterruptedError()))).toEqual({ type: "interrupted", reason: "user" })
|
||||
})
|
||||
})
|
||||
|
|
@ -2,8 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { DateTime, Effect, Layer } from "effect"
|
||||
import { Message, Model } from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { Message } from "@opencode-ai/llm"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
|
@ -300,7 +299,7 @@ describe("SessionInstructions", () => {
|
|||
|
||||
test("toLLMMessages does not forward synthetic metadata to the provider", () => {
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
|
||||
const synthetic = SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.make("msg_synthetic"),
|
||||
type: "synthetic",
|
||||
|
|
|
|||
|
|
@ -130,33 +130,3 @@ describe("SessionV2.log", () => {
|
|||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionV2 watermarks", () => {
|
||||
it.effect("list pairs each session snapshot with its durable log watermark", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const first = yield* session.create({ location })
|
||||
const second = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: first.id, title: "session.renamed" })
|
||||
|
||||
const page = yield* session.list()
|
||||
const sequences = yield* events.sequences([first.id, second.id])
|
||||
|
||||
expect(page.data.map((info) => info.id).toSorted()).toEqual([first.id, second.id].toSorted())
|
||||
expect(page.watermarks).toEqual(sequences)
|
||||
expect(page.watermarks.get(first.id)).toBeGreaterThan(page.watermarks.get(second.id)!)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("watermarks omits sessions without durable events", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
const watermarks = yield* session.watermarks([created.id, SessionV2.ID.create()])
|
||||
|
||||
expect(Array.from(watermarks.keys())).toEqual([created.id])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
|||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import {
|
||||
SessionContextCheckpointTable,
|
||||
InstructionCheckpointTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
|
|
@ -70,12 +70,17 @@ describe("SessionProjector", () => {
|
|||
})
|
||||
.run()
|
||||
const boundary = SessionMessage.ID.make("msg_boundary")
|
||||
const earlier = SessionMessage.ID.make("msg_earlier")
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)])
|
||||
.values([
|
||||
assistantRow(earlier, 0),
|
||||
assistantRow(boundary, 1),
|
||||
assistantRow(SessionMessage.ID.make("msg_later"), 2),
|
||||
])
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionContextCheckpointTable)
|
||||
.insert(InstructionCheckpointTable)
|
||||
.values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 })
|
||||
.run()
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -96,13 +101,13 @@ describe("SessionProjector", () => {
|
|||
})
|
||||
yield* events.publish(SessionEvent.RevertEvent.Committed, {
|
||||
sessionID,
|
||||
messageID: boundary,
|
||||
to: boundary,
|
||||
})
|
||||
expect(
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||
).toEqual([boundary])
|
||||
).toEqual([earlier])
|
||||
// A committed revert resets the context checkpoint so the next turn re-initializes.
|
||||
expect(yield* db.select().from(SessionContextCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
|
||||
expect(yield* db.select().from(InstructionCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -432,6 +437,73 @@ describe("SessionProjector", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const first = SessionMessage.ID.make("msg_retry_first")
|
||||
const second = SessionMessage.ID.make("msg_retry_second")
|
||||
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model })
|
||||
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: first,
|
||||
attempt: 2,
|
||||
at: 2_000,
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
})
|
||||
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type })
|
||||
const firstRow = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, first))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const projected = firstRow ?? (yield* Effect.die(new Error("Missing retry projection")))
|
||||
expect(decode(projected)).toMatchObject({
|
||||
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
|
||||
})
|
||||
|
||||
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model })
|
||||
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: second,
|
||||
attempt: 3,
|
||||
at: 6_000,
|
||||
error: { type: "provider.internal", message: "Unavailable" },
|
||||
})
|
||||
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
|
||||
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(decode(rows[0])).not.toHaveProperty("retry")
|
||||
expect(decode(rows[1])).not.toHaveProperty("retry")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("updates only the newest incomplete assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
|
@ -525,7 +597,7 @@ describe("SessionProjector", () => {
|
|||
yield* service.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
textID: "text-stale",
|
||||
ordinal: 0,
|
||||
})
|
||||
|
||||
const rows = yield* db
|
||||
|
|
@ -544,7 +616,7 @@ describe("SessionProjector", () => {
|
|||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })],
|
||||
content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
|
||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||
}),
|
||||
SessionMessage.Assistant.make({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { mkdtemp, rm } from "fs/promises"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -13,7 +17,7 @@ import { Project } from "@opencode-ai/core/project"
|
|||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
|
|
@ -169,7 +173,7 @@ describe("SessionV2.prompt", () => {
|
|||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
|
|
@ -193,7 +197,7 @@ describe("SessionV2.prompt", () => {
|
|||
|
||||
const boundary = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "boundary" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "boundary" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
|
|
@ -205,14 +209,15 @@ describe("SessionV2.prompt", () => {
|
|||
})
|
||||
expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id)
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "after revert" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "after revert" }), resume: false })
|
||||
|
||||
expect((yield* session.get(sessionID)).revert).toBeUndefined()
|
||||
expect(
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all().pipe(Effect.orDie)).map(
|
||||
(row) => row.id,
|
||||
),
|
||||
).not.toContain(stale)
|
||||
).not.toContainAnyValues([boundary.id, stale])
|
||||
expect(yield* SessionInput.find(db, boundary.id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -220,20 +225,166 @@ describe("SessionV2.prompt", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const uri =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: {
|
||||
text: "Inspect this image",
|
||||
files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }],
|
||||
files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }],
|
||||
},
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.files).toEqual([
|
||||
{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" },
|
||||
{
|
||||
data: uri.slice(uri.indexOf(",") + 1),
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 8, end: 17, text: "[Image 1]" },
|
||||
},
|
||||
])
|
||||
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
|
||||
const stored = yield* admitted(message.id)
|
||||
expect(stored?.type).toBe("prompt")
|
||||
if (stored?.type === "prompt") expect(stored.prompt.files).toEqual(message.prompt.files)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("materializes selected source file content", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const directory = import.meta.dir
|
||||
const source = path.join(directory, "session-prompt.test.ts")
|
||||
const sourceUri = pathToFileURL(source)
|
||||
sourceUri.searchParams.set("start", "1")
|
||||
sourceUri.searchParams.set("end", "1")
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: {
|
||||
text: "Inspect this",
|
||||
files: [{ uri: sourceUri.href, name: "main.ts" }],
|
||||
},
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.files).toHaveLength(1)
|
||||
expect(message.prompt.files?.[0]).toMatchObject({
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: sourceUri.href },
|
||||
name: "main.ts",
|
||||
})
|
||||
expect(
|
||||
Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64")
|
||||
.toString("utf8")
|
||||
.replace(/\r$/, ""),
|
||||
).toBe('import { describe, expect } from "bun:test"')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("materializes directories as directory attachments", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const uri = pathToFileURL(import.meta.dir).href
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: { text: "Inspect this", files: [{ uri, name: "source" }] },
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.files).toHaveLength(1)
|
||||
expect(message.prompt.files?.[0]).toMatchObject({
|
||||
mime: "application/x-directory",
|
||||
source: { type: "uri", uri },
|
||||
name: "source",
|
||||
})
|
||||
expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
|
||||
"session-prompt.test.ts",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("materializes local image content before admission", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const directory = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => mkdtemp(path.join(tmpdir(), "opencode-session-prompt-"))),
|
||||
(directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
|
||||
)
|
||||
const source = path.join(directory, "image.png")
|
||||
const bytes = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
)
|
||||
yield* Effect.promise(() => Bun.write(source, bytes))
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: { text: "Inspect this image", files: [{ uri: pathToFileURL(source).href }] },
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.files).toEqual([
|
||||
{
|
||||
data: bytes.toString("base64"),
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: pathToFileURL(source).href },
|
||||
name: "image.png",
|
||||
},
|
||||
])
|
||||
const stored = yield* admitted(message.id)
|
||||
expect(stored?.type === "prompt" ? stored.prompt.files : undefined).toEqual(message.prompt.files)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sniffs data URL content instead of trusting its declared MIME", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const uri = `data:video/mp2t;base64,${Buffer.from("export const value = 1\n").toString("base64")}`
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: { text: "Inspect this", files: [{ uri, name: "main.ts" }] },
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.files).toEqual([
|
||||
{
|
||||
data: Buffer.from("export const value = 1\n").toString("base64"),
|
||||
mime: "text/plain",
|
||||
source: { type: "inline" },
|
||||
name: "main.ts",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed base64 data URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const uri = "data:image/png;base64,not-base64"
|
||||
|
||||
const error = yield* session
|
||||
.prompt({
|
||||
sessionID,
|
||||
prompt: { text: "Inspect this", files: [{ uri, name: "image.png" }] },
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "Session.AttachmentError",
|
||||
uri,
|
||||
message: "Invalid attachment data URL",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -250,8 +401,8 @@ describe("SessionV2.prompt", () => {
|
|||
const fiber = yield* publicEvents({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
|
|
@ -275,7 +426,7 @@ describe("SessionV2.prompt", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
|
|
@ -294,7 +445,7 @@ describe("SessionV2.prompt", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const input = { sessionID, prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false }
|
||||
const input = { sessionID, prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), resume: false }
|
||||
|
||||
const first = yield* session.prompt(input)
|
||||
const second = yield* session.prompt(input)
|
||||
|
|
@ -312,7 +463,7 @@ describe("SessionV2.prompt", () => {
|
|||
const input = {
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
}
|
||||
|
||||
|
|
@ -332,7 +483,7 @@ describe("SessionV2.prompt", () => {
|
|||
const input = {
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: Prompt.make({ text: "Recover committed prompt" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Recover committed prompt" }),
|
||||
resume: false,
|
||||
}
|
||||
const first = yield* session.prompt(input)
|
||||
|
|
@ -353,13 +504,13 @@ describe("SessionV2.prompt", () => {
|
|||
yield* session.prompt({
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
})
|
||||
const failure = yield* session
|
||||
.prompt({
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: Prompt.make({ text: "Delete the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Delete the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
|
@ -378,14 +529,14 @@ describe("SessionV2.prompt", () => {
|
|||
yield* session.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
const failure = yield* session
|
||||
.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
|
@ -402,7 +553,7 @@ describe("SessionV2.prompt", () => {
|
|||
const input = {
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
}
|
||||
|
||||
|
|
@ -421,7 +572,12 @@ describe("SessionV2.prompt", () => {
|
|||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false })
|
||||
yield* session.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: PromptInput.Prompt.make({ text: "Promote once" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* Effect.all(
|
||||
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
|
||||
|
|
@ -446,7 +602,7 @@ describe("SessionV2.prompt", () => {
|
|||
yield* session.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Replay pending" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Replay pending" }),
|
||||
resume: false,
|
||||
})
|
||||
const recorded = yield* db
|
||||
|
|
@ -499,7 +655,7 @@ describe("SessionV2.prompt", () => {
|
|||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const prompt = Prompt.make({ text: "Fix the failing tests" })
|
||||
const prompt = PromptInput.Prompt.make({ text: "Fix the failing tests" })
|
||||
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
const failure = yield* session
|
||||
|
|
@ -533,7 +689,12 @@ describe("SessionV2.prompt", () => {
|
|||
.pipe(Effect.orDie)
|
||||
|
||||
const failure = yield* session
|
||||
.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false })
|
||||
.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }),
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
|
||||
|
|
@ -548,7 +709,7 @@ describe("SessionV2.prompt", () => {
|
|||
executionCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run by default" }) })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run by default" }) })
|
||||
|
||||
expect(executionCalls).toEqual([])
|
||||
expect(wakeCalls).toEqual([sessionID])
|
||||
|
|
@ -564,7 +725,7 @@ describe("SessionV2.prompt", () => {
|
|||
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Run explicitly" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Run explicitly" }),
|
||||
resume: true,
|
||||
})
|
||||
|
||||
|
|
@ -580,7 +741,7 @@ describe("SessionV2.prompt", () => {
|
|||
executionCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Do not run" }), resume: false })
|
||||
|
||||
expect(executionCalls).toEqual([])
|
||||
expect(wakeCalls).toEqual([])
|
||||
|
|
|
|||
62
packages/core/test/session-remove.test.ts
Normal file
62
packages/core/test/session-remove.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
|
||||
[
|
||||
[ProjectV2.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
describe("SessionV2.remove", () => {
|
||||
it.effect("removes a session and its children", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const parent = yield* session.create({ location })
|
||||
const child = yield* session.create({ parentID: parent.id })
|
||||
|
||||
yield* session.remove(parent.id)
|
||||
|
||||
expect((yield* session.list()).data).toEqual([])
|
||||
expect(yield* Effect.result(session.get(parent.id))).toMatchObject({ _tag: "Failure" })
|
||||
expect(yield* Effect.result(session.get(child.id))).toMatchObject({ _tag: "Failure" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails when the session does not exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const sessionID = SessionV2.ID.make("ses_missing")
|
||||
|
||||
expect(yield* Effect.result(session.remove(sessionID))).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { _tag: "Session.NotFoundError", sessionID },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -104,8 +104,10 @@ describe("SessionRunCoordinator", () => {
|
|||
Effect.gen(function* () {
|
||||
const failure = new Error("failed")
|
||||
const defect = new Error("defect")
|
||||
const settled: Exit.Exit<void, Error>[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)),
|
||||
settled: (_key, exit) => Effect.sync(() => void settled.push(exit)),
|
||||
})
|
||||
|
||||
const failed = yield* coordinator.run("failure").pipe(Effect.exit)
|
||||
|
|
@ -115,6 +117,25 @@ describe("SessionRunCoordinator", () => {
|
|||
const died = yield* coordinator.run("defect").pipe(Effect.exit)
|
||||
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
expect(settled).toHaveLength(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("preserves settlement hook defects while releasing ownership", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const defect = new Error("terminal publication failed")
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () => Effect.void,
|
||||
settled: () => Effect.die(defect),
|
||||
})
|
||||
|
||||
const exit = yield* coordinator.run("session").pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(defect)
|
||||
expect(yield* coordinator.active).toEqual(new Set())
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -209,8 +230,41 @@ describe("SessionRunCoordinator", () => {
|
|||
it.effect("does nothing when interrupted while idle", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void })
|
||||
yield* coordinator.interrupt("session")
|
||||
const reasons: Array<string | undefined> = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||
drain: () => Effect.void,
|
||||
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
|
||||
})
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
yield* coordinator.run("session")
|
||||
expect(reasons).toEqual([undefined])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not attach a late interrupt reason after terminal settlement starts", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const settling = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const reasons: Array<string | undefined> = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||
drain: () => Effect.void,
|
||||
settled: (_key, _exit, reason) =>
|
||||
Deferred.succeed(settling, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.andThen(Effect.sync(() => void reasons.push(reason))),
|
||||
),
|
||||
})
|
||||
|
||||
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(settling)
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(run)
|
||||
yield* coordinator.run("session")
|
||||
|
||||
expect(reasons).toEqual([undefined, undefined])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -221,25 +275,28 @@ describe("SessionRunCoordinator", () => {
|
|||
const started = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
const reasons: Array<string | undefined> = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
Effect.andThen(Deferred.succeed(started, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||
),
|
||||
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
|
||||
})
|
||||
|
||||
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* coordinator.wake("session")
|
||||
yield* coordinator.interrupt("session")
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
yield* Deferred.await(interrupted)
|
||||
|
||||
const exit = yield* Fiber.await(resumed)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
expect(runs).toBe(1)
|
||||
expect(reasons).toEqual(["user"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -252,6 +309,7 @@ describe("SessionRunCoordinator", () => {
|
|||
const cleanupGate = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
let starts = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
|
|
@ -266,6 +324,7 @@ describe("SessionRunCoordinator", () => {
|
|||
: Deferred.succeed(secondStarted, undefined),
|
||||
),
|
||||
),
|
||||
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
|
|
@ -278,6 +337,7 @@ describe("SessionRunCoordinator", () => {
|
|||
yield* Deferred.await(secondStarted)
|
||||
|
||||
expect(runs).toBe(2)
|
||||
expect(starts).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -399,6 +459,7 @@ describe("SessionRunCoordinator", () => {
|
|||
const gate = yield* Deferred.make<void>()
|
||||
const idle = yield* Deferred.make<void>()
|
||||
let drains = 0
|
||||
let starts = 0
|
||||
const settled: Exit.Exit<void, never>[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never>({
|
||||
drain: () =>
|
||||
|
|
@ -410,6 +471,7 @@ describe("SessionRunCoordinator", () => {
|
|||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
|
||||
settled: (_key, exit) =>
|
||||
Effect.sync(() => void settled.push(exit)).pipe(
|
||||
Effect.andThen(Deferred.succeed(idle, undefined)),
|
||||
|
|
@ -424,6 +486,7 @@ describe("SessionRunCoordinator", () => {
|
|||
yield* Deferred.await(idle)
|
||||
|
||||
expect(drains).toBe(2)
|
||||
expect(starts).toBe(1)
|
||||
expect(settled).toHaveLength(1)
|
||||
expect(Exit.isSuccess(settled[0]!)).toBe(true)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, Model } from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { Message } from "@opencode-ai/llm"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt"
|
||||
import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
|
|
@ -12,7 +11,7 @@ import { DateTime } from "effect"
|
|||
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
||||
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
|
||||
|
||||
describe("toLLMMessages", () => {
|
||||
test("omits empty assistant turns", () => {
|
||||
|
|
@ -28,17 +27,14 @@ describe("toLLMMessages", () => {
|
|||
const messages = toLLMMessages(
|
||||
[
|
||||
assistant("empty", []),
|
||||
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]),
|
||||
assistant("empty-reasoning", [
|
||||
SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }),
|
||||
]),
|
||||
assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]),
|
||||
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", text: "" })]),
|
||||
assistant("empty-reasoning", [SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "" })]),
|
||||
assistant("text", [SessionMessage.AssistantText.make({ type: "text", text: "Partial" })]),
|
||||
assistant("reasoning", [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
state: { signature: "sig_1" },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
|
|
@ -49,7 +45,12 @@ describe("toLLMMessages", () => {
|
|||
})
|
||||
|
||||
test("maps every top-level V2 Session message type", () => {
|
||||
const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
|
||||
const file = FileAttachment.make({
|
||||
data: Base64.make("aGVsbG8="),
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "hello.png",
|
||||
})
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.AgentSelected.make({
|
||||
|
|
@ -105,6 +106,7 @@ describe("toLLMMessages", () => {
|
|||
SessionMessage.Compaction.make({
|
||||
id: id("compaction"),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary: "Earlier work",
|
||||
recent: "Recent work",
|
||||
|
|
@ -122,7 +124,7 @@ describe("toLLMMessages", () => {
|
|||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
|
||||
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
|
||||
],
|
||||
metadata: { agents: [{ name: "build" }] },
|
||||
}),
|
||||
|
|
@ -149,6 +151,131 @@ Recent work
|
|||
])
|
||||
})
|
||||
|
||||
test("lowers text attachments as separate user messages", () => {
|
||||
const file = FileAttachment.make({
|
||||
data: Base64.make(Buffer.from("export const value = 1").toString("base64")),
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///project/main.ts" },
|
||||
name: "main.ts",
|
||||
})
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-text-file"),
|
||||
type: "user",
|
||||
text: "Review this file",
|
||||
files: [file],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Attached file: main.ts\n\nexport const value = 1",
|
||||
},
|
||||
],
|
||||
metadata: { attachment: { source: file.source, name: "main.ts" } },
|
||||
})
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: id("user-text-file"),
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Review this file" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("decodes inline text attachment content", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-data-file"),
|
||||
type: "user",
|
||||
text: "Review this file",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data: Base64.make(Buffer.from("inline content").toString("base64")),
|
||||
mime: "text/plain",
|
||||
source: { type: "inline" },
|
||||
name: "inline.txt",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Attached file: inline.txt\n\ninline content",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("lowers directory attachments as directory context", () => {
|
||||
const directory = FileAttachment.make({
|
||||
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
|
||||
mime: "application/x-directory",
|
||||
source: { type: "uri", uri: "file:///project/src" },
|
||||
name: "src/",
|
||||
})
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-directory"),
|
||||
type: "user",
|
||||
text: "Review this directory",
|
||||
files: [directory],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Attached directory: src/\n\nlib/\nindex.ts" }],
|
||||
metadata: { attachment: { source: directory.source, name: "src/" } },
|
||||
})
|
||||
expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }])
|
||||
})
|
||||
|
||||
test("uses materialized image data as provider media and drops unsupported attachments", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-local-image"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [
|
||||
FileAttachment.make({ data, mime: "image/png", source: { type: "inline" }, name: "image.png" }),
|
||||
FileAttachment.make({
|
||||
data: Base64.make("JVBERg=="),
|
||||
mime: "application/pdf",
|
||||
source: { type: "inline" },
|
||||
name: "document.pdf",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
|
||||
])
|
||||
})
|
||||
|
||||
test("replays durable tool media into canonical tool messages without structured base64", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
|
|
@ -158,12 +285,11 @@ Recent work
|
|||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }),
|
||||
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-1",
|
||||
text: "Think",
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
state: { signature: "sig_1" },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
|
|
@ -208,11 +334,9 @@ Recent work
|
|||
type: "tool",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { fake: { continuation: "hosted-call" } },
|
||||
resultMetadata: { fake: { continuation: "hosted-result" } },
|
||||
},
|
||||
executed: true,
|
||||
providerState: { continuation: "hosted-call" },
|
||||
providerResultState: { continuation: "hosted-result" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
|
|
@ -225,7 +349,8 @@ Recent work
|
|||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
|
||||
executed: true,
|
||||
providerState: { continuation: "failed" },
|
||||
state: SessionMessage.ToolStateError.make({
|
||||
status: "error",
|
||||
input: { path: "README.md" },
|
||||
|
|
@ -245,7 +370,7 @@ Recent work
|
|||
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Checking" },
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { provider: { signature: "sig_1" } } },
|
||||
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
|
||||
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
|
||||
{
|
||||
|
|
@ -260,14 +385,14 @@ Recent work
|
|||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-call" } },
|
||||
providerMetadata: { provider: { continuation: "hosted-call" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-result" } },
|
||||
providerMetadata: { provider: { continuation: "hosted-result" } },
|
||||
result: { type: "text", value: "Found it" },
|
||||
},
|
||||
{
|
||||
|
|
@ -276,14 +401,14 @@ Recent work
|
|||
name: "write",
|
||||
input: { path: "README.md" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
providerMetadata: { provider: { continuation: "failed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
providerMetadata: { provider: { continuation: "failed" } },
|
||||
result: {
|
||||
type: "error",
|
||||
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
||||
|
|
@ -317,9 +442,8 @@ Recent work
|
|||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-openai",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
|
|
@ -332,7 +456,7 @@ Recent work
|
|||
{
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
|
@ -348,19 +472,16 @@ Recent work
|
|||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-failed",
|
||||
text: "Partial thought",
|
||||
providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } },
|
||||
state: { itemId: "rs_failed", reasoningEncryptedContent: null },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { openai: { itemId: "call_failed" } },
|
||||
resultMetadata: { openai: { itemId: "result_failed" } },
|
||||
},
|
||||
executed: true,
|
||||
providerState: { itemId: "call_failed" },
|
||||
providerResultState: { itemId: "result_failed" },
|
||||
state: SessionMessage.ToolStateError.make({
|
||||
status: "error",
|
||||
input: { query: "Effect" },
|
||||
|
|
@ -420,19 +541,16 @@ Recent work
|
|||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-old-model",
|
||||
text: "Visible thought",
|
||||
providerMetadata: { anthropic: { signature: "sig_old" } },
|
||||
state: { signature: "sig_old" },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { openai: { itemId: "hosted-old-model" } },
|
||||
resultMetadata: { openai: { itemId: "hosted-old-model" } },
|
||||
},
|
||||
executed: true,
|
||||
providerState: { itemId: "hosted-old-model" },
|
||||
providerResultState: { itemId: "hosted-old-model" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
|
|
@ -446,11 +564,9 @@ Recent work
|
|||
type: "tool",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
provider: {
|
||||
executed: false,
|
||||
metadata: { fake: { call: "old" } },
|
||||
resultMetadata: { fake: { result: "old" } },
|
||||
},
|
||||
executed: false,
|
||||
providerState: { call: "old" },
|
||||
providerResultState: { result: "old" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { path: "README.md" },
|
||||
|
|
@ -508,4 +624,34 @@ Recent work
|
|||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves provider metadata for a catalog alias with a different API model ID", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant-alias"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
text: "Visible thought",
|
||||
state: { reasoningEncryptedContent: "encrypted" },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
ModelV2.Ref.make({ id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") }),
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Visible thought",
|
||||
providerMetadata: { provider: { reasoningEncryptedContent: "encrypted" } },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { LLM } from "@opencode-ai/llm"
|
||||
import { LLM, Model } from "@opencode-ai/llm"
|
||||
import { LLMClient } from "@opencode-ai/llm/route"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
|
|
@ -13,28 +13,26 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
type Api =
|
||||
| {
|
||||
readonly type: "aisdk"
|
||||
readonly package: string
|
||||
readonly url?: string
|
||||
readonly settings?: Record<string, unknown>
|
||||
}
|
||||
| { readonly type: "native"; readonly url?: string; readonly settings: Record<string, unknown> }
|
||||
interface ModelOptions {
|
||||
readonly modelID?: string
|
||||
readonly settings?: ModelV2.Info["settings"]
|
||||
readonly headers?: ModelV2.Info["headers"]
|
||||
readonly body?: ModelV2.Info["body"]
|
||||
readonly variants?: ModelV2.Info["variants"]
|
||||
}
|
||||
|
||||
const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
|
||||
const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
ModelV2.Info.make({
|
||||
id: ModelV2.ID.make("test-model"),
|
||||
modelID: ModelV2.ID.make(options.modelID ?? "api-test-model"),
|
||||
providerID: ProviderV2.ID.make("test-provider"),
|
||||
name: "Test model",
|
||||
api: { id: ModelV2.ID.make("api-test-model"), ...api },
|
||||
package: packageName,
|
||||
settings: options.settings ?? {},
|
||||
headers: options.headers ?? { "x-test": "header" },
|
||||
body: options.body ?? { custom_extension: { enabled: true } },
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
request: {
|
||||
settings: {},
|
||||
headers: { "x-test": "header" },
|
||||
body: { apiKey: "secret", custom_extension: { enabled: true } },
|
||||
},
|
||||
variants,
|
||||
variants: options.variants ?? [],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
|
|
@ -43,12 +41,14 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
|
|||
})
|
||||
|
||||
describe("SessionRunnerModel", () => {
|
||||
it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () =>
|
||||
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
)
|
||||
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
})
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(catalog)
|
||||
|
||||
expect(catalog.id).toBe(ModelV2.ID.make("test-model"))
|
||||
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "openai-responses",
|
||||
|
|
@ -65,7 +65,9 @@ describe("SessionRunnerModel", () => {
|
|||
it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { apiKey: "secret", baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
)
|
||||
const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
|
||||
|
|
@ -77,14 +79,14 @@ describe("SessionRunnerModel", () => {
|
|||
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://compatible.example/v1",
|
||||
settings: { apiKey: "settings-secret", compatibility: "strict" },
|
||||
}),
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
|
||||
settings: {
|
||||
apiKey: "settings-secret",
|
||||
baseURL: "https://compatible.example/v1",
|
||||
compatibility: "strict",
|
||||
},
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
|
|
@ -97,24 +99,29 @@ describe("SessionRunnerModel", () => {
|
|||
})
|
||||
|
||||
expect(headers.authorization).toBe("Bearer settings-secret")
|
||||
expect(resolved.route.id).toBe("openai-compatible-chat")
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
|
||||
expect(resolved.route.defaults.http?.body).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { reasoningEffort: "high" },
|
||||
headers: { "x-variant": "high" },
|
||||
body: {
|
||||
store: false,
|
||||
service_tier: "priority",
|
||||
temperature: 0.2,
|
||||
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
variants: [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { reasoningEffort: "high" },
|
||||
headers: { "x-variant": "high" },
|
||||
body: {
|
||||
store: false,
|
||||
service_tier: "priority",
|
||||
temperature: 0.2,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
],
|
||||
})
|
||||
const session = SessionV2.Info.make({
|
||||
id: SessionV2.ID.make("ses_model_variant"),
|
||||
projectID: ProjectV2.ID.global,
|
||||
|
|
@ -147,9 +154,9 @@ describe("SessionRunnerModel", () => {
|
|||
|
||||
it.effect("overlays selected OpenAI-compatible Session variant bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(
|
||||
{ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://compatible.example/v1" },
|
||||
[
|
||||
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
|
||||
settings: { baseURL: "https://compatible.example/v1" },
|
||||
variants: [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: {},
|
||||
|
|
@ -157,7 +164,7 @@ describe("SessionRunnerModel", () => {
|
|||
body: { store: false, reasoning_effort: "high" },
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
||||
const session = SessionV2.Info.make({
|
||||
id: SessionV2.ID.make("ses_compatible_variant"),
|
||||
projectID: ProjectV2.ID.global,
|
||||
|
|
@ -181,7 +188,9 @@ describe("SessionRunnerModel", () => {
|
|||
|
||||
it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" })
|
||||
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
})
|
||||
const session = SessionV2.Info.make({
|
||||
id: SessionV2.ID.make("ses_model_variant_unavailable"),
|
||||
projectID: ProjectV2.ID.global,
|
||||
|
|
@ -211,14 +220,17 @@ describe("SessionRunnerModel", () => {
|
|||
|
||||
it.effect("overlays selected Anthropic Session variant settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 12000 } },
|
||||
headers: {},
|
||||
body: {},
|
||||
},
|
||||
])
|
||||
const catalog = model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
|
||||
settings: { baseURL: "https://anthropic.example/v1" },
|
||||
variants: [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 12000 } },
|
||||
headers: {},
|
||||
body: {},
|
||||
},
|
||||
],
|
||||
})
|
||||
const session = SessionV2.Info.make({
|
||||
id: SessionV2.ID.make("ses_anthropic_variant"),
|
||||
projectID: ProjectV2.ID.global,
|
||||
|
|
@ -244,7 +256,9 @@ describe("SessionRunnerModel", () => {
|
|||
it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }),
|
||||
model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
|
||||
settings: { baseURL: "https://anthropic.example/v1" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route).toMatchObject({
|
||||
|
|
@ -257,9 +271,10 @@ describe("SessionRunnerModel", () => {
|
|||
it.effect("uses resolved credentials for bearer auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "secret" }),
|
||||
)
|
||||
|
|
@ -280,9 +295,10 @@ describe("SessionRunnerModel", () => {
|
|||
Effect.gen(function* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { settings: {}, headers: {}, body: { apiKey: "configured-secret" } },
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
credential,
|
||||
)
|
||||
|
|
@ -302,9 +318,10 @@ describe("SessionRunnerModel", () => {
|
|||
it.effect("does not project OAuth account metadata into the request body", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
|
|
@ -323,9 +340,10 @@ describe("SessionRunnerModel", () => {
|
|||
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
|
|
@ -354,12 +372,78 @@ describe("SessionRunnerModel", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model("@opencode-ai/llm/providers/openai", {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { accountID: "acct_123" },
|
||||
}),
|
||||
)
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not route native OpenAI-compatible packages to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model("@opencode-ai/llm/providers/openai-compatible", {
|
||||
settings: { baseURL: "https://compatible.example/v1" },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { accountID: "acct_123" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.id).toBe("openai-compatible-chat")
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps legacy OpenAI organization and project settings to headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { organization: "org_123", project: "proj_123" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.defaults.headers).toMatchObject({
|
||||
"OpenAI-Organization": "org_123",
|
||||
"OpenAI-Project": "proj_123",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
|
|
@ -387,9 +471,10 @@ describe("SessionRunnerModel", () => {
|
|||
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
|
|
@ -415,35 +500,106 @@ describe("SessionRunnerModel", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects catalog APIs without a native route", () =>
|
||||
it.effect("loads dynamic native provider packages through the injected package loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
)
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model("@opencode-ai/llm/providers/custom", {
|
||||
settings: { region: "test" },
|
||||
headers: { "x-package": "header" },
|
||||
body: { custom: true },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe("@opencode-ai/llm/providers/custom")
|
||||
return Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(modelID).toBe("api-test-model")
|
||||
expect(settings).toEqual({
|
||||
region: "test",
|
||||
headers: { "x-package": "header" },
|
||||
body: { custom: true },
|
||||
limits: { context: 100, output: 20 },
|
||||
})
|
||||
return Model.make({ id: modelID, provider: "package-provider", route: native.route })
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
)
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/google"), {
|
||||
modelID: "gemini-api-model",
|
||||
settings: { project: "test" },
|
||||
headers: { "x-aisdk": "header" },
|
||||
body: { custom: true },
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "fallback-secret" }),
|
||||
{
|
||||
loadAISDK: (runtime) =>
|
||||
Effect.sync(() => {
|
||||
expect(runtime).toMatchObject({
|
||||
id: "test-model",
|
||||
modelID: "gemini-api-model",
|
||||
providerID: "test-provider",
|
||||
package: ProviderV2.aisdk("@ai-sdk/google"),
|
||||
settings: { project: "test", apiKey: "fallback-secret" },
|
||||
headers: { "x-aisdk": "header" },
|
||||
body: { custom: true },
|
||||
})
|
||||
return Model.make({
|
||||
id: runtime.modelID ?? runtime.id,
|
||||
provider: runtime.providerID,
|
||||
route: native.route,
|
||||
})
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects AISDK packages without an available loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
|
||||
model(ProviderV2.aisdk("@ai-sdk/google"), {
|
||||
settings: { baseURL: "https://google.example/v1" },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnsupportedApiError",
|
||||
_tag: "SessionRunnerModel.UnsupportedPackageError",
|
||||
providerID: "test-provider",
|
||||
modelID: "test-model",
|
||||
api: "aisdk:@ai-sdk/google",
|
||||
package: "aisdk:@ai-sdk/google",
|
||||
})
|
||||
expect(failure.message).toBe("Unsupported API for test-provider/test-model: aisdk:@ai-sdk/google")
|
||||
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports whether a catalog model has a supported native route", () =>
|
||||
it.effect("reports whether a catalog model declares a provider package", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
SessionRunnerModel.supported(
|
||||
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
SessionRunnerModel.supported(
|
||||
model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false)
|
||||
expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
|
||||
expect(SessionRunnerModel.supported(model("@opencode-ai/llm/providers/custom"))).toBe(true)
|
||||
expect(SessionRunnerModel.supported(model(undefined))).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
||||
|
|
@ -31,9 +31,9 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
|||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { Instructions } from "@opencode-ai/core/instructions"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||
|
|
@ -73,18 +73,18 @@ const model = OpenAIChat.route
|
|||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||
const systemContext = Layer.mock(SystemContextBuiltIns.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[InstructionBuiltIns.node, systemContext],
|
||||
[InstructionDiscovery.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
|
|
@ -119,8 +119,8 @@ const it = testEffect(
|
|||
AgentV2.node,
|
||||
ToolRegistry.node,
|
||||
SessionRunnerModel.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
|
|
@ -133,8 +133,8 @@ const it = testEffect(
|
|||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[InstructionBuiltIns.node, systemContext],
|
||||
[InstructionDiscovery.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
|
|
@ -172,7 +172,7 @@ describe("SessionRunnerLLM recorded", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const prompt = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Say hello in one short sentence." }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Say hello in one short sentence." }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -27,9 +27,7 @@ const capture = () => {
|
|||
return event
|
||||
}),
|
||||
subscribe: () => Stream.empty,
|
||||
live: () => Stream.empty,
|
||||
log: () => Stream.empty,
|
||||
changes: () => Stream.empty,
|
||||
sequences: () => Effect.succeed(new Map()),
|
||||
listen: () => Effect.succeed(Effect.void),
|
||||
project: () => Effect.void,
|
||||
|
|
@ -47,6 +45,7 @@ const capture = () => {
|
|||
id: ModelV2.ID.make("model"),
|
||||
providerID: ProviderV2.ID.make("provider"),
|
||||
},
|
||||
provider: "openai",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +89,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
|
|||
})
|
||||
})
|
||||
|
||||
test("provider-executed success retains its compatibility result", async () => {
|
||||
test("provider-executed success retains its raw provider result", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
|
||||
|
|
@ -98,6 +97,19 @@ test("provider-executed success retains its compatibility result", async () => {
|
|||
expect(success?.data).toHaveProperty("result")
|
||||
})
|
||||
|
||||
test("provider state uses the route provider instead of the catalog provider", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: "reasoning" } } }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
|
||||
state: { itemId: "reasoning" },
|
||||
})
|
||||
})
|
||||
|
||||
test("binary failure emits no success event", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
|
|
@ -114,7 +126,7 @@ test("binary failure emits no success event", async () => {
|
|||
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
|
||||
})
|
||||
|
||||
test("old success event data containing result still decodes", () => {
|
||||
test("success event data can carry a provider-executed result", () => {
|
||||
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
|
|
@ -122,7 +134,7 @@ test("old success event data containing result still decodes", () => {
|
|||
structured: { type: "media", mime: "image/png" },
|
||||
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
|
||||
result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
|
||||
provider: { executed: false },
|
||||
executed: true,
|
||||
})
|
||||
expect(decoded.result).toMatchObject({ type: "content" })
|
||||
})
|
||||
|
|
@ -135,3 +147,40 @@ test("step finish records settlement without publishing step ended", async () =>
|
|||
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
||||
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
||||
})
|
||||
|
||||
test("content-filter finish retains failure evidence until step closeout", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "content-filter" })))
|
||||
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
|
||||
await Effect.runPromise(publisher.publishStepFailure())
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
|
||||
expect(published.at(-1)?.data).toMatchObject({
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
})
|
||||
expect(publisher.stepSettlement()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(
|
||||
Effect.forEach(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text" }),
|
||||
LLMEvent.textDelta({ id: "text", text: "Partial" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
|
||||
],
|
||||
(event) => publisher.publish(event),
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(publisher.publishStepFailure())
|
||||
|
||||
expect(published.some((event) => event.type === "session.step.ended.1")).toBe(false)
|
||||
expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Partial" })
|
||||
expect(published.find((event) => event.type === "session.step.failed.1")?.data).toMatchObject({
|
||||
error: { type: "provider.content-filter" },
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
|
|
|
|||
|
|
@ -76,9 +76,8 @@ describe("Tool.Progress", () => {
|
|||
sessionID,
|
||||
assistantMessageID,
|
||||
callID,
|
||||
tool: "bash",
|
||||
input: { command: "pwd" },
|
||||
provider: { executed: false },
|
||||
executed: false,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -104,7 +103,7 @@ describe("Tool.Progress", () => {
|
|||
callID: "call-success",
|
||||
structured: { phase: "done" },
|
||||
content: content("complete"),
|
||||
provider: { executed: false },
|
||||
executed: false,
|
||||
})
|
||||
expect((yield* readAssistant).content[0]).toMatchObject({
|
||||
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
|
||||
|
|
@ -123,7 +122,7 @@ describe("Tool.Progress", () => {
|
|||
assistantMessageID,
|
||||
callID: "call-failed",
|
||||
error: { type: "unknown", message: "boom" },
|
||||
provider: { executed: false },
|
||||
executed: false,
|
||||
})
|
||||
expect((yield* readAssistant).content[1]).toMatchObject({
|
||||
state: {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
||||
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionInput } from "@opencode-ai/schema/session-input"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
|
|
@ -47,7 +47,6 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
coreSessionInput,
|
||||
coreSessionMessage,
|
||||
coreSessionTodo,
|
||||
corePrompt,
|
||||
coreSkill,
|
||||
coreV2Schema,
|
||||
coreSchema,
|
||||
|
|
@ -69,7 +68,6 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
import("@opencode-ai/core/session/input"),
|
||||
import("@opencode-ai/core/session/message"),
|
||||
import("@opencode-ai/core/session/todo"),
|
||||
import("@opencode-ai/core/session/prompt"),
|
||||
import("@opencode-ai/core/skill"),
|
||||
import("@opencode-ai/core/v2-schema"),
|
||||
import("@opencode-ai/core/schema"),
|
||||
|
|
@ -105,6 +103,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
[coreIntegration.Ref, Integration.Ref],
|
||||
[coreLocation.Ref, Location.Ref],
|
||||
[coreLLM.ProviderMetadata, LLM.ProviderMetadata],
|
||||
[coreLLM.FinishReason, LLM.FinishReason],
|
||||
[coreLLM.ToolTextContent, LLM.ToolTextContent],
|
||||
[coreLLM.ToolFileContent, LLM.ToolFileContent],
|
||||
[coreLLM.ToolContent, LLM.ToolContent],
|
||||
|
|
@ -114,12 +113,8 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
[ModelV2.Family, Model.Family],
|
||||
[ModelV2.Capabilities, Model.Capabilities],
|
||||
[ModelV2.Cost, Model.Cost],
|
||||
[ModelV2.Api, Model.Api],
|
||||
[ModelV2.Info, Model.Info],
|
||||
[ProviderV2.ID, Provider.ID],
|
||||
[ProviderV2.AISDK, Provider.AISDK],
|
||||
[ProviderV2.Native, Provider.Native],
|
||||
[ProviderV2.Api, Provider.Api],
|
||||
[ProviderV2.Request, Provider.Request],
|
||||
[ProviderV2.Info, Provider.Info],
|
||||
[corePermission.Effect, Permission.Effect],
|
||||
|
|
@ -143,7 +138,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
[coreSessionInput.Delivery, SessionInput.Delivery],
|
||||
[coreSessionInput.Admitted, SessionInput.Admitted],
|
||||
[coreSessionMessage.ID, SessionMessage.ID],
|
||||
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
|
||||
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
|
||||
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
|
||||
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
|
||||
[coreSessionMessage.User, SessionMessage.User],
|
||||
|
|
@ -164,10 +159,6 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
[coreSessionMessage.Message, SessionMessage.Message],
|
||||
[coreSessionTodo.Info, SessionTodo.Info],
|
||||
[coreSessionTodo.Event, SessionTodo.Event],
|
||||
[corePrompt.Source, Source],
|
||||
[corePrompt.FileAttachment, FileAttachment],
|
||||
[corePrompt.AgentAttachment, AgentAttachment],
|
||||
[corePrompt.Prompt, Prompt],
|
||||
[coreSkill.DirectorySource, Skill.DirectorySource],
|
||||
[coreSkill.UrlSource, Skill.UrlSource],
|
||||
[coreSkill.EmbeddedSource, Skill.EmbeddedSource],
|
||||
|
|
@ -193,7 +184,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
test("shared record schemas construct and decode plain objects", () => {
|
||||
const made = Prompt.make({ text: "hello" })
|
||||
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
||||
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" })
|
||||
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", text: "hi" })
|
||||
|
||||
expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ async function pull(skills: unknown[], files: Record<string, string> = {}, fixtu
|
|||
fetch(request) {
|
||||
state.requests.push(request.url)
|
||||
const pathname = new URL(request.url).pathname
|
||||
const body = pathname === "/catalog/index.json" ? JSON.stringify({ skills: state.skills }) : state.files[pathname]
|
||||
const body =
|
||||
pathname === "/catalog/index.json" ? JSON.stringify({ skills: state.skills }) : state.files[pathname]
|
||||
return new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 })
|
||||
},
|
||||
})
|
||||
|
|
@ -119,10 +120,9 @@ describe("SkillDiscovery.pull", () => {
|
|||
})
|
||||
|
||||
test("refreshes cached files when the version changes", async () => {
|
||||
const first = await pull(
|
||||
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
|
||||
{ "/catalog/deploy/SKILL.md": "# Old" },
|
||||
)
|
||||
const first = await pull([{ name: "deploy", version: "1", files: ["SKILL.md"] }], {
|
||||
"/catalog/deploy/SKILL.md": "# Old",
|
||||
})
|
||||
try {
|
||||
const second = await pull(
|
||||
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
||||
|
|
@ -144,13 +144,10 @@ describe("SkillDiscovery.pull", () => {
|
|||
})
|
||||
|
||||
test("publishes complete updates and removes stale files", async () => {
|
||||
const first = await pull(
|
||||
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
|
||||
{
|
||||
"/catalog/deploy/SKILL.md": "# Old",
|
||||
"/catalog/deploy/old.md": "old reference",
|
||||
},
|
||||
)
|
||||
const first = await pull([{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }], {
|
||||
"/catalog/deploy/SKILL.md": "# Old",
|
||||
"/catalog/deploy/old.md": "old reference",
|
||||
})
|
||||
try {
|
||||
const root = first.directories[0]
|
||||
|
||||
|
|
|
|||
|
|
@ -26,9 +26,7 @@ const discovery = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node, EventV2.node]), [
|
||||
[SkillDiscovery.node, discovery],
|
||||
]),
|
||||
AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node, EventV2.node]), [[SkillDiscovery.node, discovery]]),
|
||||
)
|
||||
|
||||
function write(directory: string, name: string, description: string) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
|
|||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { Instructions } from "@opencode-ai/core/instructions"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ describe("SkillGuidance", () => {
|
|||
const guidance = yield* SkillGuidance.Service
|
||||
const initialized = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
.pipe(Effect.flatMap(Instructions.initialize))
|
||||
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
|
|
@ -71,7 +71,7 @@ describe("SkillGuidance", () => {
|
|||
expect(
|
||||
yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))),
|
||||
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: "The following skills are no longer available and must not be used: effect.",
|
||||
|
|
@ -92,12 +92,12 @@ describe("SkillGuidance", () => {
|
|||
const guidance = yield* SkillGuidance.Service
|
||||
const initialized = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
.pipe(Effect.flatMap(Instructions.initialize))
|
||||
|
||||
skills = [effect, debugging]
|
||||
const added = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied)))
|
||||
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied)))
|
||||
expect(added).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
|
|
@ -113,7 +113,7 @@ describe("SkillGuidance", () => {
|
|||
const removed = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(
|
||||
Effect.flatMap((context) => SystemContext.reconcile(context, added._tag === "Updated" ? added.applied : {})),
|
||||
Effect.flatMap((context) => Instructions.reconcile(context, added._tag === "Updated" ? added.applied : {})),
|
||||
)
|
||||
expect(removed).toMatchObject({
|
||||
_tag: "Updated",
|
||||
|
|
@ -129,13 +129,13 @@ describe("SkillGuidance", () => {
|
|||
const guidance = yield* SkillGuidance.Service
|
||||
const initialized = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
.pipe(Effect.flatMap(Instructions.initialize))
|
||||
|
||||
skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })]
|
||||
expect(
|
||||
yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))),
|
||||
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining(
|
||||
|
|
@ -152,12 +152,12 @@ describe("SkillGuidance", () => {
|
|||
})
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
|
||||
{
|
||||
text: "",
|
||||
applied: {},
|
||||
},
|
||||
)
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
||||
|
|
@ -171,12 +171,12 @@ describe("SkillGuidance", () => {
|
|||
})
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
|
||||
{
|
||||
text: "",
|
||||
applied: {},
|
||||
},
|
||||
)
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
||||
|
|
@ -191,7 +191,7 @@ describe("SkillGuidance", () => {
|
|||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
expect(
|
||||
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).text,
|
||||
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).text,
|
||||
).toContain("<name>effect</name>")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
|
@ -207,12 +207,12 @@ describe("SkillGuidance", () => {
|
|||
})
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
|
||||
{
|
||||
text: "",
|
||||
applied: {},
|
||||
},
|
||||
)
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
|
||||
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
|
||||
const instructionFile = FSUtil.resolve("/repo/AGENTS.md")
|
||||
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
|
||||
const localDate = (time: number) => new Date(time).toDateString()
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory },
|
||||
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
|
||||
),
|
||||
),
|
||||
)
|
||||
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, InstructionContext.node])
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(builtInsNode, [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: "/global" })],
|
||||
]),
|
||||
)
|
||||
const instructionFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
up: () => Effect.succeed([instructionFile]),
|
||||
readFileStringSafe: (path) => Effect.succeed(path === instructionFile ? "Be precise." : undefined),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const itWithInstructions = testEffect(
|
||||
AppNodeBuilder.build(builtInsNode, [
|
||||
[Location.node, locationLayer],
|
||||
[FSUtil.node, instructionFS],
|
||||
[Global.node, Global.layerWith({ config: "/global" })],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("SystemContextBuiltIns", () => {
|
||||
it.effect("loads location-scoped environment and host-local date context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
` Working directory: ${directory}`,
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
].join("\n"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles the date without repeating unchanged environment context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
||||
const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.applied)
|
||||
|
||||
expect(refreshed).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not update again within the same local calendar day", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
|
||||
expect(yield* SystemContext.reconcile(yield* context.load(), initialized.applied)).toEqual({ _tag: "Unchanged" })
|
||||
}),
|
||||
)
|
||||
|
||||
itWithInstructions.effect("composes ambient instructions after built-in context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const builtIns = yield* SystemContextBuiltIns.Service
|
||||
const instructions = yield* InstructionContext.Service
|
||||
const context = {
|
||||
load: () => Effect.all([builtIns.load(), instructions.load()]).pipe(Effect.map(SystemContext.combine)),
|
||||
}
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* context.load())).text).toBe(
|
||||
[
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
` Working directory: ${directory}`,
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
"",
|
||||
`Instructions from: ${instructionFile}\nBe precise.`,
|
||||
].join("\n"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -47,7 +47,15 @@ const permission = Layer.succeed(
|
|||
}).pipe(
|
||||
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
|
|
|||
|
|
@ -40,7 +40,15 @@ const permission = Layer.succeed(
|
|||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,17 @@ const permission = Layer.succeed(
|
|||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
|
|
@ -82,7 +92,13 @@ describe("QuestionTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
|
||||
}),
|
||||
).toEqual({ result: { type: "error", value: "Permission denied: question" } })
|
||||
).toEqual({
|
||||
result: { type: "error", value: "Permission denied: question" },
|
||||
error: {
|
||||
type: "permission.rejected",
|
||||
message: "Permission denied: question",
|
||||
},
|
||||
})
|
||||
expect(capturedInput()).toBeUndefined()
|
||||
deny = false
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -81,7 +81,19 @@ const permission = Layer.succeed(
|
|||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
allow
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import fs from "fs/promises"
|
|||
import { realpathSync } from "node:fs"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, Scope } from "effect"
|
||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
|
|
@ -47,7 +47,15 @@ const permission = Layer.succeed(
|
|||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
|
@ -75,7 +83,6 @@ const executionNode = makeGlobalNode({
|
|||
const session = yield* store.get(id)
|
||||
if (!session) return
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const textID = "text_shell_test"
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
|
|
@ -85,12 +92,12 @@ const executionNode = makeGlobalNode({
|
|||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
ordinal: 0,
|
||||
text: "ok",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
|
|
@ -435,7 +442,10 @@ describe("ShellTool", () => {
|
|||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const settled = yield* settleTool(registry, call({ command: idleCommand, background: true }))
|
||||
const settled = yield* settleTool(
|
||||
registry,
|
||||
call({ command: idleCommand, timeout: 50, background: true }),
|
||||
)
|
||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
expect(settled.output?.structured).toMatchObject({ truncated: false })
|
||||
|
|
@ -445,7 +455,45 @@ describe("ShellTool", () => {
|
|||
if (!shellID) return
|
||||
const id = ShellSchema.ID.make(shellID)
|
||||
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
||||
yield* shell.remove(id)
|
||||
expect((yield* shell.wait(id)).status).toBe("timeout")
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("updates and clears a running shell timeout", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const shell = yield* Shell.Service
|
||||
const timed = yield* settleTool(
|
||||
registry,
|
||||
call({ command: idleCommand, background: true }, "call-updated-timeout"),
|
||||
)
|
||||
const timedID = (timed.output?.structured as Record<string, unknown> | undefined)?.shellID
|
||||
expect(typeof timedID).toBe("string")
|
||||
if (typeof timedID !== "string") return
|
||||
const timedShellID = ShellSchema.ID.make(timedID)
|
||||
yield* shell.timeout(timedShellID, 50)
|
||||
expect((yield* shell.wait(timedShellID)).status).toBe("timeout")
|
||||
|
||||
const cleared = yield* settleTool(
|
||||
registry,
|
||||
call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"),
|
||||
)
|
||||
const clearedID = (cleared.output?.structured as Record<string, unknown> | undefined)?.shellID
|
||||
expect(typeof clearedID).toBe("string")
|
||||
if (typeof clearedID !== "string") return
|
||||
const clearedShellID = ShellSchema.ID.make(clearedID)
|
||||
yield* shell.timeout(clearedShellID, 0)
|
||||
yield* Effect.sleep(Duration.millis(100))
|
||||
expect((yield* shell.get(clearedShellID)).status).toBe("running")
|
||||
yield* shell.remove(clearedShellID)
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
|
@ -462,9 +510,10 @@ describe("ShellTool", () => {
|
|||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const waiting = yield* settleTool(registry, call({ command: idleCommand }, "call-background-signal")).pipe(
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
const waiting = yield* settleTool(
|
||||
registry,
|
||||
call({ command: idleCommand, timeout: 50 }, "call-background-signal"),
|
||||
).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
|
||||
const backgroundWhenReady = (remaining = 1000): Effect.Effect<Job.Info[], Error> =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -475,7 +524,6 @@ describe("ShellTool", () => {
|
|||
return yield* backgroundWhenReady(remaining - 1)
|
||||
})
|
||||
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
|
||||
|
||||
const settled = yield* Fiber.join(waiting)
|
||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
|
|
@ -493,6 +541,8 @@ describe("ShellTool", () => {
|
|||
const shell = yield* Shell.Service
|
||||
if (!shellID) return
|
||||
const id = ShellSchema.ID.make(shellID)
|
||||
yield* Effect.sleep(Duration.millis(100))
|
||||
expect((yield* shell.get(id)).status).toBe("running")
|
||||
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
||||
yield* shell.remove(id)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -55,7 +55,17 @@ describe("SkillTool", () => {
|
|||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ const executionNode = makeGlobalNode({
|
|||
}
|
||||
completed.add(sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const textID = "text_subagent_test"
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
|
|
@ -59,12 +58,12 @@ const executionNode = makeGlobalNode({
|
|||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
ordinal: 0,
|
||||
text: childText,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,17 @@ const permission = Layer.succeed(
|
|||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
|
|
|
|||
|
|
@ -38,7 +38,15 @@ const permission = Layer.succeed(
|
|||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue