provider-azure.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { Auth } from "@opencode-ai/core/auth"
  4. import { Catalog } from "@opencode-ai/core/catalog"
  5. import { EventV2 } from "@opencode-ai/core/event"
  6. import { Location } from "@opencode-ai/core/location"
  7. import { PluginV2 } from "@opencode-ai/core/plugin"
  8. import { AccountPlugin } from "@opencode-ai/core/plugin/account"
  9. import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
  10. import { ProviderV2 } from "@opencode-ai/core/provider"
  11. import { AbsolutePath } from "@opencode-ai/core/schema"
  12. import { location } from "../fixture/location"
  13. import { testEffect } from "../lib/effect"
  14. import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
  15. const itWithAccount = testEffect(
  16. Catalog.locationLayer.pipe(
  17. Layer.provideMerge(Auth.defaultLayer),
  18. Layer.provideMerge(EventV2.defaultLayer),
  19. Layer.provideMerge(
  20. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
  21. ),
  22. Layer.provideMerge(npmLayer),
  23. ),
  24. )
  25. describe("AzurePlugin", () => {
  26. it.effect("resolves resourceName from env", () =>
  27. withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
  28. Effect.gen(function* () {
  29. const plugin = yield* PluginV2.Service
  30. const catalog = yield* Catalog.Service
  31. yield* plugin.add(AzurePlugin)
  32. const transform = yield* catalog.transform()
  33. yield* transform((catalog) => {
  34. catalog.provider.update(ProviderV2.ID.azure, (item) => {
  35. item.api = { type: "aisdk", package: "@ai-sdk/azure" }
  36. })
  37. })
  38. expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
  39. }),
  40. ),
  41. )
  42. it.effect("keeps explicit resourceName over env and ignores other providers", () =>
  43. withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
  44. Effect.gen(function* () {
  45. const plugin = yield* PluginV2.Service
  46. const catalog = yield* Catalog.Service
  47. yield* plugin.add(AzurePlugin)
  48. const transform = yield* catalog.transform()
  49. yield* transform((catalog) => {
  50. const azure = provider("azure", {
  51. api: { type: "aisdk", package: "@ai-sdk/azure" },
  52. request: { headers: {}, body: { resourceName: "from-config" } },
  53. })
  54. catalog.provider.update(azure.id, (item) => {
  55. item.api = azure.api
  56. item.request = azure.request
  57. })
  58. catalog.provider.update(ProviderV2.ID.openai, () => {})
  59. })
  60. expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config")
  61. expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined()
  62. }),
  63. ),
  64. )
  65. itWithAccount.effect("prefers account resourceName over env", () =>
  66. withEnv(
  67. {
  68. AZURE_RESOURCE_NAME: "from-env",
  69. },
  70. () =>
  71. Effect.gen(function* () {
  72. const plugin = yield* PluginV2.Service
  73. const accounts = yield* Auth.Service
  74. const catalog = yield* Catalog.Service
  75. const events = yield* EventV2.Service
  76. yield* accounts.create({
  77. serviceID: Auth.ServiceID.make("azure"),
  78. credential: new Auth.ApiKeyCredential({
  79. type: "api",
  80. key: "key",
  81. metadata: { resourceName: "from-account" },
  82. }),
  83. })
  84. yield* plugin.add({
  85. ...AccountPlugin,
  86. effect: AccountPlugin.effect.pipe(
  87. Effect.provideService(Auth.Service, accounts),
  88. Effect.provideService(Catalog.Service, catalog),
  89. Effect.provideService(EventV2.Service, events),
  90. Effect.provideService(PluginV2.Service, plugin),
  91. ),
  92. })
  93. yield* plugin.add(AzurePlugin)
  94. const transform = yield* catalog.transform()
  95. yield* transform((catalog) => {
  96. catalog.provider.update(ProviderV2.ID.azure, (item) => {
  97. item.api = { type: "aisdk", package: "@ai-sdk/azure" }
  98. })
  99. })
  100. expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-account")
  101. }),
  102. ),
  103. )
  104. it.effect("falls back to env when configured resourceName is blank", () =>
  105. withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
  106. Effect.gen(function* () {
  107. const plugin = yield* PluginV2.Service
  108. const catalog = yield* Catalog.Service
  109. yield* plugin.add(AzurePlugin)
  110. const transform = yield* catalog.transform()
  111. yield* transform((catalog) => {
  112. const azure = provider("azure", {
  113. api: { type: "aisdk", package: "@ai-sdk/azure" },
  114. request: { headers: {}, body: { resourceName: "" } },
  115. })
  116. catalog.provider.update(azure.id, (item) => {
  117. item.api = azure.api
  118. item.request = azure.request
  119. })
  120. })
  121. expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
  122. }),
  123. ),
  124. )
  125. it.effect("falls back to env when configured resourceName is whitespace", () =>
  126. withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
  127. Effect.gen(function* () {
  128. const plugin = yield* PluginV2.Service
  129. const catalog = yield* Catalog.Service
  130. yield* plugin.add(AzurePlugin)
  131. const transform = yield* catalog.transform()
  132. yield* transform((catalog) => {
  133. const azure = provider("azure", {
  134. api: { type: "aisdk", package: "@ai-sdk/azure" },
  135. request: { headers: {}, body: { resourceName: " " } },
  136. })
  137. catalog.provider.update(azure.id, (item) => {
  138. item.api = azure.api
  139. item.request = azure.request
  140. })
  141. })
  142. expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
  143. }),
  144. ),
  145. )
  146. it.effect("allows configured baseURL without resourceName", () =>
  147. withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
  148. Effect.gen(function* () {
  149. const plugin = yield* PluginV2.Service
  150. yield* plugin.add(AzurePlugin)
  151. const result = yield* plugin.trigger(
  152. "aisdk.sdk",
  153. {
  154. model: model("azure", "deployment"),
  155. package: "@ai-sdk/azure",
  156. options: { name: "azure", baseURL: "https://proxy.example.com/openai" },
  157. },
  158. {},
  159. )
  160. expect(result.sdk).toBeDefined()
  161. }),
  162. ),
  163. )
  164. it.effect("rejects missing resourceName when baseURL is not configured", () =>
  165. withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
  166. Effect.gen(function* () {
  167. const plugin = yield* PluginV2.Service
  168. yield* plugin.add(AzurePlugin)
  169. const exit = yield* plugin
  170. .trigger(
  171. "aisdk.sdk",
  172. { model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } },
  173. {},
  174. )
  175. .pipe(Effect.exit)
  176. expect(exit._tag).toBe("Failure")
  177. }),
  178. ),
  179. )
  180. it.effect("selects chat only for completion URLs", () =>
  181. Effect.gen(function* () {
  182. const plugin = yield* PluginV2.Service
  183. const calls: string[] = []
  184. yield* plugin.add(AzurePlugin)
  185. yield* plugin.trigger(
  186. "aisdk.language",
  187. { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
  188. {},
  189. )
  190. expect(calls).toEqual(["chat:deployment"])
  191. }),
  192. )
  193. it.effect("selects chat from per-call useCompletionUrls", () =>
  194. Effect.gen(function* () {
  195. const plugin = yield* PluginV2.Service
  196. const calls: string[] = []
  197. yield* plugin.add(AzurePlugin)
  198. yield* plugin.trigger(
  199. "aisdk.language",
  200. { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
  201. {},
  202. )
  203. expect(calls).toEqual(["chat:deployment"])
  204. }),
  205. )
  206. it.effect("ignores model useCompletionUrls when per-call option is unset", () =>
  207. Effect.gen(function* () {
  208. const plugin = yield* PluginV2.Service
  209. const calls: string[] = []
  210. yield* plugin.add(AzurePlugin)
  211. yield* plugin.trigger(
  212. "aisdk.language",
  213. {
  214. model: model("azure", "deployment", {
  215. request: { headers: {}, body: { useCompletionUrls: true } },
  216. }),
  217. sdk: fakeSelectorSdk(calls),
  218. options: {},
  219. },
  220. {},
  221. )
  222. expect(calls).toEqual(["responses:deployment"])
  223. }),
  224. )
  225. it.effect("uses the legacy Azure selector order and provider guard", () =>
  226. Effect.gen(function* () {
  227. const plugin = yield* PluginV2.Service
  228. const calls: string[] = []
  229. yield* plugin.add(AzurePlugin)
  230. yield* plugin.trigger(
  231. "aisdk.language",
  232. { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
  233. {},
  234. )
  235. const ignored = yield* plugin.trigger(
  236. "aisdk.language",
  237. { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
  238. {},
  239. )
  240. expect(calls).toEqual(["responses:deployment"])
  241. expect(ignored.language).toBeUndefined()
  242. }),
  243. )
  244. it.effect("falls back through the legacy Azure selector order", () =>
  245. Effect.gen(function* () {
  246. const plugin = yield* PluginV2.Service
  247. const calls: string[] = []
  248. const make = (method: string) => (id: string) => {
  249. calls.push(`${method}:${id}`)
  250. return { modelId: id, provider: method, specificationVersion: "v3" }
  251. }
  252. yield* plugin.add(AzurePlugin)
  253. yield* plugin.trigger(
  254. "aisdk.language",
  255. {
  256. model: model("azure", "messages-deployment"),
  257. sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") },
  258. options: {},
  259. },
  260. {},
  261. )
  262. yield* plugin.trigger(
  263. "aisdk.language",
  264. { model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} },
  265. {},
  266. )
  267. expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"])
  268. }),
  269. )
  270. })