provider-azure.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import { AISDK } from "@opencode-ai/core/aisdk"
  2. import { describe, expect } from "bun:test"
  3. import type { LanguageModelV3 } from "@ai-sdk/provider"
  4. import { Effect } from "effect"
  5. import { Catalog } from "@opencode-ai/core/catalog"
  6. import { Model } from "@opencode-ai/core/model"
  7. import { Plugin } from "@opencode-ai/core/plugin"
  8. import { PluginHost } from "@opencode-ai/core/plugin/host"
  9. import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
  10. import { Provider } from "@opencode-ai/core/provider"
  11. import { testEffect } from "../lib/effect"
  12. import { PluginTestLayer } from "./fixture"
  13. const it = testEffect(PluginTestLayer)
  14. const addPlugin = Effect.fn(function* () {
  15. const plugin = yield* Plugin.Service
  16. const aisdk = yield* AISDK.Service
  17. const host = yield* PluginHost.make(plugin)
  18. yield* AzurePlugin.effect(host)
  19. })
  20. function required<T>(value: T | undefined): T {
  21. if (value === undefined) throw new Error("Expected value")
  22. return value
  23. }
  24. function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
  25. return Effect.acquireUseRelease(
  26. Effect.sync(() => {
  27. const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
  28. Object.entries(vars).forEach(([key, value]) => {
  29. if (value === undefined) delete process.env[key]
  30. else process.env[key] = value
  31. })
  32. return previous
  33. }),
  34. fx,
  35. (previous) =>
  36. Effect.sync(() => {
  37. Object.entries(previous).forEach(([key, value]) => {
  38. if (value === undefined) delete process.env[key]
  39. else process.env[key] = value
  40. })
  41. }),
  42. )
  43. }
  44. function fakeSelectorSdk(calls: string[]) {
  45. const make = (method: string) => (id: string) => {
  46. calls.push(`${method}:${id}`)
  47. return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
  48. }
  49. return {
  50. responses: make("responses"),
  51. messages: make("messages"),
  52. chat: make("chat"),
  53. languageModel: make("languageModel"),
  54. }
  55. }
  56. describe("AzurePlugin", () => {
  57. it.effect("resolves resourceName from env", () =>
  58. withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
  59. Effect.gen(function* () {
  60. const catalog = yield* Catalog.Service
  61. yield* catalog.transform((catalog) => {
  62. catalog.provider.update(Provider.ID.azure, (item) => {
  63. item.package = Provider.aisdk("@ai-sdk/azure")
  64. })
  65. })
  66. yield* addPlugin()
  67. expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings?.resourceName).toBe("from-env")
  68. }),
  69. ),
  70. )
  71. it.effect("resolves resourceName from the legacy env", () =>
  72. withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "legacy-resource" }, () =>
  73. Effect.gen(function* () {
  74. const catalog = yield* Catalog.Service
  75. yield* catalog.transform((catalog) => {
  76. catalog.provider.update(Provider.ID.azure, (item) => {
  77. item.package = Provider.aisdk("@ai-sdk/azure")
  78. })
  79. })
  80. yield* addPlugin()
  81. expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings?.resourceName).toBe("legacy-resource")
  82. }),
  83. ),
  84. )
  85. it.effect("expands provider and model resource URLs", () =>
  86. withEnv({ AZURE_RESOURCE_NAME: "from-env", AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "legacy-env" }, () =>
  87. Effect.gen(function* () {
  88. const catalog = yield* Catalog.Service
  89. yield* catalog.transform((catalog) => {
  90. catalog.provider.update(Provider.ID.azure, (provider) => {
  91. provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
  92. provider.settings = {
  93. baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
  94. }
  95. })
  96. catalog.model.update(Provider.ID.azure, Model.ID.make("anthropic"), (model) => {
  97. model.package = Provider.aisdk("@ai-sdk/anthropic")
  98. model.settings = {
  99. resourceName: "model-resource",
  100. baseURL: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/anthropic/v1",
  101. }
  102. })
  103. })
  104. yield* addPlugin()
  105. expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings).toMatchObject({
  106. resourceName: "from-env",
  107. baseURL: "https://from-env.cognitiveservices.azure.com/openai",
  108. })
  109. expect(
  110. required(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("anthropic"))).settings,
  111. ).toMatchObject({
  112. resourceName: "model-resource",
  113. baseURL: "https://model-resource.services.ai.azure.com/anthropic/v1",
  114. })
  115. }),
  116. ),
  117. )
  118. it.effect("keeps explicit resourceName over env and ignores other providers", () =>
  119. withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
  120. Effect.gen(function* () {
  121. const catalog = yield* Catalog.Service
  122. yield* catalog.transform((catalog) => {
  123. const azure = Provider.Info.make({
  124. ...Provider.Info.empty(Provider.ID.azure),
  125. package: Provider.aisdk("@ai-sdk/azure"),
  126. settings: { resourceName: "from-config" },
  127. })
  128. catalog.provider.update(azure.id, (item) => {
  129. item.package = azure.package
  130. item.settings = { resourceName: "from-config" }
  131. })
  132. catalog.provider.update(Provider.ID.openai, () => {})
  133. })
  134. yield* addPlugin()
  135. expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings?.resourceName).toBe("from-config")
  136. expect(required(yield* catalog.provider.get(Provider.ID.openai)).settings?.resourceName).toBeUndefined()
  137. }),
  138. ),
  139. )
  140. it.effect("falls back to env when configured resourceName is blank", () =>
  141. withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
  142. Effect.gen(function* () {
  143. const catalog = yield* Catalog.Service
  144. yield* catalog.transform((catalog) => {
  145. const azure = Provider.Info.make({
  146. ...Provider.Info.empty(Provider.ID.azure),
  147. package: Provider.aisdk("@ai-sdk/azure"),
  148. settings: { resourceName: "" },
  149. })
  150. catalog.provider.update(azure.id, (item) => {
  151. item.package = azure.package
  152. item.settings = { resourceName: "" }
  153. })
  154. })
  155. yield* addPlugin()
  156. expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings?.resourceName).toBe("from-env")
  157. }),
  158. ),
  159. )
  160. it.effect("falls back to env when configured resourceName is whitespace", () =>
  161. withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
  162. Effect.gen(function* () {
  163. const catalog = yield* Catalog.Service
  164. yield* catalog.transform((catalog) => {
  165. const azure = Provider.Info.make({
  166. ...Provider.Info.empty(Provider.ID.azure),
  167. package: Provider.aisdk("@ai-sdk/azure"),
  168. settings: { resourceName: " " },
  169. })
  170. catalog.provider.update(azure.id, (item) => {
  171. item.package = azure.package
  172. item.settings = { resourceName: " " }
  173. })
  174. })
  175. yield* addPlugin()
  176. expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings?.resourceName).toBe("from-env")
  177. }),
  178. ),
  179. )
  180. it.effect("allows configured baseURL without resourceName", () =>
  181. withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
  182. Effect.gen(function* () {
  183. const plugin = yield* Plugin.Service
  184. const aisdk = yield* AISDK.Service
  185. yield* addPlugin()
  186. const result = yield* aisdk.runSDK({
  187. model: Model.Info.make({
  188. ...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
  189. modelID: Model.ID.make("deployment"),
  190. package: Provider.aisdk("test-provider"),
  191. }),
  192. package: "@ai-sdk/azure",
  193. options: { name: "azure", baseURL: "https://proxy.example.com/openai" },
  194. })
  195. expect(result.sdk).toBeDefined()
  196. }),
  197. ),
  198. )
  199. it.effect("rejects missing resourceName when baseURL is not configured", () =>
  200. withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
  201. Effect.gen(function* () {
  202. const aisdk = yield* AISDK.Service
  203. yield* addPlugin()
  204. const exit = yield* aisdk
  205. .runSDK({
  206. model: Model.Info.make({
  207. ...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
  208. modelID: Model.ID.make("deployment"),
  209. package: Provider.aisdk("test-provider"),
  210. }),
  211. package: "@ai-sdk/azure",
  212. options: { name: "azure" },
  213. })
  214. .pipe(Effect.exit)
  215. expect(exit._tag).toBe("Failure")
  216. }),
  217. ),
  218. )
  219. it.effect("selects chat only for completion URLs", () =>
  220. Effect.gen(function* () {
  221. const plugin = yield* Plugin.Service
  222. const aisdk = yield* AISDK.Service
  223. const calls: string[] = []
  224. yield* addPlugin()
  225. yield* aisdk.runLanguage({
  226. model: Model.Info.make({
  227. ...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
  228. modelID: Model.ID.make("deployment"),
  229. package: Provider.aisdk("test-provider"),
  230. }),
  231. sdk: fakeSelectorSdk(calls),
  232. options: { useCompletionUrls: true },
  233. })
  234. expect(calls).toEqual(["chat:deployment"])
  235. }),
  236. )
  237. it.effect("selects chat from per-call useCompletionUrls", () =>
  238. Effect.gen(function* () {
  239. const plugin = yield* Plugin.Service
  240. const aisdk = yield* AISDK.Service
  241. const calls: string[] = []
  242. yield* addPlugin()
  243. yield* aisdk.runLanguage({
  244. model: Model.Info.make({
  245. ...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
  246. modelID: Model.ID.make("deployment"),
  247. package: Provider.aisdk("test-provider"),
  248. }),
  249. sdk: fakeSelectorSdk(calls),
  250. options: { useCompletionUrls: true },
  251. })
  252. expect(calls).toEqual(["chat:deployment"])
  253. }),
  254. )
  255. it.effect("ignores model useCompletionUrls when per-call option is unset", () =>
  256. Effect.gen(function* () {
  257. const plugin = yield* Plugin.Service
  258. const aisdk = yield* AISDK.Service
  259. const calls: string[] = []
  260. yield* addPlugin()
  261. yield* aisdk.runLanguage({
  262. model: Model.Info.make({
  263. ...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
  264. modelID: Model.ID.make("deployment"),
  265. package: Provider.aisdk("test-provider"),
  266. body: { useCompletionUrls: true },
  267. }),
  268. sdk: fakeSelectorSdk(calls),
  269. options: {},
  270. })
  271. expect(calls).toEqual(["responses:deployment"])
  272. }),
  273. )
  274. it.effect("uses the legacy Azure selector order and provider guard", () =>
  275. Effect.gen(function* () {
  276. const plugin = yield* Plugin.Service
  277. const aisdk = yield* AISDK.Service
  278. const calls: string[] = []
  279. yield* addPlugin()
  280. yield* aisdk.runLanguage({
  281. model: Model.Info.make({
  282. ...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
  283. modelID: Model.ID.make("deployment"),
  284. package: Provider.aisdk("test-provider"),
  285. }),
  286. sdk: fakeSelectorSdk(calls),
  287. options: {},
  288. })
  289. const ignored = yield* aisdk.runLanguage({
  290. model: Model.Info.make({
  291. ...Model.Info.default(Provider.ID.openai, Model.ID.make("deployment")),
  292. modelID: Model.ID.make("deployment"),
  293. package: Provider.aisdk("test-provider"),
  294. }),
  295. sdk: fakeSelectorSdk(calls),
  296. options: {},
  297. })
  298. expect(calls).toEqual(["responses:deployment"])
  299. expect(ignored.language).toBeUndefined()
  300. }),
  301. )
  302. it.effect("falls back through the legacy Azure selector order", () =>
  303. Effect.gen(function* () {
  304. const plugin = yield* Plugin.Service
  305. const aisdk = yield* AISDK.Service
  306. const calls: string[] = []
  307. const make = (method: string) => (id: string) => {
  308. calls.push(`${method}:${id}`)
  309. return { modelId: id, provider: method, specificationVersion: "v3" }
  310. }
  311. yield* addPlugin()
  312. yield* aisdk.runLanguage({
  313. model: Model.Info.make({
  314. ...Model.Info.default(Provider.ID.azure, Model.ID.make("messages-deployment")),
  315. modelID: Model.ID.make("messages-deployment"),
  316. package: Provider.aisdk("test-provider"),
  317. }),
  318. sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") },
  319. options: {},
  320. })
  321. yield* aisdk.runLanguage({
  322. model: Model.Info.make({
  323. ...Model.Info.default(Provider.ID.azure, Model.ID.make("language-deployment")),
  324. modelID: Model.ID.make("language-deployment"),
  325. package: Provider.aisdk("test-provider"),
  326. }),
  327. sdk: { languageModel: make("languageModel") },
  328. options: {},
  329. })
  330. expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"])
  331. }),
  332. )
  333. })