1import * as AWS from "alchemy/AWS";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5import * as Stream from "effect/Stream";
6import {
7 LanguageModel as AiLanguageModel,
8 Tool,
9 Toolkit,
10} from "effect/ai";
11import { HttpServerRequest } from "effect/http/HttpServerRequest";
12import * as HttpServerResponse from "effect/http/HttpServerResponse";
13
14// Amazon Nova Micro through the us cross-region inference profile — cheap
15// and fast. Any conversational Bedrock model works here (Claude, Llama,
16// Mistral, ...); enable it under "Model access" in the Bedrock console.
17const MODEL = "us.amazon.nova-micro-v1:0";
18
19const GetWeather = Tool.make("get_weather", {
20 description: "Get the current weather for a city.",
21 parameters: Schema.Struct({ city: Schema.String }),
22 success: Schema.Struct({
23 city: Schema.String,
24 temperatureF: Schema.Number,
25 condition: Schema.String,
26 }),
27});
28
29const WeatherToolkit = Toolkit.make(GetWeather);
30
31const WeatherToolkitLayer = WeatherToolkit.toLayer({
32 get_weather: ({ city }) =>
33 Effect.succeed({ city, temperatureF: 72, condition: "sunny" }),
34});
35
36export default class ChatFunction extends AWS.Lambda.Function<ChatFunction>()(
37 "ChatFunction",
38 {
39 main: import.meta.url,
40 functionUrl: true,
41 timeout: Duration.seconds(60),
42 },
43 Effect.gen(function* () {
44 // Init: bind the model. This grants the Function `bedrock:InvokeModel`
45 // and `bedrock:InvokeModelWithResponseStream` scoped to exactly MODEL
46 // and returns an Effect AI `LanguageModel` Layer.
47 const model = yield* AWS.Bedrock.LanguageModel(MODEL, {
48 parameters: { maxTokens: 1024, temperature: 0.7 },
49 });
50
51 return {
52 fetch: Effect.gen(function* () {
53 const request = yield* HttpServerRequest;
54 const url = new URL(request.originalUrl);
55 const prompt = url.searchParams.get("prompt") ?? "Say hello.";
56
57 // GET /stream?prompt=... — stream the answer as SSE parts.
58 if (url.pathname === "/stream") {
59 const parts = yield* Stream.runCollect(
60 AiLanguageModel.streamText({ prompt }),
61 );
62 const sse = [...parts]
63 .map((part) => `data: ${JSON.stringify(part)}\n\n`)
64 .join("");
65 return HttpServerResponse.text(sse, {
66 headers: { "content-type": "text/event-stream" },
67 });
68 }
69
70 // GET /weather?prompt=... — let the model call a typed tool.
71 if (url.pathname === "/weather") {
72 const response = yield* AiLanguageModel.generateText({
73 prompt,
74 toolkit: WeatherToolkit,
75 }).pipe(Effect.provide(WeatherToolkitLayer));
76 return yield* HttpServerResponse.json({
77 text: response.text,
78 toolCalls: response.toolCalls.map((call) => ({
79 name: call.name,
80 params: call.params,
81 })),
82 toolResults: response.toolResults.map((result) => ({
83 name: result.name,
84 result: result.result,
85 })),
86 });
87 }
88
89 // GET /?prompt=...&temperature=0.2&maxTokens=64 — single-shot text
90 // generation. IAM access to the model is bound at deploy time, but
91 // inference parameters are a per-request decision:
92 // `withModelParameters` overrides the binding's defaults for just
93 // this call.
94 const maxTokens = url.searchParams.get("maxTokens");
95 const temperature = url.searchParams.get("temperature");
96 const response = yield* AiLanguageModel.generateText({ prompt }).pipe(
97 AWS.Bedrock.withModelParameters({
98 ...(maxTokens !== null ? { maxTokens: Number(maxTokens) } : {}),
99 ...(temperature !== null
100 ? { temperature: Number(temperature) }
101 : {}),
102 }),
103 );
104 return yield* HttpServerResponse.json({
105 text: response.text,
106 finishReason: response.finishReason,
107 usage: {
108 inputTokens: response.usage.inputTokens.total,
109 outputTokens: response.usage.outputTokens.total,
110 },
111 });
112 }).pipe(
113 Effect.catchTag("AiError", (error) =>
114 HttpServerResponse.json({ error: error.message }, { status: 500 }),
115 ),
116 Effect.provide(model),
117 Effect.orDie,
118 ),
119 };
120 }).pipe(Effect.provide(AWS.Bedrock.LanguageModelHttp)),
121) {}