aisdk.test.ts 20 KB

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