provider-package.test.ts 17 KB

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