google-vertex.test.ts 9.4 KB

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