provider-azure.test.ts 11 KB

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