provider-google-vertex.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import { describe, expect, mock } from "bun:test"
  2. import { Effect } from "effect"
  3. import { Catalog } from "@opencode-ai/core/catalog"
  4. import { PluginV2 } from "@opencode-ai/core/plugin"
  5. import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
  6. import { ProviderV2 } from "@opencode-ai/core/provider"
  7. import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
  8. const vertexOptions: Record<string, any>[] = []
  9. const googleAuthOptions: Record<string, any>[] = []
  10. void mock.module("@ai-sdk/google-vertex", () => ({
  11. createVertex: (options: Record<string, any>) => {
  12. vertexOptions.push(options)
  13. return {
  14. languageModel: (modelID: string) => ({ modelID, provider: "google-vertex", specificationVersion: "v3" }),
  15. }
  16. },
  17. }))
  18. void mock.module("google-auth-library", () => ({
  19. GoogleAuth: class {
  20. constructor(options: Record<string, any>) {
  21. googleAuthOptions.push(options)
  22. }
  23. async getClient() {
  24. return {
  25. async getAccessToken() {
  26. return { token: "vertex-token" }
  27. },
  28. }
  29. }
  30. },
  31. }))
  32. describe("GoogleVertexPlugin", () => {
  33. it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () =>
  34. Effect.gen(function* () {
  35. const plugin = yield* PluginV2.Service
  36. const catalog = yield* Catalog.Service
  37. yield* addPlugin(plugin, GoogleVertexPlugin)
  38. yield* catalog.transform((catalog) =>
  39. catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
  40. provider.api = {
  41. type: "aisdk",
  42. package: "@ai-sdk/openai-compatible",
  43. url: "https://opencode.ai/zen/v1",
  44. }
  45. }),
  46. )
  47. const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode))
  48. expect(provider.request.body).toEqual({})
  49. }),
  50. )
  51. it.effect("resolves project and location from env using legacy precedence", () =>
  52. withEnv(
  53. {
  54. GOOGLE_CLOUD_PROJECT: "google-cloud-project",
  55. GCP_PROJECT: "gcp-project",
  56. GCLOUD_PROJECT: "gcloud-project",
  57. GOOGLE_VERTEX_LOCATION: "google-vertex-location",
  58. GOOGLE_CLOUD_LOCATION: "google-cloud-location",
  59. VERTEX_LOCATION: "vertex-location",
  60. },
  61. () =>
  62. Effect.gen(function* () {
  63. const plugin = yield* PluginV2.Service
  64. const catalog = yield* Catalog.Service
  65. yield* addPlugin(plugin, GoogleVertexPlugin)
  66. yield* catalog.transform((catalog) =>
  67. catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
  68. provider.api = {
  69. type: "aisdk",
  70. package: "@ai-sdk/openai-compatible",
  71. url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
  72. }
  73. }),
  74. )
  75. const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
  76. expect(provider.request.body.project).toBe("google-cloud-project")
  77. expect(provider.request.body.location).toBe("google-vertex-location")
  78. expect(provider.api).toEqual({
  79. type: "aisdk",
  80. package: "@ai-sdk/openai-compatible",
  81. url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location",
  82. })
  83. }),
  84. ),
  85. )
  86. it.effect("resolves the advertised GOOGLE_VERTEX_PROJECT env for provider updates and SDKs", () =>
  87. withEnv(
  88. {
  89. GOOGLE_VERTEX_PROJECT: "vertex-project",
  90. GOOGLE_CLOUD_PROJECT: undefined,
  91. GCP_PROJECT: undefined,
  92. GCLOUD_PROJECT: undefined,
  93. GOOGLE_VERTEX_LOCATION: "europe-west4",
  94. GOOGLE_CLOUD_LOCATION: undefined,
  95. VERTEX_LOCATION: undefined,
  96. },
  97. () =>
  98. Effect.gen(function* () {
  99. vertexOptions.length = 0
  100. const plugin = yield* PluginV2.Service
  101. const catalog = yield* Catalog.Service
  102. yield* addPlugin(plugin, GoogleVertexPlugin)
  103. yield* catalog.transform((catalog) =>
  104. catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
  105. provider.api = {
  106. type: "aisdk",
  107. package: "@ai-sdk/openai-compatible",
  108. url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
  109. }
  110. }),
  111. )
  112. const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
  113. yield* plugin.trigger(
  114. "aisdk.sdk",
  115. {
  116. model: model("google-vertex", "gemini", {
  117. api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
  118. }),
  119. package: "@ai-sdk/google-vertex",
  120. options: { name: "google-vertex" },
  121. },
  122. {},
  123. )
  124. expect(provider.request.body.project).toBe("vertex-project")
  125. expect(provider.api).toEqual({
  126. type: "aisdk",
  127. package: "@ai-sdk/openai-compatible",
  128. url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4",
  129. })
  130. expect(vertexOptions[0].project).toBe("vertex-project")
  131. expect(vertexOptions[0].location).toBe("europe-west4")
  132. }),
  133. ),
  134. )
  135. it.effect("keeps configured project and location over env and uses global endpoint", () =>
  136. withEnv(
  137. {
  138. GOOGLE_CLOUD_PROJECT: "env-project",
  139. GCP_PROJECT: "env-gcp-project",
  140. GCLOUD_PROJECT: "env-gcloud-project",
  141. GOOGLE_VERTEX_LOCATION: "env-location",
  142. GOOGLE_CLOUD_LOCATION: "env-google-cloud-location",
  143. VERTEX_LOCATION: "env-vertex-location",
  144. },
  145. () =>
  146. Effect.gen(function* () {
  147. const plugin = yield* PluginV2.Service
  148. const catalog = yield* Catalog.Service
  149. yield* addPlugin(plugin, GoogleVertexPlugin)
  150. yield* catalog.transform((catalog) =>
  151. catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
  152. provider.api = {
  153. type: "aisdk",
  154. package: "@ai-sdk/openai-compatible",
  155. url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
  156. }
  157. provider.request.body.project = "config-project"
  158. provider.request.body.location = "global"
  159. }),
  160. )
  161. const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
  162. expect(provider.request.body.project).toBe("config-project")
  163. expect(provider.request.body.location).toBe("global")
  164. expect(provider.api).toEqual({
  165. type: "aisdk",
  166. package: "@ai-sdk/openai-compatible",
  167. url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global",
  168. })
  169. }),
  170. ),
  171. )
  172. it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
  173. Effect.gen(function* () {
  174. const plugin = yield* PluginV2.Service
  175. const catalog = yield* Catalog.Service
  176. yield* addPlugin(plugin, GoogleVertexPlugin)
  177. yield* catalog.transform((catalog) =>
  178. catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
  179. provider.api = {
  180. type: "aisdk",
  181. package: "@ai-sdk/openai-compatible",
  182. url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
  183. }
  184. provider.request.body.project = "config-project"
  185. provider.request.body.location = "eu"
  186. }),
  187. )
  188. const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
  189. expect(provider.api).toEqual({
  190. type: "aisdk",
  191. package: "@ai-sdk/openai-compatible",
  192. url: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu",
  193. })
  194. }),
  195. )
  196. it.effect("defaults location to us-central1 when only project is configured", () =>
  197. withEnv(
  198. {
  199. GOOGLE_CLOUD_PROJECT: undefined,
  200. GCP_PROJECT: undefined,
  201. GCLOUD_PROJECT: undefined,
  202. GOOGLE_VERTEX_LOCATION: undefined,
  203. GOOGLE_CLOUD_LOCATION: undefined,
  204. VERTEX_LOCATION: undefined,
  205. },
  206. () =>
  207. Effect.gen(function* () {
  208. const plugin = yield* PluginV2.Service
  209. const catalog = yield* Catalog.Service
  210. yield* addPlugin(plugin, GoogleVertexPlugin)
  211. yield* catalog.transform((catalog) =>
  212. catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
  213. provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" }
  214. provider.request.body.project = "config-project"
  215. }),
  216. )
  217. const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
  218. expect(provider.request.body.project).toBe("config-project")
  219. expect(provider.request.body.location).toBe("us-central1")
  220. }),
  221. ),
  222. )
  223. it.effect("does not pass Google auth fetch to the native Vertex SDK", () =>
  224. withEnv(
  225. {
  226. GOOGLE_CLOUD_PROJECT: "env-project",
  227. GOOGLE_VERTEX_LOCATION: "env-location",
  228. },
  229. () =>
  230. Effect.gen(function* () {
  231. vertexOptions.length = 0
  232. const plugin = yield* PluginV2.Service
  233. yield* addPlugin(plugin, GoogleVertexPlugin)
  234. yield* plugin.trigger(
  235. "aisdk.sdk",
  236. {
  237. model: model("google-vertex", "gemini", {
  238. api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
  239. }),
  240. package: "@ai-sdk/google-vertex",
  241. options: { name: "google-vertex" },
  242. },
  243. {},
  244. )
  245. expect(vertexOptions).toHaveLength(1)
  246. expect(vertexOptions[0].project).toBe("env-project")
  247. expect(vertexOptions[0].location).toBe("env-location")
  248. expect(vertexOptions[0].fetch).toBeUndefined()
  249. }),
  250. ),
  251. )
  252. it.effect("keeps Google auth fetch for OpenAI-compatible Vertex endpoints", () =>
  253. Effect.gen(function* () {
  254. googleAuthOptions.length = 0
  255. const fetchCalls: { input: Parameters<typeof fetch>[0]; init?: RequestInit }[] = []
  256. const plugin = yield* PluginV2.Service
  257. yield* addPlugin(plugin, GoogleVertexPlugin)
  258. yield* plugin.add({
  259. id: PluginV2.ID.make("capture-openai-compatible"),
  260. effect: Effect.succeed({
  261. "aisdk.sdk": (evt) =>
  262. Effect.promise(async () => {
  263. if (evt.model.providerID !== "google-vertex") return
  264. if (evt.package !== "@ai-sdk/openai-compatible") return
  265. expect(typeof evt.options.fetch).toBe("function")
  266. await evt.options.fetch("https://vertex.example", {
  267. headers: { "x-test": "1" },
  268. })
  269. }),
  270. }),
  271. })
  272. const originalFetch = fetch
  273. ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async (
  274. input: Parameters<typeof fetch>[0],
  275. init?: RequestInit,
  276. ) => {
  277. fetchCalls.push({ input, init })
  278. return new Response("ok")
  279. }) as typeof fetch
  280. yield* Effect.acquireUseRelease(
  281. Effect.void,
  282. () =>
  283. plugin.trigger(
  284. "aisdk.sdk",
  285. {
  286. model: model("google-vertex", "gemini", {
  287. api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
  288. }),
  289. package: "@ai-sdk/openai-compatible",
  290. options: { name: "google-vertex" },
  291. },
  292. {},
  293. ),
  294. () =>
  295. Effect.sync(() => {
  296. ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = originalFetch
  297. }),
  298. )
  299. expect(fetchCalls).toHaveLength(1)
  300. expect(googleAuthOptions).toEqual([{ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }])
  301. expect(fetchCalls[0].input).toBe("https://vertex.example")
  302. expect(new Headers(fetchCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token")
  303. expect(new Headers(fetchCalls[0].init?.headers).get("x-test")).toBe("1")
  304. }),
  305. )
  306. it.effect("trims model IDs before selecting language models", () =>
  307. Effect.gen(function* () {
  308. const plugin = yield* PluginV2.Service
  309. const calls: string[] = []
  310. yield* addPlugin(plugin, GoogleVertexPlugin)
  311. yield* plugin.trigger(
  312. "aisdk.language",
  313. {
  314. model: model("google-vertex", " gemini-2.5-pro "),
  315. sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
  316. options: {},
  317. },
  318. {},
  319. )
  320. expect(calls).toEqual(["languageModel:gemini-2.5-pro"])
  321. }),
  322. )
  323. })