aisdk.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  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, AIError, 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("leaves max output tokens unset when the request omits them", () =>
  90. Effect.gen(function* () {
  91. const aisdk = yield* AISDK.Service
  92. yield* aisdk.hook.sdk((event) => {
  93. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  94. })
  95. const resolved = yield* aisdk.model({
  96. ...model("@openrouter/ai-sdk-provider"),
  97. limit: { context: 500_000, output: 500_000 },
  98. })
  99. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  100. expect(prepared.body.maxOutputTokens).toBeUndefined()
  101. }),
  102. )
  103. it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
  104. Effect.gen(function* () {
  105. const aisdk = yield* AISDK.Service
  106. let body: unknown
  107. yield* aisdk.hook.sdk((event) => {
  108. body = event.options.body
  109. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  110. })
  111. const resolved = yield* aisdk.model({
  112. ...model("@ai-sdk/openai"),
  113. body: { reasoning: { mode: "pro" } },
  114. })
  115. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  116. expect(body).toBeUndefined()
  117. expect(prepared.body.providerOptions).toEqual({
  118. openai: { forceReasoning: true, reasoningMode: "pro" },
  119. })
  120. }),
  121. )
  122. it.effect("maps package-specific AI SDK provider option keys", () =>
  123. Effect.gen(function* () {
  124. const aisdk = yield* AISDK.Service
  125. yield* aisdk.hook.sdk((event) => {
  126. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  127. })
  128. const cases = [
  129. ["@ai-sdk/github-copilot", "copilot", { reasoningEffort: "high" }],
  130. ["@ai-sdk/amazon-bedrock/mantle", "openai", { reasoningEffort: "high", forceReasoning: true }],
  131. ["@ai-sdk/openai-compatible", "test-provider", { reasoningEffort: "high" }],
  132. ["@jerome-benoit/sap-ai-provider-v2", "sap-ai", { reasoningEffort: "high" }],
  133. ["ai-gateway-provider", "openaiCompatible", { reasoningEffort: "high" }],
  134. ] as const
  135. for (const [packageName, key, settings] of cases) {
  136. const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" }))
  137. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  138. expect(prepared.body.providerOptions).toEqual({ [key]: settings })
  139. }
  140. }),
  141. )
  142. it.effect("forces reasoning and projects both Azure AI SDK namespaces", () =>
  143. Effect.gen(function* () {
  144. const aisdk = yield* AISDK.Service
  145. yield* aisdk.hook.sdk((event) => {
  146. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  147. })
  148. const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" }))
  149. const openaiPrepared = yield* compileRequest(LLM.request({ model: openai, prompt: "Hello" }))
  150. expect(openaiPrepared.body.providerOptions).toEqual({
  151. openai: { reasoningEffort: "high", forceReasoning: true },
  152. })
  153. const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" }))
  154. const azurePrepared = yield* compileRequest(LLM.request({ model: azure, prompt: "Hello" }))
  155. expect(azurePrepared.body.providerOptions).toEqual({
  156. openai: { reasoningEffort: "high", forceReasoning: true },
  157. azure: { reasoningEffort: "high", forceReasoning: true },
  158. })
  159. }),
  160. )
  161. it.effect("routes AI Gateway model options by upstream prefix", () =>
  162. Effect.gen(function* () {
  163. const aisdk = yield* AISDK.Service
  164. yield* aisdk.hook.sdk((event) => {
  165. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  166. })
  167. const anthropic = yield* aisdk.model({
  168. ...model("@ai-sdk/gateway", {
  169. gateway: { order: ["anthropic"] },
  170. thinking: { type: "adaptive" },
  171. }),
  172. modelID: Model.ID.make("anthropic/claude-sonnet-5"),
  173. })
  174. const anthropicPrepared = yield* compileRequest(LLM.request({ model: anthropic, prompt: "Hello" }))
  175. expect(anthropicPrepared.body.providerOptions).toEqual({
  176. gateway: { order: ["anthropic"] },
  177. anthropic: { thinking: { type: "adaptive" } },
  178. })
  179. const bedrock = yield* aisdk.model({
  180. ...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }),
  181. modelID: Model.ID.make("amazon/nova-2-lite"),
  182. })
  183. const bedrockPrepared = yield* compileRequest(LLM.request({ model: bedrock, prompt: "Hello" }))
  184. expect(bedrockPrepared.body.providerOptions).toEqual({
  185. bedrock: { reasoningConfig: { type: "enabled" } },
  186. })
  187. const fallback = yield* aisdk.model({
  188. ...model("@ai-sdk/gateway", { reasoningEffort: "high" }),
  189. modelID: Model.ID.make("deepseek/deepseek-v4"),
  190. })
  191. const fallbackPrepared = yield* compileRequest(LLM.request({ model: fallback, prompt: "Hello" }))
  192. expect(fallbackPrepared.body.providerOptions).toEqual({
  193. deepseek: { reasoningEffort: "high" },
  194. })
  195. }),
  196. )
  197. it.effect("projects replay metadata onto AI SDK prompt parts", () =>
  198. Effect.gen(function* () {
  199. const aisdk = yield* AISDK.Service
  200. yield* aisdk.hook.sdk((event) => {
  201. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  202. })
  203. const resolved = yield* aisdk.model(model("@ai-sdk/anthropic"))
  204. expect(resolved.route.providerMetadataKey).toBe("anthropic")
  205. const prepared = yield* compileRequest(
  206. LLM.request({
  207. model: resolved,
  208. messages: [
  209. Message.assistant([
  210. { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } },
  211. {
  212. type: "tool-call",
  213. id: "hosted",
  214. name: "web_search",
  215. input: { query: "Effect" },
  216. providerExecuted: true,
  217. providerMetadata: { anthropic: { blockType: "server_tool_use" } },
  218. },
  219. ]),
  220. ],
  221. }),
  222. )
  223. expect(prepared.body.prompt).toEqual([
  224. {
  225. role: "assistant",
  226. content: [
  227. {
  228. type: "reasoning",
  229. text: "Think",
  230. providerOptions: { anthropic: { signature: "signed" } },
  231. },
  232. {
  233. type: "tool-call",
  234. toolCallId: "hosted",
  235. toolName: "web_search",
  236. input: { query: "Effect" },
  237. providerExecuted: true,
  238. providerOptions: { anthropic: { blockType: "server_tool_use" } },
  239. },
  240. ],
  241. },
  242. ])
  243. }),
  244. )
  245. it.effect("emits malformed AI SDK tool input without executing it", () =>
  246. Effect.gen(function* () {
  247. const aisdk = yield* AISDK.Service
  248. const raw = '{"query":"partial'
  249. yield* aisdk.hook.sdk((event) => {
  250. event.sdk = {
  251. languageModel: () =>
  252. streamModel([
  253. { type: "tool-input-start", id: "call_1", toolName: "lookup" },
  254. { type: "tool-input-delta", id: "call_1", delta: raw },
  255. { type: "tool-input-end", id: "call_1" },
  256. { type: "tool-call", toolCallId: "call_1", toolName: "lookup", input: raw },
  257. { type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage },
  258. ]),
  259. }
  260. })
  261. const resolved = yield* aisdk.model(model("test-ai-sdk"))
  262. const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Lookup" })).pipe(
  263. Effect.provide(client),
  264. )
  265. expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
  266. id: "call_1",
  267. name: "lookup",
  268. raw,
  269. })
  270. expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue()
  271. expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
  272. expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_calls" })
  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(AIError)
  302. expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
  303. }),
  304. )