provider-package.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import { describe, expect, test } from "bun:test"
  2. import { model } from "@opencode-ai/ai/providers/openai"
  3. describe("provider package entrypoints", () => {
  4. test("semantic API aliases expose the same contract", async () => {
  5. const modules = await Promise.all([
  6. import("@opencode-ai/ai/providers/openai"),
  7. import("@opencode-ai/ai/providers/openai/responses"),
  8. import("@opencode-ai/ai/providers/openai/chat"),
  9. import("@opencode-ai/ai/providers/anthropic"),
  10. import("@opencode-ai/ai/providers/anthropic-compatible"),
  11. import("@opencode-ai/ai/providers/openai-compatible"),
  12. import("@opencode-ai/ai/providers/openai-compatible/responses"),
  13. import("@opencode-ai/ai/providers/amazon-bedrock"),
  14. import("@opencode-ai/ai/providers/azure"),
  15. import("@opencode-ai/ai/providers/azure/responses"),
  16. import("@opencode-ai/ai/providers/azure/chat"),
  17. import("@opencode-ai/ai/providers/google"),
  18. import("@opencode-ai/ai/providers/google-vertex"),
  19. import("@opencode-ai/ai/providers/google-vertex/gemini"),
  20. import("@opencode-ai/ai/providers/google-vertex/chat"),
  21. import("@opencode-ai/ai/providers/google-vertex/responses"),
  22. import("@opencode-ai/ai/providers/google-vertex/messages"),
  23. import("@opencode-ai/ai/providers/openrouter"),
  24. import("@opencode-ai/ai/providers/xai"),
  25. import("@opencode-ai/ai/providers/amazon-bedrock/mantle"),
  26. import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
  27. import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
  28. ])
  29. for (const module of modules) expect(module.model).toBeFunction()
  30. expect(modules[0].model).toBe(modules[1].model)
  31. expect(modules[8].model).toBe(modules[9].model)
  32. expect(modules[12].model).toBe(modules[13].model)
  33. expect(modules[19].model).toBe(modules[20].model)
  34. })
  35. test("maps OpenRouter and xAI package settings onto executable models", async () => {
  36. const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
  37. const XAI = await import("@opencode-ai/ai/providers/xai")
  38. const settings = {
  39. apiKey: "fixture",
  40. baseURL: "https://provider.example.test/v1",
  41. headers: { "x-application": "opencode" },
  42. body: { service_tier: "priority" },
  43. limits: { context: 200_000, output: 64_000 },
  44. }
  45. const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
  46. ...settings,
  47. providerOptions: { openrouter: { usage: true } },
  48. })
  49. const xai = XAI.model("grok-4", {
  50. ...settings,
  51. providerOptions: { xai: { reasoningEffort: "high" } },
  52. })
  53. for (const selected of [openrouter, xai]) {
  54. expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
  55. expect(selected.route.defaults.headers).toEqual(settings.headers)
  56. expect(selected.route.defaults.http?.body).toEqual(settings.body)
  57. expect(selected.route.defaults.limits).toEqual(settings.limits)
  58. }
  59. expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
  60. expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
  61. })
  62. test("maps package settings onto the executable model", () => {
  63. const selected = model("gpt-5", {
  64. apiKey: "fixture",
  65. baseURL: "https://api.openai.test/v1",
  66. headers: { "x-application": "opencode" },
  67. body: { service_tier: "priority" },
  68. limits: { context: 200_000, output: 64_000 },
  69. unrelatedInheritedSetting: true,
  70. })
  71. expect(selected.route.id).toBe("openai-responses")
  72. expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
  73. expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
  74. expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
  75. })
  76. test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
  77. const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
  78. const selected = OpenAICompatibleResponses.model("custom-model", {
  79. apiKey: "fixture",
  80. baseURL: "https://responses.example.test/v1",
  81. provider: "example",
  82. headers: { "x-application": "opencode" },
  83. body: { service_tier: "priority" },
  84. limits: { context: 200_000, output: 64_000 },
  85. providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
  86. })
  87. expect(String(selected.provider)).toBe("example")
  88. expect(selected.route.id).toBe("openai-compatible-responses")
  89. expect(selected.route.endpoint).toMatchObject({
  90. baseURL: "https://responses.example.test/v1",
  91. path: "/responses",
  92. })
  93. expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
  94. expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
  95. expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
  96. expect(selected.route.defaults.providerOptions).toEqual({
  97. openresponses: { reasoningEffort: "low", store: true },
  98. })
  99. })
  100. test("maps Anthropic-compatible settings onto the executable model", async () => {
  101. const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
  102. const selected = AnthropicCompatible.model("compatible-model", {
  103. apiKey: "fixture",
  104. baseURL: "https://messages.example.test/v1",
  105. provider: "example",
  106. headers: { "x-application": "opencode" },
  107. body: { metadata: { user_id: "user_1" } },
  108. limits: { context: 200_000, output: 64_000 },
  109. providerOptions: { anthropic: { effort: "low" } },
  110. })
  111. expect(String(selected.provider)).toBe("example")
  112. expect(selected.route.id).toBe("anthropic-messages")
  113. expect(selected.route.endpoint).toMatchObject({
  114. baseURL: "https://messages.example.test/v1",
  115. path: "/messages",
  116. })
  117. expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
  118. expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
  119. expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
  120. expect(selected.route.defaults.providerOptions).toEqual({ anthropic: { effort: "low" } })
  121. })
  122. test("maps Anthropic provider options onto the executable model", async () => {
  123. const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
  124. const selected = Anthropic.model("claude-sonnet-4-6", {
  125. apiKey: "fixture",
  126. providerOptions: { anthropic: { thinking: { type: "adaptive" } } },
  127. })
  128. expect(selected.route.defaults.providerOptions).toEqual({
  129. anthropic: { thinking: { type: "adaptive" } },
  130. })
  131. })
  132. test("requires an Anthropic-compatible base URL at runtime", async () => {
  133. const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
  134. expect(() =>
  135. Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
  136. ).toThrow("Anthropic-compatible providers require a baseURL")
  137. })
  138. test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
  139. const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
  140. const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
  141. expect(() =>
  142. Reflect.apply(AnthropicCompatible.model, undefined, [
  143. "compatible-model",
  144. {
  145. apiKey: "fixture",
  146. authToken: "token",
  147. baseURL: "https://messages.example.test/v1",
  148. },
  149. ]),
  150. ).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
  151. expect(() =>
  152. Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
  153. ).toThrow("Anthropic apiKey cannot be combined with authToken")
  154. })
  155. test("maps legacy OpenAI organization and project settings to headers", () => {
  156. const selected = model("gpt-5", {
  157. apiKey: "fixture",
  158. organization: "org_123",
  159. project: "proj_123",
  160. })
  161. expect(selected.route.defaults.headers).toMatchObject({
  162. "OpenAI-Organization": "org_123",
  163. "OpenAI-Project": "proj_123",
  164. })
  165. })
  166. test("selects Azure API entrypoints with the same model contract", async () => {
  167. const Azure = await import("@opencode-ai/ai/providers/azure")
  168. const AzureChat = await import("@opencode-ai/ai/providers/azure/chat")
  169. const AzureResponses = await import("@opencode-ai/ai/providers/azure/responses")
  170. const settings = {
  171. apiKey: "fixture",
  172. resourceName: "opencode-test",
  173. headers: { "x-application": "opencode" },
  174. body: { service_tier: "priority" },
  175. limits: { context: 200_000, output: 64_000 },
  176. }
  177. const responses = AzureResponses.model("deployment", settings)
  178. const chat = AzureChat.model("deployment", settings)
  179. expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
  180. expect(responses.route.id).toBe("azure-openai-responses")
  181. expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
  182. expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
  183. expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
  184. expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
  185. expect(chat.route.id).toBe("azure-openai-chat")
  186. })
  187. test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
  188. const Azure = await import("@opencode-ai/ai/providers/azure")
  189. const deployment = Azure.model("custom-deployment", {
  190. apiKey: "fixture",
  191. resourceName: "opencode-test",
  192. apiVersion: "2025-01-01-preview",
  193. useDeploymentBasedUrls: true,
  194. })
  195. const gateway = Azure.model("gateway-model", {
  196. apiKey: "fixture",
  197. baseURL: "https://gateway.example/azure/",
  198. })
  199. expect(deployment.route.endpoint).toMatchObject({
  200. baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
  201. query: { "api-version": "2025-01-01-preview" },
  202. })
  203. expect(gateway.route.endpoint.baseURL).toBe("https://gateway.example/azure")
  204. expect(gateway.route.endpoint.query).toBeUndefined()
  205. })
  206. test("maps Google package settings onto the Gemini model", async () => {
  207. const Google = await import("@opencode-ai/ai/providers/google")
  208. const selected = Google.model("gemini-2.5-flash", {
  209. apiKey: "fixture",
  210. baseURL: "https://generativelanguage.test/v1beta",
  211. headers: { "x-application": "opencode" },
  212. body: { safetySettings: [] },
  213. limits: { context: 1_000_000, output: 65_536 },
  214. providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
  215. })
  216. expect(selected.route.id).toBe("gemini")
  217. expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
  218. expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
  219. expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
  220. expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
  221. expect(selected.route.defaults.providerOptions).toEqual({
  222. gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
  223. })
  224. })
  225. test("selects Vertex entrypoints with the same model contract", async () => {
  226. const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
  227. const GoogleVertexGemini = await import("@opencode-ai/ai/providers/google-vertex/gemini")
  228. const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
  229. const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
  230. const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
  231. const gemini = GoogleVertex.model("gemini-3.5-flash", {
  232. apiKey: "fixture",
  233. headers: { "x-application": "opencode" },
  234. body: { safetySettings: [] },
  235. limits: { context: 1_000_000, output: 65_536 },
  236. })
  237. const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
  238. accessToken: "fixture",
  239. location: "global",
  240. project: "vertex-project",
  241. })
  242. const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", {
  243. accessToken: "fixture",
  244. location: "global",
  245. project: "vertex-project",
  246. })
  247. const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", {
  248. accessToken: "fixture",
  249. location: "global",
  250. project: "vertex-project",
  251. })
  252. expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
  253. expect(gemini.route.id).toBe("google-vertex-gemini")
  254. expect(gemini.route.protocol).toBe("gemini")
  255. expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
  256. expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
  257. expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
  258. expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
  259. expect(
  260. GoogleVertex.model("gemini-3.5-flash", {
  261. accessToken: "fixture",
  262. location: "eu",
  263. project: "vertex-project",
  264. }).route.endpoint.baseURL,
  265. ).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
  266. expect(messages.route.id).toBe("google-vertex-messages")
  267. expect(messages.route.protocol).toBe("anthropic-messages")
  268. expect(messages.route.endpoint.baseURL).toBe(
  269. "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/anthropic/models",
  270. )
  271. expect(chat.route.id).toBe("google-vertex-chat")
  272. expect(chat.route.protocol).toBe("openai-chat")
  273. expect(chat.route.endpoint).toMatchObject({
  274. baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
  275. path: "/chat/completions",
  276. })
  277. expect(responses.route.id).toBe("google-vertex-responses")
  278. expect(responses.route.protocol).toBe("open-responses")
  279. expect(responses.route.endpoint).toMatchObject({
  280. baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
  281. path: "/responses",
  282. })
  283. expect(responses.route.defaults.providerOptions).toEqual({ openresponses: { store: false } })
  284. })
  285. test("rejects conflicting Vertex auth settings at runtime", async () => {
  286. const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
  287. const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
  288. const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
  289. const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
  290. const Providers = await import("@opencode-ai/ai/providers")
  291. expect(() =>
  292. Reflect.apply(GoogleVertex.model, undefined, [
  293. "gemini-3.5-flash",
  294. { accessToken: "token", apiKey: "fixture", project: "vertex-project" },
  295. ]),
  296. ).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
  297. const configured = Reflect.apply(GoogleVertex.configure, undefined, [
  298. { accessToken: "token", auth: {}, project: "vertex-project" },
  299. ])
  300. expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
  301. expect(() =>
  302. Reflect.apply(GoogleVertexMessages.model, undefined, [
  303. "claude-sonnet-4-6",
  304. { apiKey: "fixture", project: "vertex-project" },
  305. ]),
  306. ).toThrow("Google Vertex Messages does not support API keys")
  307. expect(() =>
  308. Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
  309. { apiKey: "fixture", project: "vertex-project" },
  310. ]),
  311. ).toThrow("Google Vertex Messages does not support API keys")
  312. expect(() =>
  313. Reflect.apply(GoogleVertexChat.model, undefined, [
  314. "deepseek-ai/deepseek-v3.2-maas",
  315. { apiKey: "fixture", project: "vertex-project" },
  316. ]),
  317. ).toThrow("Google Vertex Chat does not support API keys")
  318. expect(() =>
  319. Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
  320. { apiKey: "fixture", project: "vertex-project" },
  321. ]),
  322. ).toThrow("Google Vertex Chat does not support API keys")
  323. expect(() =>
  324. Reflect.apply(GoogleVertexResponses.model, undefined, [
  325. "xai/grok-4.20-reasoning",
  326. { apiKey: "fixture", project: "vertex-project" },
  327. ]),
  328. ).toThrow("Google Vertex Responses does not support API keys")
  329. expect(() =>
  330. Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
  331. { apiKey: "fixture", project: "vertex-project" },
  332. ]),
  333. ).toThrow("Google Vertex Responses does not support API keys")
  334. })
  335. })