aisdk.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
  2. import { AISDK } from "@opencode-ai/core/aisdk"
  3. import { Model } from "@opencode-ai/core/model"
  4. import { Provider } from "@opencode-ai/core/provider"
  5. import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
  6. import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
  7. import { compileRequest } from "@opencode-ai/ai/route/client"
  8. import { expect } from "bun:test"
  9. import { Effect, Layer } from "effect"
  10. import { testEffect } from "./lib/effect"
  11. const it = testEffect(AISDK.locationLayer)
  12. const model = (packageName: string, settings: Record<string, unknown> = {}) =>
  13. Model.Info.make({
  14. ...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
  15. modelID: Model.ID.make("api-model"),
  16. package: Provider.aisdk(packageName),
  17. settings,
  18. limit: { context: 100, output: 20 },
  19. })
  20. const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
  21. specificationVersion: "v3",
  22. provider: "test",
  23. modelId: "test",
  24. supportedUrls: {},
  25. doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
  26. doStream: () =>
  27. Promise.resolve({
  28. stream: new ReadableStream({
  29. start(controller) {
  30. events.forEach((event) => controller.enqueue(event))
  31. controller.close()
  32. },
  33. }),
  34. }),
  35. })
  36. const usage = {
  37. inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
  38. outputTokens: { total: 1, text: 0, reasoning: 0 },
  39. } as const
  40. const client = LLMClient.layer.pipe(
  41. Layer.provide(
  42. Layer.succeed(
  43. RequestExecutor.Service,
  44. RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
  45. ),
  46. ),
  47. )
  48. it.effect("keys language models by package and flattened overlays", () =>
  49. Effect.gen(function* () {
  50. const aisdk = yield* AISDK.Service
  51. const loaded: string[] = []
  52. yield* aisdk.hook.sdk((event) => {
  53. loaded.push(event.package)
  54. event.sdk = { languageModel: () => ({ package: event.package }) }
  55. })
  56. const first = yield* aisdk.language(model("first", { region: "us-east-1" }))
  57. const second = yield* aisdk.language(model("second", { region: "us-east-1" }))
  58. const third = yield* aisdk.language(model("second", { region: "us-west-2" }))
  59. expect(first).not.toBe(second)
  60. expect(second).not.toBe(third)
  61. expect(loaded).toEqual(["first", "second", "second"])
  62. }),
  63. )
  64. it.effect("projects request settings, headers, and body overlays", () =>
  65. Effect.gen(function* () {
  66. const aisdk = yield* AISDK.Service
  67. let body: unknown
  68. yield* aisdk.hook.sdk((event) => {
  69. body = event.options.body
  70. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  71. })
  72. const input = model("@ai-sdk/google", {
  73. apiKey: "secret",
  74. thinkingConfig: { thinkingBudget: 1024 },
  75. })
  76. const resolved = yield* aisdk.model({
  77. ...input,
  78. headers: { "x-test": "header" },
  79. body: { safety_setting: "strict" },
  80. })
  81. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  82. expect(prepared.body.providerOptions).toEqual({
  83. google: { thinkingConfig: { thinkingBudget: 1024 } },
  84. })
  85. expect(prepared.body.headers).toEqual({ "x-test": "header" })
  86. expect(body).toEqual({ safety_setting: "strict" })
  87. }),
  88. )
  89. it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
  90. Effect.gen(function* () {
  91. const aisdk = yield* AISDK.Service
  92. let body: unknown
  93. yield* aisdk.hook.sdk((event) => {
  94. body = event.options.body
  95. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  96. })
  97. const resolved = yield* aisdk.model({
  98. ...model("@ai-sdk/openai"),
  99. body: { reasoning: { mode: "pro" } },
  100. })
  101. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  102. expect(body).toBeUndefined()
  103. expect(prepared.body.providerOptions).toEqual({
  104. openai: { forceReasoning: true, reasoningMode: "pro" },
  105. })
  106. }),
  107. )
  108. it.effect("maps package-specific AI SDK provider option keys", () =>
  109. Effect.gen(function* () {
  110. const aisdk = yield* AISDK.Service
  111. yield* aisdk.hook.sdk((event) => {
  112. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  113. })
  114. const cases = [
  115. ["@ai-sdk/github-copilot", "copilot", { reasoningEffort: "high" }],
  116. ["@ai-sdk/amazon-bedrock/mantle", "openai", { reasoningEffort: "high", forceReasoning: true }],
  117. ["@ai-sdk/openai-compatible", "test-provider", { reasoningEffort: "high" }],
  118. ["@jerome-benoit/sap-ai-provider-v2", "sap-ai", { reasoningEffort: "high" }],
  119. ["ai-gateway-provider", "openaiCompatible", { reasoningEffort: "high" }],
  120. ] as const
  121. for (const [packageName, key, settings] of cases) {
  122. const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" }))
  123. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  124. expect(prepared.body.providerOptions).toEqual({ [key]: settings })
  125. }
  126. }),
  127. )
  128. it.effect("forces reasoning and projects both Azure AI SDK namespaces", () =>
  129. Effect.gen(function* () {
  130. const aisdk = yield* AISDK.Service
  131. yield* aisdk.hook.sdk((event) => {
  132. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  133. })
  134. const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" }))
  135. const openaiPrepared = yield* compileRequest(LLM.request({ model: openai, prompt: "Hello" }))
  136. expect(openaiPrepared.body.providerOptions).toEqual({
  137. openai: { reasoningEffort: "high", forceReasoning: true },
  138. })
  139. const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" }))
  140. const azurePrepared = yield* compileRequest(LLM.request({ model: azure, prompt: "Hello" }))
  141. expect(azurePrepared.body.providerOptions).toEqual({
  142. openai: { reasoningEffort: "high", forceReasoning: true },
  143. azure: { reasoningEffort: "high", forceReasoning: true },
  144. })
  145. }),
  146. )
  147. it.effect("routes AI Gateway model options by upstream prefix", () =>
  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 anthropic = yield* aisdk.model({
  154. ...model("@ai-sdk/gateway", {
  155. gateway: { order: ["anthropic"] },
  156. thinking: { type: "adaptive" },
  157. }),
  158. modelID: Model.ID.make("anthropic/claude-sonnet-5"),
  159. })
  160. const anthropicPrepared = yield* compileRequest(LLM.request({ model: anthropic, prompt: "Hello" }))
  161. expect(anthropicPrepared.body.providerOptions).toEqual({
  162. gateway: { order: ["anthropic"] },
  163. anthropic: { thinking: { type: "adaptive" } },
  164. })
  165. const bedrock = yield* aisdk.model({
  166. ...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }),
  167. modelID: Model.ID.make("amazon/nova-2-lite"),
  168. })
  169. const bedrockPrepared = yield* compileRequest(LLM.request({ model: bedrock, prompt: "Hello" }))
  170. expect(bedrockPrepared.body.providerOptions).toEqual({
  171. bedrock: { reasoningConfig: { type: "enabled" } },
  172. })
  173. const fallback = yield* aisdk.model({
  174. ...model("@ai-sdk/gateway", { reasoningEffort: "high" }),
  175. modelID: Model.ID.make("deepseek/deepseek-v4"),
  176. })
  177. const fallbackPrepared = yield* compileRequest(LLM.request({ model: fallback, prompt: "Hello" }))
  178. expect(fallbackPrepared.body.providerOptions).toEqual({
  179. deepseek: { reasoningEffort: "high" },
  180. })
  181. }),
  182. )
  183. it.effect("projects replay metadata onto AI SDK prompt parts", () =>
  184. Effect.gen(function* () {
  185. const aisdk = yield* AISDK.Service
  186. yield* aisdk.hook.sdk((event) => {
  187. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  188. })
  189. const resolved = yield* aisdk.model(model("@ai-sdk/anthropic"))
  190. expect(resolved.route.providerMetadataKey).toBe("anthropic")
  191. const prepared = yield* compileRequest(
  192. LLM.request({
  193. model: resolved,
  194. messages: [
  195. Message.assistant([
  196. { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } },
  197. {
  198. type: "tool-call",
  199. id: "hosted",
  200. name: "web_search",
  201. input: { query: "Effect" },
  202. providerExecuted: true,
  203. providerMetadata: { anthropic: { blockType: "server_tool_use" } },
  204. },
  205. ]),
  206. ],
  207. }),
  208. )
  209. expect(prepared.body.prompt).toEqual([
  210. {
  211. role: "assistant",
  212. content: [
  213. {
  214. type: "reasoning",
  215. text: "Think",
  216. providerOptions: { anthropic: { signature: "signed" } },
  217. },
  218. {
  219. type: "tool-call",
  220. toolCallId: "hosted",
  221. toolName: "web_search",
  222. input: { query: "Effect" },
  223. providerExecuted: true,
  224. providerOptions: { anthropic: { blockType: "server_tool_use" } },
  225. },
  226. ],
  227. },
  228. ])
  229. }),
  230. )
  231. it.effect("emits malformed AI SDK tool input without executing it", () =>
  232. Effect.gen(function* () {
  233. const aisdk = yield* AISDK.Service
  234. const raw = '{"query":"partial'
  235. yield* aisdk.hook.sdk((event) => {
  236. event.sdk = {
  237. languageModel: () =>
  238. streamModel([
  239. { type: "tool-input-start", id: "call_1", toolName: "lookup" },
  240. { type: "tool-input-delta", id: "call_1", delta: raw },
  241. { type: "tool-input-end", id: "call_1" },
  242. { type: "tool-call", toolCallId: "call_1", toolName: "lookup", input: raw },
  243. { type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage },
  244. ]),
  245. }
  246. })
  247. const resolved = yield* aisdk.model(model("test-ai-sdk"))
  248. const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Lookup" })).pipe(
  249. Effect.provide(client),
  250. )
  251. expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
  252. id: "call_1",
  253. name: "lookup",
  254. raw,
  255. })
  256. expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue()
  257. expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
  258. expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_calls" })
  259. }),
  260. )
  261. it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
  262. Effect.gen(function* () {
  263. const aisdk = yield* AISDK.Service
  264. const raw = '{"query":"partial'
  265. yield* aisdk.hook.sdk((event) => {
  266. event.sdk = {
  267. languageModel: () =>
  268. streamModel([
  269. { type: "tool-input-start", id: "call_1", toolName: "web_search", providerExecuted: true },
  270. { type: "tool-input-delta", id: "call_1", delta: raw },
  271. { type: "tool-input-end", id: "call_1" },
  272. {
  273. type: "tool-call",
  274. toolCallId: "call_1",
  275. toolName: "web_search",
  276. input: raw,
  277. providerExecuted: true,
  278. },
  279. ]),
  280. }
  281. })
  282. const resolved = yield* aisdk.model(model("hosted-test-ai-sdk"))
  283. const error = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Search" })).pipe(
  284. Effect.provide(client),
  285. Effect.flip,
  286. )
  287. expect(error).toBeInstanceOf(LLMError)
  288. expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
  289. }),
  290. )