aisdk.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. import { APICallError } from "@ai-sdk/provider"
  2. import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
  3. import { AISDK } from "@opencode-ai/core/aisdk"
  4. import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
  5. import { toSessionError } from "@opencode-ai/core/session/to-session-error"
  6. import { Model } from "@opencode-ai/core/model"
  7. import { Provider } from "@opencode-ai/core/provider"
  8. import { LLM, AIError, LLMEvent, Message, isContextOverflowFailure } from "@opencode-ai/ai"
  9. import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
  10. import { compileRequest } from "@opencode-ai/ai/route/client"
  11. import { expect } from "bun:test"
  12. import { Effect, Layer } from "effect"
  13. import { testEffect } from "./lib/effect"
  14. const it = testEffect(AISDK.locationLayer)
  15. const model = (packageName: string, settings: Record<string, unknown> = {}) =>
  16. Model.Info.make({
  17. ...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
  18. modelID: Model.ID.make("api-model"),
  19. package: Provider.aisdk(packageName),
  20. settings,
  21. limit: { context: 100, output: 20 },
  22. })
  23. const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
  24. specificationVersion: "v3",
  25. provider: "test",
  26. modelId: "test",
  27. supportedUrls: {},
  28. doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
  29. doStream: () =>
  30. Promise.resolve({
  31. stream: new ReadableStream({
  32. start(controller) {
  33. events.forEach((event) => controller.enqueue(event))
  34. controller.close()
  35. },
  36. }),
  37. }),
  38. })
  39. const usage = {
  40. inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
  41. outputTokens: { total: 1, text: 0, reasoning: 0 },
  42. } as const
  43. const client = LLMClient.layer.pipe(
  44. Layer.provide(
  45. Layer.succeed(
  46. RequestExecutor.Service,
  47. RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
  48. ),
  49. ),
  50. )
  51. it.effect("keys language models by package and flattened overlays", () =>
  52. Effect.gen(function* () {
  53. const aisdk = yield* AISDK.Service
  54. const loaded: string[] = []
  55. yield* aisdk.hook.sdk((event) => {
  56. loaded.push(event.package)
  57. event.sdk = { languageModel: () => ({ package: event.package }) }
  58. })
  59. const first = yield* aisdk.language(model("first", { region: "us-east-1" }))
  60. const second = yield* aisdk.language(model("second", { region: "us-east-1" }))
  61. const third = yield* aisdk.language(model("second", { region: "us-west-2" }))
  62. expect(first).not.toBe(second)
  63. expect(second).not.toBe(third)
  64. expect(loaded).toEqual(["first", "second", "second"])
  65. }),
  66. )
  67. it.effect("projects request settings, headers, and body overlays", () =>
  68. Effect.gen(function* () {
  69. const aisdk = yield* AISDK.Service
  70. let body: unknown
  71. yield* aisdk.hook.sdk((event) => {
  72. body = event.options.body
  73. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  74. })
  75. const input = model("@ai-sdk/google", {
  76. apiKey: "secret",
  77. thinkingConfig: { thinkingBudget: 1024 },
  78. })
  79. const resolved = yield* aisdk.model({
  80. ...input,
  81. headers: { "x-test": "header" },
  82. body: { safety_setting: "strict" },
  83. })
  84. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  85. expect(prepared.body.providerOptions).toEqual({
  86. google: { thinkingConfig: { thinkingBudget: 1024 } },
  87. })
  88. expect(prepared.body.headers).toEqual({ "x-test": "header" })
  89. expect(body).toEqual({ safety_setting: "strict" })
  90. }),
  91. )
  92. it.effect("leaves max output tokens unset when the request omits them", () =>
  93. Effect.gen(function* () {
  94. const aisdk = yield* AISDK.Service
  95. yield* aisdk.hook.sdk((event) => {
  96. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  97. })
  98. const resolved = yield* aisdk.model({
  99. ...model("@openrouter/ai-sdk-provider"),
  100. limit: { context: 500_000, output: 500_000 },
  101. })
  102. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  103. expect(prepared.body.maxOutputTokens).toBeUndefined()
  104. }),
  105. )
  106. it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
  107. Effect.gen(function* () {
  108. const aisdk = yield* AISDK.Service
  109. let body: unknown
  110. yield* aisdk.hook.sdk((event) => {
  111. body = event.options.body
  112. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  113. })
  114. const resolved = yield* aisdk.model({
  115. ...model("@ai-sdk/openai"),
  116. body: { reasoning: { mode: "pro" } },
  117. })
  118. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  119. expect(body).toBeUndefined()
  120. expect(prepared.body.providerOptions).toEqual({
  121. openai: { forceReasoning: true, reasoningMode: "pro" },
  122. })
  123. }),
  124. )
  125. it.effect("maps package-specific AI SDK provider option keys", () =>
  126. Effect.gen(function* () {
  127. const aisdk = yield* AISDK.Service
  128. yield* aisdk.hook.sdk((event) => {
  129. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  130. })
  131. const cases = [
  132. ["@ai-sdk/github-copilot", "copilot", { reasoningEffort: "high" }],
  133. ["@ai-sdk/amazon-bedrock/mantle", "openai", { reasoningEffort: "high", forceReasoning: true }],
  134. ["@ai-sdk/openai-compatible", "test-provider", { reasoningEffort: "high" }],
  135. ["@jerome-benoit/sap-ai-provider-v2", "sap-ai", { reasoningEffort: "high" }],
  136. ["ai-gateway-provider", "openaiCompatible", { reasoningEffort: "high" }],
  137. ] as const
  138. for (const [packageName, key, settings] of cases) {
  139. const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" }))
  140. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  141. expect(prepared.body.providerOptions).toEqual({ [key]: settings })
  142. }
  143. }),
  144. )
  145. it.effect("forces reasoning and projects both Azure AI SDK namespaces", () =>
  146. Effect.gen(function* () {
  147. const aisdk = yield* AISDK.Service
  148. yield* aisdk.hook.sdk((event) => {
  149. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  150. })
  151. const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" }))
  152. const openaiPrepared = yield* compileRequest(LLM.request({ model: openai, prompt: "Hello" }))
  153. expect(openaiPrepared.body.providerOptions).toEqual({
  154. openai: { reasoningEffort: "high", forceReasoning: true },
  155. })
  156. const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" }))
  157. const azurePrepared = yield* compileRequest(LLM.request({ model: azure, prompt: "Hello" }))
  158. expect(azurePrepared.body.providerOptions).toEqual({
  159. openai: { reasoningEffort: "high", forceReasoning: true },
  160. azure: { reasoningEffort: "high", forceReasoning: true },
  161. })
  162. }),
  163. )
  164. it.effect("routes AI Gateway model options by upstream prefix", () =>
  165. Effect.gen(function* () {
  166. const aisdk = yield* AISDK.Service
  167. yield* aisdk.hook.sdk((event) => {
  168. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  169. })
  170. const anthropic = yield* aisdk.model({
  171. ...model("@ai-sdk/gateway", {
  172. gateway: { order: ["anthropic"] },
  173. thinking: { type: "adaptive" },
  174. }),
  175. modelID: Model.ID.make("anthropic/claude-sonnet-5"),
  176. })
  177. const anthropicPrepared = yield* compileRequest(LLM.request({ model: anthropic, prompt: "Hello" }))
  178. expect(anthropicPrepared.body.providerOptions).toEqual({
  179. gateway: { order: ["anthropic"] },
  180. anthropic: { thinking: { type: "adaptive" } },
  181. })
  182. const bedrock = yield* aisdk.model({
  183. ...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }),
  184. modelID: Model.ID.make("amazon/nova-2-lite"),
  185. })
  186. const bedrockPrepared = yield* compileRequest(LLM.request({ model: bedrock, prompt: "Hello" }))
  187. expect(bedrockPrepared.body.providerOptions).toEqual({
  188. bedrock: { reasoningConfig: { type: "enabled" } },
  189. })
  190. const fallback = yield* aisdk.model({
  191. ...model("@ai-sdk/gateway", { reasoningEffort: "high" }),
  192. modelID: Model.ID.make("deepseek/deepseek-v4"),
  193. })
  194. const fallbackPrepared = yield* compileRequest(LLM.request({ model: fallback, prompt: "Hello" }))
  195. expect(fallbackPrepared.body.providerOptions).toEqual({
  196. deepseek: { reasoningEffort: "high" },
  197. })
  198. }),
  199. )
  200. it.effect("projects replay metadata onto AI SDK prompt parts", () =>
  201. Effect.gen(function* () {
  202. const aisdk = yield* AISDK.Service
  203. yield* aisdk.hook.sdk((event) => {
  204. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  205. })
  206. const resolved = yield* aisdk.model(model("@ai-sdk/anthropic"))
  207. expect(resolved.route.providerMetadataKey).toBe("anthropic")
  208. const prepared = yield* compileRequest(
  209. LLM.request({
  210. model: resolved,
  211. messages: [
  212. Message.assistant([
  213. { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } },
  214. {
  215. type: "tool-call",
  216. id: "hosted",
  217. name: "web_search",
  218. input: { query: "Effect" },
  219. providerExecuted: true,
  220. providerMetadata: { anthropic: { blockType: "server_tool_use" } },
  221. },
  222. ]),
  223. ],
  224. }),
  225. )
  226. expect(prepared.body.prompt).toEqual([
  227. {
  228. role: "assistant",
  229. content: [
  230. {
  231. type: "reasoning",
  232. text: "Think",
  233. providerOptions: { anthropic: { signature: "signed" } },
  234. },
  235. {
  236. type: "tool-call",
  237. toolCallId: "hosted",
  238. toolName: "web_search",
  239. input: { query: "Effect" },
  240. providerExecuted: true,
  241. providerOptions: { anthropic: { blockType: "server_tool_use" } },
  242. },
  243. ],
  244. },
  245. ])
  246. }),
  247. )
  248. it.effect("preserves tool result content in AI SDK prompts", () =>
  249. Effect.gen(function* () {
  250. const aisdk = yield* AISDK.Service
  251. yield* aisdk.hook.sdk((event) => {
  252. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  253. })
  254. const resolved = yield* aisdk.model(model("test-ai-sdk"))
  255. const prepared = yield* compileRequest(
  256. LLM.request({
  257. model: resolved,
  258. messages: [
  259. Message.tool({
  260. id: "call_1",
  261. name: "read",
  262. result: {
  263. type: "content",
  264. value: [
  265. { type: "text", text: "attachments" },
  266. { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" },
  267. {
  268. type: "file",
  269. uri: "data:application/pdf;charset=utf-8;base64,JVBERg==",
  270. mime: "application/pdf",
  271. name: "document.pdf",
  272. },
  273. { type: "file", uri: "data:audio/mpeg;base64,SUQz", mime: "audio/mpeg", name: "clip.mp3" },
  274. { type: "file", uri: "https://example.com/pixel.png", mime: "image/png" },
  275. { type: "file", uri: "https://example.com/document.pdf", mime: "application/pdf" },
  276. ],
  277. },
  278. }),
  279. ],
  280. }),
  281. )
  282. expect(prepared.body.prompt).toEqual([
  283. {
  284. role: "tool",
  285. content: [
  286. {
  287. type: "tool-result",
  288. toolCallId: "call_1",
  289. toolName: "read",
  290. output: {
  291. type: "content",
  292. value: [
  293. { type: "text", text: "attachments" },
  294. { type: "image-data", data: "AAAA", mediaType: "image/png" },
  295. {
  296. type: "file-data",
  297. data: "JVBERg==",
  298. mediaType: "application/pdf",
  299. filename: "document.pdf",
  300. },
  301. { type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
  302. { type: "image-url", url: "https://example.com/pixel.png" },
  303. { type: "file-url", url: "https://example.com/document.pdf" },
  304. ],
  305. },
  306. },
  307. ],
  308. },
  309. ])
  310. }),
  311. )
  312. it.effect("emits malformed AI SDK tool input without executing it", () =>
  313. Effect.gen(function* () {
  314. const aisdk = yield* AISDK.Service
  315. const raw = '{"query":"partial'
  316. yield* aisdk.hook.sdk((event) => {
  317. event.sdk = {
  318. languageModel: () =>
  319. streamModel([
  320. { type: "tool-input-start", id: "call_1", toolName: "lookup" },
  321. { type: "tool-input-delta", id: "call_1", delta: raw },
  322. { type: "tool-input-end", id: "call_1" },
  323. { type: "tool-call", toolCallId: "call_1", toolName: "lookup", input: raw },
  324. { type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage },
  325. ]),
  326. }
  327. })
  328. const resolved = yield* aisdk.model(model("test-ai-sdk"))
  329. const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Lookup" })).pipe(
  330. Effect.provide(client),
  331. )
  332. expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
  333. id: "call_1",
  334. name: "lookup",
  335. raw,
  336. })
  337. expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue()
  338. expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
  339. expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_calls" })
  340. }),
  341. )
  342. it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
  343. Effect.gen(function* () {
  344. const aisdk = yield* AISDK.Service
  345. const raw = '{"query":"partial'
  346. yield* aisdk.hook.sdk((event) => {
  347. event.sdk = {
  348. languageModel: () =>
  349. streamModel([
  350. { type: "tool-input-start", id: "call_1", toolName: "web_search", providerExecuted: true },
  351. { type: "tool-input-delta", id: "call_1", delta: raw },
  352. { type: "tool-input-end", id: "call_1" },
  353. {
  354. type: "tool-call",
  355. toolCallId: "call_1",
  356. toolName: "web_search",
  357. input: raw,
  358. providerExecuted: true,
  359. },
  360. ]),
  361. }
  362. })
  363. const resolved = yield* aisdk.model(model("hosted-test-ai-sdk"))
  364. const error = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Search" })).pipe(
  365. Effect.provide(client),
  366. Effect.flip,
  367. )
  368. expect(error).toBeInstanceOf(AIError)
  369. expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
  370. }),
  371. )
  372. const failingModel = (failure: unknown): LanguageModelV3 => ({
  373. specificationVersion: "v3",
  374. provider: "test",
  375. modelId: "test",
  376. supportedUrls: {},
  377. doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
  378. doStream: () => Promise.reject(failure),
  379. })
  380. const streamFailure = (failure: unknown) =>
  381. Effect.gen(function* () {
  382. const aisdk = yield* AISDK.Service
  383. yield* aisdk.hook.sdk((event) => {
  384. event.sdk = { languageModel: () => failingModel(failure) }
  385. })
  386. const resolved = yield* aisdk.model(model("test-ai-sdk"))
  387. return yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
  388. Effect.provide(client),
  389. Effect.flip,
  390. )
  391. })
  392. it.effect("preserves non-empty AI SDK error messages", () =>
  393. Effect.gen(function* () {
  394. const error = yield* streamFailure(new Error("Bad Request"))
  395. expect(error).toBeInstanceOf(AIError)
  396. expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Bad Request" })
  397. }),
  398. )
  399. const apiCallError = (input: Partial<ConstructorParameters<typeof APICallError>[0]>) =>
  400. new APICallError({
  401. message: "",
  402. url: "https://api.example.com/chat",
  403. requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
  404. responseHeaders: { authorization: "Bearer secret-token" },
  405. ...input,
  406. })
  407. it.effect("derives status and code when the AI SDK error message is empty", () =>
  408. Effect.gen(function* () {
  409. const error = yield* streamFailure(
  410. apiCallError({
  411. statusCode: 404,
  412. responseBody: '{"error":{"message":"","code":"not_found"}}',
  413. data: { error: { message: "", code: "not_found" } },
  414. }),
  415. )
  416. expect(error.reason.message).toBe("Provider request failed with HTTP 404: not_found")
  417. expect(error.reason.message).not.toContain("secret-token")
  418. expect(error.reason.message).not.toContain("private prompt")
  419. const projected = toSessionError(error)
  420. expect(projected.type).toBe("provider.invalid-request")
  421. expect(projected.status).toBe(404)
  422. expect(projected.message).not.toBe("")
  423. }),
  424. )
  425. it.effect("preserves redacted HTTP context on AI SDK call errors", () =>
  426. Effect.gen(function* () {
  427. const error = yield* streamFailure(
  428. apiCallError({
  429. statusCode: 404,
  430. responseBody: '{"error":{"message":"","code":"not_found"}}',
  431. }),
  432. )
  433. expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
  434. const http = "http" in error.reason ? error.reason.http : undefined
  435. expect(http?.request.url).toBe("https://api.example.com/chat")
  436. expect(http?.response?.status).toBe(404)
  437. expect(http?.response?.headers["authorization"]).toBe("<redacted>")
  438. expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
  439. }),
  440. )
  441. it.effect("classifies retryable AI SDK failures with retry-after details", () =>
  442. Effect.gen(function* () {
  443. const error = yield* streamFailure(
  444. apiCallError({
  445. statusCode: 429,
  446. responseHeaders: { "retry-after": "7" },
  447. }),
  448. )
  449. expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 7000 })
  450. }),
  451. )
  452. it.effect("classifies data-only AI SDK provider codes", () =>
  453. Effect.gen(function* () {
  454. const error = yield* streamFailure(
  455. apiCallError({
  456. statusCode: 400,
  457. data: { error: { code: "api_error" } },
  458. }),
  459. )
  460. expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 400 })
  461. expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
  462. }),
  463. )
  464. it.effect("classifies data-only AI SDK authentication errors", () =>
  465. Effect.gen(function* () {
  466. const error = yield* streamFailure(
  467. apiCallError({
  468. statusCode: 400,
  469. data: { error: { code: "authentication_error" } },
  470. }),
  471. )
  472. expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
  473. expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
  474. }),
  475. )
  476. it.effect("detects context overflow from data-only AI SDK errors", () =>
  477. Effect.gen(function* () {
  478. const error = yield* streamFailure(
  479. apiCallError({
  480. statusCode: 400,
  481. data: { error: { code: "context_length_exceeded" } },
  482. }),
  483. )
  484. expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
  485. expect(isContextOverflowFailure(error)).toBeTrue()
  486. }),
  487. )
  488. it.effect("retries status-less AI SDK transport failures", () =>
  489. Effect.gen(function* () {
  490. const error = yield* streamFailure(
  491. apiCallError({
  492. message: "Cannot connect to API: connection refused",
  493. isRetryable: true,
  494. }),
  495. )
  496. expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
  497. expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
  498. expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
  499. }),
  500. )
  501. it.effect("prefers a structured provider message over the code fallback", () =>
  502. Effect.gen(function* () {
  503. const error = yield* streamFailure(
  504. apiCallError({
  505. statusCode: 404,
  506. data: { error: { code: "not_found" } },
  507. responseBody: '{"message":"The requested model does not exist"}',
  508. }),
  509. )
  510. expect(error.reason.message).toBe("The requested model does not exist")
  511. }),
  512. )
  513. it.effect("falls back to the status alone for malformed response bodies", () =>
  514. Effect.gen(function* () {
  515. const error = yield* streamFailure(
  516. apiCallError({
  517. statusCode: 502,
  518. isRetryable: false,
  519. responseBody: "<html>Bad Gateway</html>",
  520. }),
  521. )
  522. expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 502 })
  523. expect(error.reason.message).toBe("Provider request failed with HTTP 502")
  524. }),
  525. )