opencode/packages/core/test/aisdk.test.ts
2026-07-21 19:12:08 +00:00

399 lines
14 KiB
TypeScript

import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } 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, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
import { LLMClient, RequestExecutor, type RequestData } from "@opencode-ai/ai/route"
import { expect } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(
Layer.merge(
AISDK.locationLayer,
LLMClient.layer.pipe(Layer.provide(Layer.mock(RequestExecutor.Service)({ execute: () => Effect.die("unused") }))),
),
)
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 },
})
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
specificationVersion: "v3",
provider: "test",
modelId: "test",
supportedUrls: {},
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
doStream: () =>
Promise.resolve({
stream: new ReadableStream({
start(controller) {
events.forEach((event) => controller.enqueue(event))
controller.close()
},
}),
}),
})
const usage = {
inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 1, text: 0, reasoning: 0 },
} as const
const client = LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
),
),
)
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" })
}),
)
it.effect("isolates request transforms across cached AI SDK streams", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const upstream: Array<{ headers: Headers; body: Record<string, unknown> }> = []
let sdkFetch: typeof fetch | undefined
const input = model("test-sdk")
if (!input.settings) return yield* Effect.die("AI SDK test model settings are missing")
Object.assign(input.settings, {
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
upstream.push({
headers: new Headers(init?.headers),
body: JSON.parse(String(init?.body)),
})
return new Response()
},
})
yield* aisdk.hook.sdk((event) => {
sdkFetch = event.options.fetch
event.sdk = {
languageModel: () => ({
doStream: async (options: LanguageModelV3CallOptions) => {
if (!sdkFetch) throw new Error("AI SDK fetch was not installed")
await sdkFetch("https://provider.test/model", {
method: "POST",
headers: Object.fromEntries(
Object.entries(options.headers ?? {}).filter(
(entry): entry is [string, string] => entry[1] !== undefined,
),
),
body: JSON.stringify({ model: "api-model", remove: true }),
})
return { stream: new ReadableStream({ start: (controller) => controller.close() }) }
},
}),
}
})
const resolved = yield* aisdk.model(input)
const llm = yield* LLMClient.Service
const run = (session: string) =>
llm
.stream(LLM.request({ model: resolved, prompt: session }), {
transformRequest: (request) =>
Effect.sync(() => {
const body: RequestData["body"] = { ...request.body, session }
delete body.remove
return { headers: { ...request.headers, "x-session": session }, body }
}),
})
.pipe(Stream.runDrain)
yield* Effect.all([run("one"), run("two")], { concurrency: "unbounded" })
expect(upstream.map((request) => request.headers.get("x-session")).toSorted()).toEqual(["one", "two"])
expect(upstream.map((request) => request.body.session).toSorted()).toEqual(["one", "two"])
expect(upstream.every((request) => !("remove" in request.body))).toBe(true)
}),
)
it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
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 resolved = yield* aisdk.model({
...model("@ai-sdk/openai"),
body: { reasoning: { mode: "pro" } },
})
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: resolved, prompt: "Hello" }),
)
expect(body).toBeUndefined()
expect(prepared.body.providerOptions).toEqual({
openai: { forceReasoning: true, reasoningMode: "pro" },
})
}),
)
it.effect("maps package-specific AI SDK provider option keys", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const cases = [
["@ai-sdk/github-copilot", "copilot", { reasoningEffort: "high" }],
["@ai-sdk/amazon-bedrock/mantle", "openai", { reasoningEffort: "high", forceReasoning: true }],
["@ai-sdk/openai-compatible", "test-provider", { reasoningEffort: "high" }],
["@jerome-benoit/sap-ai-provider-v2", "sap-ai", { reasoningEffort: "high" }],
["ai-gateway-provider", "openaiCompatible", { reasoningEffort: "high" }],
] as const
for (const [packageName, key, settings] of cases) {
const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" }))
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: resolved, prompt: "Hello" }),
)
expect(prepared.body.providerOptions).toEqual({ [key]: settings })
}
}),
)
it.effect("forces reasoning and projects both Azure AI SDK namespaces", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" }))
const openaiPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: openai, prompt: "Hello" }),
)
expect(openaiPrepared.body.providerOptions).toEqual({
openai: { reasoningEffort: "high", forceReasoning: true },
})
const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" }))
const azurePrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: azure, prompt: "Hello" }),
)
expect(azurePrepared.body.providerOptions).toEqual({
openai: { reasoningEffort: "high", forceReasoning: true },
azure: { reasoningEffort: "high", forceReasoning: true },
})
}),
)
it.effect("routes AI Gateway model options by upstream prefix", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const anthropic = yield* aisdk.model({
...model("@ai-sdk/gateway", {
gateway: { order: ["anthropic"] },
thinking: { type: "adaptive" },
}),
modelID: ModelV2.ID.make("anthropic/claude-sonnet-5"),
})
const anthropicPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: anthropic, prompt: "Hello" }),
)
expect(anthropicPrepared.body.providerOptions).toEqual({
gateway: { order: ["anthropic"] },
anthropic: { thinking: { type: "adaptive" } },
})
const bedrock = yield* aisdk.model({
...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }),
modelID: ModelV2.ID.make("amazon/nova-2-lite"),
})
const bedrockPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: bedrock, prompt: "Hello" }),
)
expect(bedrockPrepared.body.providerOptions).toEqual({
bedrock: { reasoningConfig: { type: "enabled" } },
})
const fallback = yield* aisdk.model({
...model("@ai-sdk/gateway", { reasoningEffort: "high" }),
modelID: ModelV2.ID.make("deepseek/deepseek-v4"),
})
const fallbackPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: fallback, prompt: "Hello" }),
)
expect(fallbackPrepared.body.providerOptions).toEqual({
deepseek: { reasoningEffort: "high" },
})
}),
)
it.effect("projects replay metadata onto AI SDK prompt parts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("@ai-sdk/anthropic"))
expect(resolved.route.providerMetadataKey).toBe("anthropic")
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({
model: resolved,
messages: [
Message.assistant([
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } },
{
type: "tool-call",
id: "hosted",
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { anthropic: { blockType: "server_tool_use" } },
},
]),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "assistant",
content: [
{
type: "reasoning",
text: "Think",
providerOptions: { anthropic: { signature: "signed" } },
},
{
type: "tool-call",
toolCallId: "hosted",
toolName: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerOptions: { anthropic: { blockType: "server_tool_use" } },
},
],
},
])
}),
)
it.effect("emits malformed AI SDK tool input without executing it", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const raw = '{"query":"partial'
yield* aisdk.hook.sdk((event) => {
event.sdk = {
languageModel: () =>
streamModel([
{ type: "tool-input-start", id: "call_1", toolName: "lookup" },
{ type: "tool-input-delta", id: "call_1", delta: raw },
{ type: "tool-input-end", id: "call_1" },
{ type: "tool-call", toolCallId: "call_1", toolName: "lookup", input: raw },
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage },
]),
}
})
const resolved = yield* aisdk.model(model("test-ai-sdk"))
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Lookup" })).pipe(
Effect.provide(client),
)
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
id: "call_1",
name: "lookup",
raw,
})
expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue()
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
}),
)
it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const raw = '{"query":"partial'
yield* aisdk.hook.sdk((event) => {
event.sdk = {
languageModel: () =>
streamModel([
{ type: "tool-input-start", id: "call_1", toolName: "web_search", providerExecuted: true },
{ type: "tool-input-delta", id: "call_1", delta: raw },
{ type: "tool-input-end", id: "call_1" },
{
type: "tool-call",
toolCallId: "call_1",
toolName: "web_search",
input: raw,
providerExecuted: true,
},
]),
}
})
const resolved = yield* aisdk.model(model("hosted-test-ai-sdk"))
const error = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Search" })).pipe(
Effect.provide(client),
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
}),
)