provider-azure.test.ts 10 KB

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