mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 22:10:11 -04:00
841 lines
29 KiB
TypeScript
841 lines
29 KiB
TypeScript
import { APICallError } from "@ai-sdk/provider"
|
|
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
|
import { createMistral } from "@ai-sdk/mistral"
|
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
|
import { AISDK } from "@opencode-ai/core/aisdk"
|
|
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
|
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
|
import { Model } from "@opencode-ai/core/model"
|
|
import { Provider } from "@opencode-ai/core/provider"
|
|
import {
|
|
LLM,
|
|
AIError,
|
|
HttpContext,
|
|
LLMEvent,
|
|
Message,
|
|
RateLimitError,
|
|
TransportError,
|
|
UnknownProviderError,
|
|
isContextOverflowFailure,
|
|
} from "@opencode-ai/ai"
|
|
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
|
import { compileRequest } from "@opencode-ai/ai/route/client"
|
|
import { expect } from "bun:test"
|
|
import { Effect, Layer } from "effect"
|
|
import { testEffect } from "./lib/effect"
|
|
|
|
const it = testEffect(AISDK.locationLayer)
|
|
|
|
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
|
Model.Info.make({
|
|
...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
|
|
modelID: Model.ID.make("api-model"),
|
|
package: Provider.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* compileRequest(
|
|
LLM.request({
|
|
model: resolved,
|
|
prompt: "Hello",
|
|
providerOptions: { safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }] },
|
|
}),
|
|
)
|
|
|
|
expect(prepared.body.providerOptions).toEqual({
|
|
google: {
|
|
thinkingConfig: { thinkingBudget: 1024 },
|
|
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
|
},
|
|
})
|
|
expect(prepared.body.headers).toEqual({ "x-test": "header" })
|
|
expect(body).toEqual({ safety_setting: "strict" })
|
|
}),
|
|
)
|
|
|
|
it.effect("uses only the provider timeout signal when the request signal is null", () =>
|
|
Effect.gen(function* () {
|
|
const aisdk = yield* AISDK.Service
|
|
let wrappedFetch: typeof fetch | undefined
|
|
let requestSignal: AbortSignal | null | undefined
|
|
yield* aisdk.hook.sdk((event) => {
|
|
wrappedFetch = event.options.fetch
|
|
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
|
})
|
|
|
|
yield* aisdk.language(
|
|
model("test-ai-sdk", {
|
|
timeout: 60_000,
|
|
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
requestSignal = init?.signal
|
|
return new Response()
|
|
},
|
|
}),
|
|
)
|
|
const request = wrappedFetch
|
|
if (!request) return yield* Effect.die("Expected wrapped fetch")
|
|
yield* Effect.promise(() => request("https://example.com", { signal: null }))
|
|
|
|
expect(requestSignal).toBeInstanceOf(AbortSignal)
|
|
expect(requestSignal?.aborted).toBeFalse()
|
|
}),
|
|
)
|
|
|
|
it.effect("lowers chronological system updates to wrapped user messages", () =>
|
|
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("opaque-provider"))
|
|
const prepared = yield* compileRequest(
|
|
LLM.request({
|
|
model: resolved,
|
|
system: "Initial instructions.",
|
|
messages: [
|
|
Message.user("Before."),
|
|
Message.system("Updated <rules> & constraints."),
|
|
Message.assistant("After."),
|
|
],
|
|
}),
|
|
)
|
|
|
|
expect(prepared.body.prompt).toEqual([
|
|
{ role: "system", content: "Initial instructions." },
|
|
{ role: "user", content: [{ type: "text", text: "Before." }] },
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "<system-update>\nUpdated <rules> & constraints.\n</system-update>",
|
|
},
|
|
],
|
|
},
|
|
{ role: "assistant", content: [{ type: "text", text: "After." }] },
|
|
])
|
|
}),
|
|
)
|
|
|
|
it.effect("leaves max output tokens unset when the request omits them", () =>
|
|
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("@openrouter/ai-sdk-provider"),
|
|
limit: { context: 500_000, output: 500_000 },
|
|
})
|
|
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
|
|
|
expect(prepared.body.maxOutputTokens).toBeUndefined()
|
|
}),
|
|
)
|
|
|
|
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* compileRequest(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* compileRequest(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* compileRequest(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* compileRequest(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: Model.ID.make("anthropic/claude-sonnet-5"),
|
|
})
|
|
const anthropicPrepared = yield* compileRequest(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: Model.ID.make("amazon/nova-2-lite"),
|
|
})
|
|
const bedrockPrepared = yield* compileRequest(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: Model.ID.make("deepseek/deepseek-v4"),
|
|
})
|
|
const fallbackPrepared = yield* compileRequest(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* compileRequest(
|
|
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("moves a tool image through the real Mistral provider as a user message", () =>
|
|
Effect.gen(function* () {
|
|
const aisdk = yield* AISDK.Service
|
|
let body: { messages?: unknown[] } | undefined
|
|
const mockFetch = Object.assign(
|
|
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
body = JSON.parse(String(init?.body))
|
|
const chunks = [
|
|
{
|
|
id: "response-1",
|
|
created: 0,
|
|
model: "pixtral-large-latest",
|
|
choices: [{ index: 0, delta: { content: [{ type: "text", text: "I see it." }] } }],
|
|
},
|
|
{
|
|
id: "response-1",
|
|
created: 0,
|
|
model: "pixtral-large-latest",
|
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
},
|
|
]
|
|
return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), {
|
|
headers: { "Content-Type": "text/event-stream" },
|
|
})
|
|
},
|
|
{ preconnect: fetch.preconnect },
|
|
)
|
|
yield* aisdk.hook.sdk((event) => {
|
|
event.sdk = createMistral({ apiKey: "test", fetch: mockFetch })
|
|
})
|
|
|
|
const resolved = yield* aisdk.model({
|
|
...model("@ai-sdk/mistral"),
|
|
modelID: Model.ID.make("pixtral-large-latest"),
|
|
})
|
|
yield* LLMClient.generate(
|
|
LLM.request({
|
|
model: resolved,
|
|
messages: [
|
|
Message.user("Inspect the screenshot."),
|
|
Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
|
|
Message.tool({
|
|
type: "tool-result",
|
|
id: "call_1",
|
|
name: "screenshot",
|
|
result: {
|
|
type: "content",
|
|
value: [
|
|
{ type: "text", text: "Screenshot captured" },
|
|
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" },
|
|
],
|
|
},
|
|
}),
|
|
],
|
|
}),
|
|
).pipe(Effect.provide(client))
|
|
|
|
expect(body?.messages).toEqual([
|
|
{ role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
|
|
{
|
|
role: "assistant",
|
|
content: "",
|
|
tool_calls: [
|
|
{
|
|
id: "call_1",
|
|
type: "function",
|
|
function: { name: "screenshot", arguments: "{}" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
name: "screenshot",
|
|
tool_call_id: "call_1",
|
|
content: '[{"type":"text","text":"Screenshot captured"}]',
|
|
},
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Attached media from tool result:" },
|
|
{ type: "image_url", image_url: "data:image/png;base64,AAAA" },
|
|
],
|
|
},
|
|
])
|
|
}),
|
|
)
|
|
|
|
it.effect("does not treat SSE comment heartbeats as model progress", () =>
|
|
Effect.gen(function* () {
|
|
const aisdk = yield* AISDK.Service
|
|
const encoder = new TextEncoder()
|
|
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
const customFetch = Object.assign(
|
|
async () =>
|
|
new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
'data: {"id":"response-1","object":"chat.completion.chunk","created":0,"model":"api-model","choices":[{"index":0,"delta":{"content":"partial"},"finish_reason":null}]}\n\n',
|
|
),
|
|
)
|
|
heartbeat = setInterval(() => controller.enqueue(encoder.encode(": keepalive\n\n")), 5)
|
|
},
|
|
cancel() {
|
|
if (heartbeat) clearInterval(heartbeat)
|
|
},
|
|
}),
|
|
{ headers: { "content-type": "text/event-stream" } },
|
|
),
|
|
{ preconnect: fetch.preconnect },
|
|
)
|
|
yield* aisdk.hook.sdk((event) => {
|
|
event.sdk = createOpenAICompatible({
|
|
...event.options,
|
|
name: String(event.options.name),
|
|
baseURL: String(event.options.baseURL),
|
|
})
|
|
})
|
|
const resolved = yield* aisdk.model(
|
|
model("@ai-sdk/openai-compatible", {
|
|
apiKey: "test",
|
|
baseURL: "https://example.test/v1",
|
|
chunkTimeout: 25,
|
|
fetch: customFetch,
|
|
}),
|
|
)
|
|
const result = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
|
|
Effect.provide(client),
|
|
Effect.result,
|
|
Effect.ensuring(
|
|
Effect.sync(() => {
|
|
if (heartbeat) clearInterval(heartbeat)
|
|
}),
|
|
),
|
|
)
|
|
|
|
expect(result).toMatchObject({
|
|
_tag: "Failure",
|
|
failure: { message: expect.stringContaining("SSE read timed out") },
|
|
})
|
|
}),
|
|
)
|
|
|
|
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()
|
|
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_calls" })
|
|
}),
|
|
)
|
|
|
|
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(AIError)
|
|
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
|
|
}),
|
|
)
|
|
|
|
const failingModel = (failure: unknown): LanguageModelV3 => ({
|
|
specificationVersion: "v3",
|
|
provider: "test",
|
|
modelId: "test",
|
|
supportedUrls: {},
|
|
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
|
|
doStream: () => Promise.reject(failure),
|
|
})
|
|
|
|
const streamFailure = (failure: unknown, streamed = false) =>
|
|
Effect.gen(function* () {
|
|
const aisdk = yield* AISDK.Service
|
|
yield* aisdk.hook.sdk((event) => {
|
|
event.sdk = {
|
|
languageModel: () => (streamed ? streamModel([{ type: "error", error: failure }]) : failingModel(failure)),
|
|
}
|
|
})
|
|
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
|
return yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
|
|
Effect.provide(client),
|
|
Effect.flip,
|
|
)
|
|
})
|
|
|
|
it.effect("preserves non-empty AI SDK error messages", () =>
|
|
Effect.gen(function* () {
|
|
const cause = new Error("Bad Request")
|
|
const error = yield* streamFailure(cause)
|
|
expect(error).toBeInstanceOf(AIError)
|
|
expect(error.message).toBe("Bad Request")
|
|
expect(error.reason).toBeInstanceOf(UnknownProviderError)
|
|
expect(error.reason).toBeInstanceOf(Error)
|
|
expect(error.cause).toBe(error.reason)
|
|
expect(error.reason.cause).toBe(cause)
|
|
}),
|
|
)
|
|
|
|
Object.values({
|
|
object: { error: { message: "Provider busy", metadata: { requestId: "stream-request", retryable: true } } },
|
|
string: '{"error":"Provider busy","requestId":"stream-request"}',
|
|
}).forEach((payload) => {
|
|
it.effect(`preserves ${typeof payload} AI SDK stream error payloads in the runtime body`, () =>
|
|
Effect.gen(function* () {
|
|
const error = yield* streamFailure(payload, true)
|
|
const body = typeof payload === "string" ? payload : JSON.stringify(payload)
|
|
expect(error.reason.body).toBe(body)
|
|
expect(error.reason.cause).toBe(payload)
|
|
}),
|
|
)
|
|
})
|
|
|
|
it.effect("does not copy Error request internals into the provider body", () =>
|
|
Effect.gen(function* () {
|
|
const cause = Object.assign(new Error("Connection failed"), {
|
|
requestBodyValues: { prompt: "private prompt" },
|
|
requestHeaders: { authorization: "Bearer private-token" },
|
|
})
|
|
cause.cause = cause
|
|
const error = yield* streamFailure(cause, true)
|
|
expect(error.reason.body).toBeUndefined()
|
|
expect(error.reason.cause).toBe(cause)
|
|
expect(error.message).toBe("Connection failed")
|
|
}),
|
|
)
|
|
|
|
it.effect("preserves existing AI errors and their retry semantics unchanged", () =>
|
|
Effect.gen(function* () {
|
|
const failure = new AIError({
|
|
reason: new RateLimitError({
|
|
message: "Provider is busy",
|
|
retryAfterMs: 7000,
|
|
body: '{"error":"busy"}',
|
|
http: new HttpContext({ url: "https://api.example.com/chat", status: 429, headers: { "retry-after": "7" } }),
|
|
cause: new Error("original provider failure"),
|
|
}),
|
|
})
|
|
const error = yield* streamFailure(failure)
|
|
expect(error).toBe(failure)
|
|
expect(error.reason).toBe(failure.reason)
|
|
expect(error.reason).toBeInstanceOf(RateLimitError)
|
|
expect(error.cause).toBe(error.reason)
|
|
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
|
}),
|
|
)
|
|
|
|
const apiCallError = (input: Partial<ConstructorParameters<typeof APICallError>[0]>) =>
|
|
new APICallError({
|
|
message: "",
|
|
url: "https://api.example.com/chat",
|
|
requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
|
|
responseHeaders: { authorization: "Bearer secret-token" },
|
|
...input,
|
|
})
|
|
|
|
it.effect("derives status and code when the AI SDK error message is empty", () =>
|
|
Effect.gen(function* () {
|
|
const error = yield* streamFailure(
|
|
apiCallError({
|
|
statusCode: 404,
|
|
responseBody: '{"error":{"message":"","code":"not_found"}}',
|
|
data: { error: { message: "", code: "not_found" } },
|
|
}),
|
|
)
|
|
expect(error.message).toBe("Provider request failed with HTTP 404: not_found")
|
|
expect(error.message).not.toContain("secret-token")
|
|
expect(error.message).not.toContain("private prompt")
|
|
const projected = toSessionError(error)
|
|
expect(projected.type).toBe("provider.invalid-request")
|
|
expect(projected.status).toBe(404)
|
|
expect(projected.message).not.toBe("")
|
|
}),
|
|
)
|
|
|
|
it.effect("preserves complete HTTP context on AI SDK call errors", () =>
|
|
Effect.gen(function* () {
|
|
const cause = apiCallError({
|
|
statusCode: 404,
|
|
responseBody: '{"error":{"message":"","code":"not_found"}}',
|
|
data: { error: { code: "not_found", metadata: "parsed-only" } },
|
|
})
|
|
const error = yield* streamFailure(cause)
|
|
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
|
expect(error.reason.http).toEqual(
|
|
new HttpContext({
|
|
url: "https://api.example.com/chat",
|
|
status: 404,
|
|
headers: { authorization: "Bearer secret-token" },
|
|
}),
|
|
)
|
|
expect(error.reason.body).toBe('{"error":{"message":"","code":"not_found"}}')
|
|
expect(error.reason.cause).toBe(cause)
|
|
}),
|
|
)
|
|
|
|
it.effect("classifies retryable AI SDK failures with retry-after details", () =>
|
|
Effect.gen(function* () {
|
|
const error = yield* streamFailure(
|
|
apiCallError({
|
|
statusCode: 429,
|
|
responseHeaders: { "retry-after": "7" },
|
|
}),
|
|
)
|
|
expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 7000 })
|
|
}),
|
|
)
|
|
|
|
it.effect("classifies data-only AI SDK provider codes", () =>
|
|
Effect.gen(function* () {
|
|
const data = {
|
|
error: { code: "api_error", metadata: { requestId: "data-request", retryable: true } },
|
|
trace: { region: "test-region" },
|
|
}
|
|
const cause = apiCallError({ statusCode: 400, data })
|
|
const error = yield* streamFailure(cause)
|
|
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
|
expect(error.reason.http?.status).toBe(400)
|
|
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
|
expect(error.reason.body).toBe(JSON.stringify(data))
|
|
expect(error.reason.cause).toBe(cause)
|
|
expect(error.reason.body).not.toContain("private prompt")
|
|
}),
|
|
)
|
|
|
|
it.effect("classifies data-only AI SDK authentication errors", () =>
|
|
Effect.gen(function* () {
|
|
const error = yield* streamFailure(
|
|
apiCallError({
|
|
statusCode: 400,
|
|
data: { error: { code: "authentication_error" } },
|
|
}),
|
|
)
|
|
expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
|
|
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
|
|
}),
|
|
)
|
|
|
|
Object.entries({
|
|
json: '{"message":"Request failed"}',
|
|
malformed: "<html>Request failed</html>",
|
|
empty: "",
|
|
}).forEach(([kind, responseBody]) => {
|
|
it.effect(`classifies SDK data alongside the original ${kind} response body`, () =>
|
|
Effect.gen(function* () {
|
|
const cause = apiCallError({
|
|
message: "Request failed",
|
|
statusCode: 400,
|
|
data: { error: { code: "authentication_error" } },
|
|
responseBody,
|
|
})
|
|
const error = yield* streamFailure(cause)
|
|
expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
|
|
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
|
|
expect(error.reason.body).toBe(responseBody)
|
|
expect(error.reason.cause).toBe(cause)
|
|
}),
|
|
)
|
|
})
|
|
|
|
it.effect("detects context overflow from data-only AI SDK errors", () =>
|
|
Effect.gen(function* () {
|
|
const error = yield* streamFailure(
|
|
apiCallError({
|
|
statusCode: 400,
|
|
data: { error: { code: "context_length_exceeded" } },
|
|
}),
|
|
)
|
|
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
|
|
expect(isContextOverflowFailure(error)).toBeTrue()
|
|
}),
|
|
)
|
|
|
|
it.effect("retries status-less AI SDK transport failures", () =>
|
|
Effect.gen(function* () {
|
|
const cause = apiCallError({
|
|
message: "Cannot connect to API: connection refused",
|
|
isRetryable: true,
|
|
data: { code: "ECONNREFUSED" },
|
|
})
|
|
const error = yield* streamFailure(cause)
|
|
expect(error.reason).toMatchObject({
|
|
_tag: "Transport",
|
|
transport: "http",
|
|
operation: "request",
|
|
})
|
|
expect(error.reason).not.toHaveProperty("code")
|
|
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
|
expect(error.reason).toBeInstanceOf(TransportError)
|
|
expect(error.reason).toBeInstanceOf(Error)
|
|
expect(error.cause).toBe(error.reason)
|
|
expect(error.reason.cause).toBe(cause)
|
|
expect(error.message).toBe(cause.message)
|
|
expect(error.reason.body).toBe(JSON.stringify(cause.data))
|
|
expect(error.reason).toMatchObject({ url: "https://api.example.com/chat" })
|
|
expect(error.reason.http).toBeUndefined()
|
|
}),
|
|
)
|
|
|
|
it.effect("prefers a structured provider message over the code fallback", () =>
|
|
Effect.gen(function* () {
|
|
const error = yield* streamFailure(
|
|
apiCallError({
|
|
statusCode: 404,
|
|
data: { error: { code: "not_found" } },
|
|
responseBody: '{"message":"The requested model does not exist"}',
|
|
}),
|
|
)
|
|
expect(error.message).toBe("The requested model does not exist")
|
|
}),
|
|
)
|
|
|
|
it.effect("falls back to the status alone for malformed response bodies", () =>
|
|
Effect.gen(function* () {
|
|
const error = yield* streamFailure(
|
|
apiCallError({
|
|
statusCode: 502,
|
|
isRetryable: false,
|
|
responseBody: "<html>Bad Gateway</html>",
|
|
}),
|
|
)
|
|
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
|
expect(error.reason.http?.status).toBe(502)
|
|
expect(error.message).toBe("Provider request failed with HTTP 502")
|
|
}),
|
|
)
|