1
0

aisdk.test.ts 12 KB

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