provider-package.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  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. ])
  24. for (const module of modules) expect(module.model).toBeFunction()
  25. expect(modules[0].model).toBe(modules[1].model)
  26. expect(modules[8].model).toBe(modules[9].model)
  27. expect(modules[12].model).toBe(modules[13].model)
  28. })
  29. test("maps package settings onto the executable model", () => {
  30. const selected = model("gpt-5", {
  31. apiKey: "fixture",
  32. baseURL: "https://api.openai.test/v1",
  33. headers: { "x-application": "opencode" },
  34. body: { service_tier: "priority" },
  35. limits: { context: 200_000, output: 64_000 },
  36. unrelatedInheritedSetting: true,
  37. })
  38. expect(selected.route.id).toBe("openai-responses")
  39. expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
  40. expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
  41. expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
  42. })
  43. test("selects transport without changing the semantic API", () => {
  44. expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses")
  45. expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket")
  46. })
  47. test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
  48. const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
  49. const selected = OpenAICompatibleResponses.model("custom-model", {
  50. apiKey: "fixture",
  51. baseURL: "https://responses.example.test/v1",
  52. provider: "example",
  53. headers: { "x-application": "opencode" },
  54. body: { service_tier: "priority" },
  55. limits: { context: 200_000, output: 64_000 },
  56. providerOptions: { openai: { reasoningEffort: "low", store: true } },
  57. })
  58. expect(String(selected.provider)).toBe("example")
  59. expect(selected.route.id).toBe("openai-compatible-responses")
  60. expect(selected.route.endpoint).toMatchObject({
  61. baseURL: "https://responses.example.test/v1",
  62. path: "/responses",
  63. })
  64. expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
  65. expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
  66. expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
  67. expect(selected.route.defaults.providerOptions).toEqual({
  68. openai: { reasoningEffort: "low", store: true },
  69. })
  70. })
  71. test("maps Anthropic-compatible settings onto the executable model", async () => {
  72. const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
  73. const selected = AnthropicCompatible.model("compatible-model", {
  74. apiKey: "fixture",
  75. baseURL: "https://messages.example.test/v1",
  76. provider: "example",
  77. headers: { "x-application": "opencode" },
  78. body: { metadata: { user_id: "user_1" } },
  79. limits: { context: 200_000, output: 64_000 },
  80. })
  81. expect(String(selected.provider)).toBe("example")
  82. expect(selected.route.id).toBe("anthropic-messages")
  83. expect(selected.route.endpoint).toMatchObject({
  84. baseURL: "https://messages.example.test/v1",
  85. path: "/messages",
  86. })
  87. expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
  88. expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
  89. expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
  90. })
  91. test("requires an Anthropic-compatible base URL at runtime", async () => {
  92. const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
  93. expect(() =>
  94. Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
  95. ).toThrow("Anthropic-compatible providers require a baseURL")
  96. })
  97. test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
  98. const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
  99. const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
  100. expect(() =>
  101. Reflect.apply(AnthropicCompatible.model, undefined, [
  102. "compatible-model",
  103. {
  104. apiKey: "fixture",
  105. authToken: "token",
  106. baseURL: "https://messages.example.test/v1",
  107. },
  108. ]),
  109. ).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
  110. expect(() =>
  111. Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
  112. ).toThrow("Anthropic apiKey cannot be combined with authToken")
  113. })
  114. test("maps legacy OpenAI organization and project settings to headers", () => {
  115. const selected = model("gpt-5", {
  116. apiKey: "fixture",
  117. organization: "org_123",
  118. project: "proj_123",
  119. })
  120. expect(selected.route.defaults.headers).toMatchObject({
  121. "OpenAI-Organization": "org_123",
  122. "OpenAI-Project": "proj_123",
  123. })
  124. })
  125. test("selects Azure API entrypoints with the same model contract", async () => {
  126. const Azure = await import("@opencode-ai/ai/providers/azure")
  127. const AzureChat = await import("@opencode-ai/ai/providers/azure/chat")
  128. const AzureResponses = await import("@opencode-ai/ai/providers/azure/responses")
  129. const settings = {
  130. apiKey: "fixture",
  131. resourceName: "opencode-test",
  132. headers: { "x-application": "opencode" },
  133. body: { service_tier: "priority" },
  134. limits: { context: 200_000, output: 64_000 },
  135. }
  136. const responses = AzureResponses.model("deployment", settings)
  137. const chat = AzureChat.model("deployment", settings)
  138. expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
  139. expect(responses.route.id).toBe("azure-openai-responses")
  140. expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
  141. expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
  142. expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
  143. expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
  144. expect(chat.route.id).toBe("azure-openai-chat")
  145. })
  146. test("maps Google package settings onto the Gemini model", async () => {
  147. const Google = await import("@opencode-ai/ai/providers/google")
  148. const selected = Google.model("gemini-2.5-flash", {
  149. apiKey: "fixture",
  150. baseURL: "https://generativelanguage.test/v1beta",
  151. headers: { "x-application": "opencode" },
  152. body: { safetySettings: [] },
  153. limits: { context: 1_000_000, output: 65_536 },
  154. providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
  155. })
  156. expect(selected.route.id).toBe("gemini")
  157. expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
  158. expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
  159. expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
  160. expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
  161. expect(selected.route.defaults.providerOptions).toEqual({
  162. gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
  163. })
  164. })
  165. test("selects Vertex entrypoints with the same model contract", async () => {
  166. const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
  167. const GoogleVertexGemini = await import("@opencode-ai/ai/providers/google-vertex/gemini")
  168. const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
  169. const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
  170. const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
  171. const gemini = GoogleVertex.model("gemini-3.5-flash", {
  172. apiKey: "fixture",
  173. headers: { "x-application": "opencode" },
  174. body: { safetySettings: [] },
  175. limits: { context: 1_000_000, output: 65_536 },
  176. })
  177. const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
  178. accessToken: "fixture",
  179. location: "global",
  180. project: "vertex-project",
  181. })
  182. const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", {
  183. accessToken: "fixture",
  184. location: "global",
  185. project: "vertex-project",
  186. })
  187. const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", {
  188. accessToken: "fixture",
  189. location: "global",
  190. project: "vertex-project",
  191. })
  192. expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
  193. expect(gemini.route.id).toBe("google-vertex-gemini")
  194. expect(gemini.route.protocol).toBe("gemini")
  195. expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
  196. expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
  197. expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
  198. expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
  199. expect(
  200. GoogleVertex.model("gemini-3.5-flash", {
  201. accessToken: "fixture",
  202. location: "eu",
  203. project: "vertex-project",
  204. }).route.endpoint.baseURL,
  205. ).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
  206. expect(messages.route.id).toBe("google-vertex-messages")
  207. expect(messages.route.protocol).toBe("anthropic-messages")
  208. expect(messages.route.endpoint.baseURL).toBe(
  209. "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/anthropic/models",
  210. )
  211. expect(chat.route.id).toBe("google-vertex-chat")
  212. expect(chat.route.protocol).toBe("openai-chat")
  213. expect(chat.route.endpoint).toMatchObject({
  214. baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
  215. path: "/chat/completions",
  216. })
  217. expect(responses.route.id).toBe("google-vertex-responses")
  218. expect(responses.route.protocol).toBe("openai-responses")
  219. expect(responses.route.endpoint).toMatchObject({
  220. baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
  221. path: "/responses",
  222. })
  223. expect(responses.route.defaults.providerOptions).toEqual({ openai: { store: false } })
  224. })
  225. test("rejects conflicting Vertex auth settings at runtime", async () => {
  226. const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
  227. const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
  228. const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
  229. const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
  230. const Providers = await import("@opencode-ai/ai/providers")
  231. expect(() =>
  232. Reflect.apply(GoogleVertex.model, undefined, [
  233. "gemini-3.5-flash",
  234. { accessToken: "token", apiKey: "fixture", project: "vertex-project" },
  235. ]),
  236. ).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
  237. const configured = Reflect.apply(GoogleVertex.configure, undefined, [
  238. { accessToken: "token", auth: {}, project: "vertex-project" },
  239. ])
  240. expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
  241. expect(() =>
  242. Reflect.apply(GoogleVertexMessages.model, undefined, [
  243. "claude-sonnet-4-6",
  244. { apiKey: "fixture", project: "vertex-project" },
  245. ]),
  246. ).toThrow("Google Vertex Messages does not support API keys")
  247. expect(() =>
  248. Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
  249. { apiKey: "fixture", project: "vertex-project" },
  250. ]),
  251. ).toThrow("Google Vertex Messages does not support API keys")
  252. expect(() =>
  253. Reflect.apply(GoogleVertexChat.model, undefined, [
  254. "deepseek-ai/deepseek-v3.2-maas",
  255. { apiKey: "fixture", project: "vertex-project" },
  256. ]),
  257. ).toThrow("Google Vertex Chat does not support API keys")
  258. expect(() =>
  259. Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
  260. { apiKey: "fixture", project: "vertex-project" },
  261. ]),
  262. ).toThrow("Google Vertex Chat does not support API keys")
  263. expect(() =>
  264. Reflect.apply(GoogleVertexResponses.model, undefined, [
  265. "xai/grok-4.20-reasoning",
  266. { apiKey: "fixture", project: "vertex-project" },
  267. ]),
  268. ).toThrow("Google Vertex Responses does not support API keys")
  269. expect(() =>
  270. Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
  271. { apiKey: "fixture", project: "vertex-project" },
  272. ]),
  273. ).toThrow("Google Vertex Responses does not support API keys")
  274. })
  275. })