google-vertex.test.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { HttpClientRequest } from "effect/unstable/http"
  4. import { LLM } from "../../src"
  5. import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers"
  6. import { LLMClient } from "../../src/route"
  7. import { compileRequest } from "../../src/route/client"
  8. import { it } from "../lib/effect"
  9. import { dynamicResponse } from "../lib/http"
  10. import { deltaChunk, finishChunk } from "../lib/openai-chunks"
  11. import { sseEvents } from "../lib/sse"
  12. describe("Google Vertex providers", () => {
  13. it.effect("sends Gemini requests to the global Vertex endpoint", () =>
  14. Effect.gen(function* () {
  15. const response = yield* LLMClient.generate(
  16. LLM.request({
  17. model: GoogleVertex.configure({
  18. accessToken: "vertex-token",
  19. location: "global",
  20. project: "vertex-project",
  21. }).model("gemini-3.5-flash"),
  22. prompt: "Say hello.",
  23. }),
  24. ).pipe(
  25. Effect.provide(
  26. dynamicResponse((input) =>
  27. Effect.gen(function* () {
  28. const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  29. expect(request.url).toBe(
  30. "https://aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/global/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
  31. )
  32. expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
  33. expect(yield* Effect.promise(() => request.json())).toMatchObject({
  34. contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
  35. })
  36. return input.respond(
  37. sseEvents({
  38. candidates: [
  39. {
  40. content: { role: "model", parts: [{ text: "Hello." }] },
  41. finishReason: "STOP",
  42. },
  43. ],
  44. }),
  45. { headers: { "content-type": "text/event-stream" } },
  46. )
  47. }),
  48. ),
  49. ),
  50. )
  51. expect(response.text).toBe("Hello.")
  52. }),
  53. )
  54. it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
  55. Effect.gen(function* () {
  56. const model = GoogleVertexMessages.configure({
  57. accessToken: "vertex-token",
  58. location: "eu",
  59. project: "vertex-project",
  60. }).model("claude-sonnet-4-6")
  61. const response = yield* LLMClient.generate(
  62. LLM.request({
  63. model,
  64. prompt: "Say hello.",
  65. }),
  66. ).pipe(
  67. Effect.provide(
  68. dynamicResponse((input) =>
  69. Effect.gen(function* () {
  70. const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  71. expect(request.url).toBe(
  72. "https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
  73. )
  74. expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
  75. expect(request.headers.get("anthropic-version")).toBeNull()
  76. const body = yield* Effect.promise(() => request.json())
  77. expect(body).toMatchObject({
  78. anthropic_version: "vertex-2023-10-16",
  79. messages: [{ role: "user", content: [{ type: "text", text: "Say hello." }] }],
  80. stream: true,
  81. })
  82. expect(body).not.toHaveProperty("model")
  83. return input.respond(
  84. sseEvents(
  85. { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
  86. { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello." } },
  87. { type: "content_block_stop", index: 0 },
  88. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
  89. { type: "message_stop" },
  90. ),
  91. { headers: { "content-type": "text/event-stream" } },
  92. )
  93. }),
  94. ),
  95. ),
  96. )
  97. expect(model.provider).toBe("google-vertex")
  98. expect(response.text).toBe("Hello.")
  99. }),
  100. )
  101. it.effect("sends MaaS requests through Vertex Chat Completions", () =>
  102. Effect.gen(function* () {
  103. const response = yield* LLMClient.generate(
  104. LLM.request({
  105. model: GoogleVertexChat.configure({
  106. accessToken: "vertex-token",
  107. location: "global",
  108. project: "vertex-project",
  109. }).model("deepseek-ai/deepseek-v3.2-maas"),
  110. prompt: "Say hello.",
  111. }),
  112. ).pipe(
  113. Effect.provide(
  114. dynamicResponse((input) =>
  115. Effect.gen(function* () {
  116. const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  117. expect(request.url).toBe(
  118. "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/chat/completions",
  119. )
  120. expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
  121. expect(yield* Effect.promise(() => request.json())).toMatchObject({
  122. model: "deepseek-ai/deepseek-v3.2-maas",
  123. messages: [{ role: "user", content: "Say hello." }],
  124. stream: true,
  125. stream_options: { include_usage: true },
  126. })
  127. return input.respond(sseEvents(deltaChunk({ content: "Hello." }), finishChunk("stop")), {
  128. headers: { "content-type": "text/event-stream" },
  129. })
  130. }),
  131. ),
  132. ),
  133. )
  134. expect(response.text).toBe("Hello.")
  135. }),
  136. )
  137. it.effect("sends Grok requests through Vertex Responses", () =>
  138. Effect.gen(function* () {
  139. const response = yield* LLMClient.generate(
  140. LLM.request({
  141. model: GoogleVertexResponses.configure({
  142. accessToken: "vertex-token",
  143. location: "global",
  144. project: "vertex-project",
  145. }).model("xai/grok-4.20-reasoning"),
  146. prompt: "Say hello.",
  147. }),
  148. ).pipe(
  149. Effect.provide(
  150. dynamicResponse((input) =>
  151. Effect.gen(function* () {
  152. const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  153. expect(request.url).toBe(
  154. "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/responses",
  155. )
  156. expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
  157. expect(yield* Effect.promise(() => request.json())).toMatchObject({
  158. model: "xai/grok-4.20-reasoning",
  159. input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
  160. store: false,
  161. stream: true,
  162. })
  163. return input.respond(
  164. sseEvents(
  165. { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello." },
  166. { type: "response.completed", response: { id: "resp_1" } },
  167. ),
  168. { headers: { "content-type": "text/event-stream" } },
  169. )
  170. }),
  171. ),
  172. ),
  173. )
  174. expect(response.text).toBe("Hello.")
  175. }),
  176. )
  177. it.effect("routes tuned Gemini models through their deployed endpoint", () =>
  178. Effect.gen(function* () {
  179. const response = yield* LLMClient.generate(
  180. LLM.request({
  181. model: GoogleVertex.configure({
  182. accessToken: "vertex-token",
  183. location: "us-central1",
  184. project: "vertex-project",
  185. }).model("endpoints/1234567890"),
  186. prompt: "Say hello.",
  187. }),
  188. ).pipe(
  189. Effect.provide(
  190. dynamicResponse((input) =>
  191. Effect.gen(function* () {
  192. const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  193. expect(request.url).toBe(
  194. "https://us-central1-aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/us-central1/endpoints/1234567890:streamGenerateContent?alt=sse",
  195. )
  196. return input.respond(
  197. sseEvents({
  198. candidates: [
  199. {
  200. content: { role: "model", parts: [{ text: "Hello." }] },
  201. finishReason: "STOP",
  202. },
  203. ],
  204. }),
  205. { headers: { "content-type": "text/event-stream" } },
  206. )
  207. }),
  208. ),
  209. ),
  210. )
  211. expect(response.text).toBe("Hello.")
  212. }),
  213. )
  214. it.effect("rejects tuned Gemini models in express mode", () =>
  215. Effect.sync(() => {
  216. expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
  217. "Google Vertex tuned models do not support Express Mode API keys",
  218. )
  219. }),
  220. )
  221. })