1import * as GCP from "alchemy/GCP";
2import * as Effect from "effect/Effect";
3import { HttpServerRequest } from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5
6// Gemini 2.5 Flash on the `global` endpoint — cheap, fast, and routed to
7// any region with capacity. Any Gemini model id works here; pass
8// `{ model, location: "us-central1" }` to pin a region instead.
9const MODEL = "gemini-2.5-flash";
10
11interface ChatRequest {
12 prompt?: string;
13 system?: string;
14 temperature?: number;
15 maxOutputTokens?: number;
16}
17
18/**
19 * A public chat endpoint on Cloud Run that answers with Gemini on
20 * Vertex AI.
21 *
22 * - `GET /` — health check.
23 * - `POST /chat` — `{ prompt, system?, temperature?, maxOutputTokens? }`
24 * in, `{ text, finishReason, modelVersion, usage }` out.
25 *
26 * There is no API key anywhere: the container calls Vertex AI as its own
27 * runtime service account, which the binding grants
28 * `roles/aiplatform.user` at deploy time.
29 *
30 * `invokerIamDisabled: true` makes the service publicly reachable. Drop it
31 * and Cloud Run requires a signed Google identity token on every request —
32 * worth doing before this endpoint spends real money on strangers' prompts.
33 */
34export default class Chat extends GCP.Function<Chat>()(
35 "Chat",
36 {
37 main: import.meta.url,
38 invokerIamDisabled: true,
39 },
40 Effect.gen(function* () {
41 // Init: bind the model. Grants the runtime service account
42 // roles/aiplatform.user and returns a typed Gemini client.
43 const gemini = yield* GCP.AIPlatform.GenerateContent(MODEL);
44
45 return {
46 fetch: Effect.gen(function* () {
47 const request = yield* HttpServerRequest;
48 const url = new URL(request.originalUrl);
49
50 if (request.method === "GET" && url.pathname === "/") {
51 return HttpServerResponse.text("ok");
52 }
53
54 if (request.method !== "POST" || url.pathname !== "/chat") {
55 return yield* HttpServerResponse.json(
56 { error: "not found" },
57 { status: 404 },
58 );
59 }
60
61 const body = (yield* request.json) as ChatRequest;
62 if (!body.prompt) {
63 return yield* HttpServerResponse.json(
64 { error: "prompt is required" },
65 { status: 400 },
66 );
67 }
68
69 // Model access is bound at deploy time; generation settings are a
70 // per-request decision.
71 const response = yield* gemini.generate({
72 contents: [{ role: "user", parts: [{ text: body.prompt }] }],
73 ...(body.system
74 ? { systemInstruction: { parts: [{ text: body.system }] } }
75 : {}),
76 generationConfig: {
77 ...(body.temperature !== undefined
78 ? { temperature: body.temperature }
79 : {}),
80 ...(body.maxOutputTokens !== undefined
81 ? { maxOutputTokens: body.maxOutputTokens }
82 : {}),
83 },
84 });
85
86 const candidate = response.candidates?.[0];
87 return yield* HttpServerResponse.json({
88 // Gemini 2.5 may return "thought" parts; answer with the rest.
89 text: (candidate?.content?.parts ?? [])
90 .filter((part) => !part.thought)
91 .map((part) => part.text ?? "")
92 .join(""),
93 finishReason: candidate?.finishReason,
94 modelVersion: response.modelVersion,
95 usage: {
96 inputTokens: response.usageMetadata?.promptTokenCount,
97 outputTokens: response.usageMetadata?.candidatesTokenCount,
98 },
99 });
100 }).pipe(
101 // A blocked prompt or bad generation config is the caller's
102 // problem; surface Vertex AI's message instead of a bare 500.
103 Effect.catchTag("BadRequest", (error) =>
104 HttpServerResponse.json({ error: error.message }, { status: 400 }),
105 ),
106 Effect.orDie,
107 ),
108 };
109 }).pipe(Effect.provide(GCP.AIPlatform.GenerateContentHttp)),
110) {}