1import * as Bedrock from "@/AWS/Bedrock";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as Stream from "effect/Stream";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14// Nova Micro via the us cross-region inference profile: the cheapest
15// on-demand conversational model in the testing account (bare
16// amazon.nova-micro-v1:0 rejects on-demand invocation).
17const MODEL = "us.amazon.nova-micro-v1:0";
18
19// Amazon's reranker — enabled in the testing account (us-west-2).
20const RERANK_MODEL = "amazon.rerank-v1:0";
21
22// CountTokens only supports a subset of models addressed by their BARE
23// foundation-model id — Nova rejects it with "The provided model doesn't
24// support counting tokens", as do cross-region inference-profile ids.
25const COUNT_TOKENS_MODEL = "anthropic.claude-haiku-4-5-20251001-v1:0";
26
27export class BedrockTestFunction extends Lambda.Function<Lambda.Function>()(
28 "BedrockTestFunction",
29) {}
30
31export default BedrockTestFunction.make(
32 {
33 main,
34 functionUrl: true,
35 // Model inference (and agent orchestration especially) regularly
36 // exceeds Lambda's 3s default timeout.
37 timeout: Duration.seconds(60),
38 },
39 Effect.gen(function* () {
40 // An agent + alias to exercise InvokeAgent end-to-end. The alias
41 // snapshots the prepared DRAFT into version 1 on create.
42 const agent = yield* Bedrock.Agent("BindingsTestAgent", {
43 foundationModel: MODEL,
44 instruction:
45 "You are a helpful assistant. Answer every question with one short sentence.",
46 // Long-term memory so GetAgentMemory / DeleteAgentMemory are callable.
47 memoryConfiguration: {
48 enabledMemoryTypes: ["SESSION_SUMMARY"],
49 storage: "30 days",
50 },
51 });
52 const alias = yield* Bedrock.AgentAlias("BindingsTestAlias", {
53 agentId: agent.agentId,
54 });
55
56 const converse = yield* Bedrock.Converse(MODEL);
57 const converseStream = yield* Bedrock.ConverseStream(MODEL);
58 const countTokens = yield* Bedrock.CountTokens(COUNT_TOKENS_MODEL);
59 const invokeAgent = yield* Bedrock.InvokeAgent(alias);
60 const getAgentMemory = yield* Bedrock.GetAgentMemory(alias);
61 const deleteAgentMemory = yield* Bedrock.DeleteAgentMemory(alias);
62 const invokeModel = yield* Bedrock.InvokeModel(MODEL);
63 const rerank = yield* Bedrock.Rerank(RERANK_MODEL);
64 const invokeModelStream =
65 yield* Bedrock.InvokeModelWithResponseStream(MODEL);
66
67 return {
68 fetch: Effect.gen(function* () {
69 const request = yield* HttpServerRequest;
70 const url = new URL(request.originalUrl);
71 const pathname = url.pathname;
72
73 // Cheap readiness route — no Bedrock call.
74 if (request.method === "GET" && pathname === "/ping") {
75 return yield* HttpServerResponse.json({ ok: true });
76 }
77
78 if (request.method === "GET" && pathname === "/count-tokens") {
79 const result = yield* countTokens({
80 input: {
81 converse: {
82 messages: [{ role: "user", content: [{ text: "Say hello." }] }],
83 },
84 },
85 });
86 return yield* HttpServerResponse.json({
87 inputTokens: result.inputTokens,
88 });
89 }
90
91 if (request.method === "GET" && pathname === "/rerank") {
92 const region = yield* Effect.sync(() => process.env.AWS_REGION);
93 const result = yield* rerank({
94 queries: [
95 { type: "TEXT", textQuery: { text: "What is alchemy?" } },
96 ],
97 sources: [
98 "alchemy is an infrastructure-as-effects framework",
99 "bananas are yellow",
100 ].map((text) => ({
101 type: "INLINE",
102 inlineDocumentSource: {
103 type: "TEXT",
104 textDocument: { text },
105 },
106 })),
107 rerankingConfiguration: {
108 type: "BEDROCK_RERANKING_MODEL",
109 bedrockRerankingConfiguration: {
110 modelConfiguration: {
111 modelArn: `arn:aws:bedrock:${region}::foundation-model/${RERANK_MODEL}`,
112 },
113 },
114 },
115 });
116 return yield* HttpServerResponse.json({
117 results: result.results.map(({ index, relevanceScore }) => ({
118 index,
119 relevanceScore,
120 })),
121 });
122 }
123
124 if (request.method === "GET" && pathname === "/invoke-agent") {
125 const sessionId = yield* Effect.sync(() => crypto.randomUUID());
126 const result = yield* invokeAgent({
127 sessionId,
128 inputText: "What is the capital of France?",
129 });
130 const events = yield* Stream.runCollect(result.completion);
131 const decoder = new TextDecoder();
132 let chunkEvents = 0;
133 let text = "";
134 for (const event of events) {
135 const bytes = event.chunk?.bytes;
136 if (bytes !== undefined) {
137 chunkEvents += 1;
138 text += decoder.decode(
139 Redacted.isRedacted(bytes) ? Redacted.value(bytes) : bytes,
140 { stream: true },
141 );
142 }
143 }
144 return yield* HttpServerResponse.json({
145 sessionId: result.sessionId,
146 chunkEvents,
147 text,
148 });
149 }
150
151 if (request.method === "GET" && pathname === "/agent-memory") {
152 // Read then clear memory for a fixed memory id. Summaries are
153 // generated asynchronously after a session ends, so on a fresh
154 // agent the read returns no contents — the route validates the
155 // IAM grants and wiring, not summary generation.
156 const memoryId = "alchemy-bindings-test-memory";
157 const memory = yield* getAgentMemory({
158 memoryType: "SESSION_SUMMARY",
159 memoryId,
160 maxItems: 10,
161 });
162 yield* deleteAgentMemory({ memoryId });
163 return yield* HttpServerResponse.json({
164 memoryContents: memory.memoryContents?.length ?? 0,
165 deleted: true,
166 });
167 }
168
169 if (request.method === "GET" && pathname === "/converse") {
170 const result = yield* converse({
171 system: [{ text: "You reply with exactly one word." }],
172 messages: [{ role: "user", content: [{ text: "Say hello." }] }],
173 inferenceConfig: { maxTokens: 64, temperature: 0 },
174 });
175 return yield* HttpServerResponse.json({
176 text: result.output.message.content
177 .map((block) => block.text ?? "")
178 .join(""),
179 stopReason: result.stopReason,
180 outputTokens: result.usage.outputTokens,
181 });
182 }
183
184 if (request.method === "GET" && pathname === "/converse-stream") {
185 const result = yield* converseStream({
186 system: [{ text: "You reply with exactly one word." }],
187 messages: [{ role: "user", content: [{ text: "Say hello." }] }],
188 inferenceConfig: { maxTokens: 64, temperature: 0 },
189 });
190 const events = yield* Stream.runCollect(
191 result.stream ?? Stream.empty,
192 );
193 let text = "";
194 let deltaEvents = 0;
195 let stopReason: string | undefined;
196 for (const event of events) {
197 if (event.contentBlockDelta?.delta.text !== undefined) {
198 text += event.contentBlockDelta.delta.text;
199 deltaEvents += 1;
200 }
201 if (event.messageStop !== undefined) {
202 stopReason = event.messageStop.stopReason;
203 }
204 }
205 return yield* HttpServerResponse.json({
206 text,
207 deltaEvents,
208 totalEvents: events.length,
209 stopReason,
210 });
211 }
212
213 if (request.method === "GET" && pathname === "/invoke-model-stream") {
214 const result = yield* invokeModelStream({
215 contentType: "application/json",
216 accept: "application/json",
217 // Raw Nova messages-v1 payload — no marshalling.
218 body: JSON.stringify({
219 system: [{ text: "You reply with exactly one word." }],
220 messages: [{ role: "user", content: [{ text: "Say hello." }] }],
221 inferenceConfig: { maxTokens: 64, temperature: 0 },
222 }),
223 });
224 const events = yield* Stream.runCollect(result.body);
225 const decoder = new TextDecoder();
226 let chunkEvents = 0;
227 let raw = "";
228 for (const event of events) {
229 const bytes = event.chunk?.bytes;
230 if (bytes !== undefined) {
231 chunkEvents += 1;
232 raw += decoder.decode(
233 Redacted.isRedacted(bytes) ? Redacted.value(bytes) : bytes,
234 { stream: true },
235 );
236 }
237 }
238 // Each chunk is one JSON event (Nova emits messageStart,
239 // contentBlockDelta, ... as separate chunks); aggregate the text
240 // deltas out of the raw concatenated JSON events.
241 const text = [...raw.matchAll(/"text"\s*:\s*"([^"]*)"/g)]
242 .map((m) => m[1])
243 .join("");
244 return yield* HttpServerResponse.json({
245 contentType: result.contentType,
246 chunkEvents,
247 text,
248 });
249 }
250
251 if (request.method === "GET" && pathname === "/invoke-model") {
252 const result = yield* invokeModel({
253 contentType: "application/json",
254 accept: "application/json",
255 // Raw Nova messages-v1 payload — InvokeModel does no marshalling.
256 body: JSON.stringify({
257 system: [{ text: "You reply with exactly one word." }],
258 messages: [{ role: "user", content: [{ text: "Say hello." }] }],
259 inferenceConfig: { maxTokens: 64, temperature: 0 },
260 }),
261 });
262 const raw = yield* Stream.mkString(Stream.decodeText(result.body));
263 const parsed = yield* Effect.try(
264 () =>
265 JSON.parse(raw) as {
266 output?: { message?: { content?: Array<{ text?: string }> } };
267 stopReason?: string;
268 },
269 );
270 return yield* HttpServerResponse.json({
271 contentType: result.contentType,
272 text: (parsed.output?.message?.content ?? [])
273 .map((block) => block.text ?? "")
274 .join(""),
275 stopReason: parsed.stopReason,
276 });
277 }
278
279 return yield* HttpServerResponse.json(
280 { error: "Not found", method: request.method, pathname },
281 { status: 404 },
282 );
283 }).pipe(Effect.orDie),
284 };
285 }).pipe(
286 Effect.provide(
287 Layer.mergeAll(
288 Bedrock.ConverseHttp,
289 Bedrock.ConverseStreamHttp,
290 Bedrock.CountTokensHttp,
291 Bedrock.DeleteAgentMemoryHttp,
292 Bedrock.GetAgentMemoryHttp,
293 Bedrock.InvokeAgentHttp,
294 Bedrock.InvokeModelHttp,
295 Bedrock.InvokeModelWithResponseStreamHttp,
296 Bedrock.RerankHttp,
297 ),
298 ),
299 ),
300);