provider-google-vertex.test.ts 13 KB

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