1import * as Cloudflare from "@/Cloudflare";
2import * as Alchemy from "@/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import { AiAsyncWorker } from "./fixtures/AiAsyncWorker.ts";
10import AiBindingTestWorker from "./fixtures/AiBindingWorker.ts";
11
12// Fresh `workers.dev` URLs return non-200 (404 / 500 "Script not found") for
13// a few seconds while the edge propagates. Each test uses
14// `HttpClient.filterStatusOk` so `Effect.retry` rides through these by
15// converting the bad-status response into a retryable Effect failure.
16
17const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
18 providers: Cloudflare.providers(),
19});
20
21const logLevel = Effect.provideService(
22 MinimumLogLevel,
23 process.env.DEBUG ? "Debug" : "Info",
24);
25
26const Stack = Alchemy.Stack(
27 "AiBindingStack",
28 {
29 providers: Cloudflare.providers(),
30 state: Cloudflare.state(),
31 },
32 Effect.gen(function* () {
33 const worker = yield* AiBindingTestWorker;
34 const asyncWorker = yield* AiAsyncWorker;
35 return {
36 url: worker.url.as<string>(),
37 asyncUrl: asyncWorker.url.as<string>(),
38 };
39 }),
40);
41
42const stack = beforeAll(deploy(Stack));
43afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
44
45type StreamPart = {
46 type: string;
47 delta?: string;
48 reason?: string;
49};
50
51const parseSse = (sse: string): ReadonlyArray<StreamPart> =>
52 sse
53 .split("\n\n")
54 .map((frame) => frame.replace(/^data:\s*/, "").trim())
55 .filter((line) => line.length > 0)
56 .map((line) => JSON.parse(line) as StreamPart);
57
58test(
59 "deployed worker runs a Workers AI model via the plain AI binding",
60 Effect.gen(function* () {
61 const out = yield* stack;
62 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
63
64 const res = yield* client
65 .get(`${out.url}/run?prompt=${encodeURIComponent("Say pong.")}`)
66 .pipe(
67 Effect.retry({
68 schedule: Schedule.exponential("500 millis"),
69 times: 10,
70 }),
71 );
72 expect(res.status).toBe(200);
73
74 // Workers AI answers either the native shape (`{ response }`) or the
75 // OpenAI shape (`{ choices: [{ message: { content } }] }`).
76 const body = (yield* res.json) as {
77 response?: string;
78 choices?: Array<{ message?: { content?: string } }>;
79 };
80 const text = body.response ?? body.choices?.[0]?.message?.content;
81 expect(typeof text).toBe("string");
82 expect((text as string).length).toBeGreaterThan(0);
83 }).pipe(logLevel),
84 {
85 tags: [
86 "provider:cloudflare",
87 "provider:cloudflare:ai",
88 "provider:cloudflare:worker",
89 "live",
90 ],
91 timeout: 180_000,
92 },
93);
94
95test(
96 "deployed worker lists Workers AI models via the binding",
97 Effect.gen(function* () {
98 const out = yield* stack;
99 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
100
101 const res = yield* client.get(`${out.url}/models`).pipe(
102 Effect.retry({
103 schedule: Schedule.exponential("500 millis"),
104 times: 10,
105 }),
106 );
107 expect(res.status).toBe(200);
108
109 const body = (yield* res.json) as { count: number; names: string[] };
110 expect(body.count).toBeGreaterThan(0);
111 expect(body.names.some((name) => name.includes("llama-3.3"))).toBe(true);
112 }).pipe(logLevel),
113 {
114 tags: [
115 "provider:cloudflare",
116 "provider:cloudflare:ai",
117 "provider:cloudflare:worker",
118 "live",
119 ],
120 timeout: 180_000,
121 },
122);
123
124test(
125 "deployed worker generates text via AI-binding-backed LanguageModel",
126 Effect.gen(function* () {
127 const out = yield* stack;
128 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
129
130 const res = yield* client
131 .get(`${out.url}/generate?prompt=${encodeURIComponent("Say pong.")}`)
132 .pipe(
133 Effect.retry({
134 schedule: Schedule.exponential("500 millis"),
135 times: 10,
136 }),
137 );
138 expect(res.status).toBe(200);
139
140 const body = (yield* res.json) as {
141 text: string;
142 finishReason: string;
143 usage: {
144 inputTokens: number | undefined;
145 outputTokens: number | undefined;
146 };
147 };
148
149 expect(typeof body.text).toBe("string");
150 expect(body.text.length).toBeGreaterThan(0);
151 // Invariant shared with the gateway-backed adapter: `mapUsage` populates
152 // both counts and a normal completion maps to `stop`.
153 expect(body.usage.inputTokens).toBeGreaterThan(0);
154 expect(body.usage.outputTokens).toBeGreaterThan(0);
155 expect(body.finishReason).toBe("stop");
156 }).pipe(logLevel),
157 {
158 tags: [
159 "provider:cloudflare",
160 "provider:cloudflare:ai",
161 "provider:cloudflare:worker",
162 "live",
163 ],
164 timeout: 180_000,
165 },
166);
167
168test(
169 "async worker runs a Workers AI model via env AI binding",
170 Effect.gen(function* () {
171 const out = yield* stack;
172 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
173
174 const res = yield* client
175 .get(`${out.asyncUrl}/run?prompt=${encodeURIComponent("Say pong.")}`)
176 .pipe(
177 Effect.retry({
178 schedule: Schedule.exponential("500 millis"),
179 times: 10,
180 }),
181 );
182 expect(res.status).toBe(200);
183
184 const body = (yield* res.json) as {
185 mode: string;
186 result: {
187 response?: string;
188 choices?: Array<{ message?: { content?: string } }>;
189 };
190 };
191 expect(body.mode).toBe("async");
192 const text =
193 body.result.response ?? body.result.choices?.[0]?.message?.content;
194 expect(typeof text).toBe("string");
195 expect((text as string).length).toBeGreaterThan(0);
196 }).pipe(logLevel),
197 {
198 tags: [
199 "provider:cloudflare",
200 "provider:cloudflare:ai",
201 "provider:cloudflare:worker",
202 "live",
203 ],
204 timeout: 180_000,
205 },
206);
207
208test(
209 "async worker lists Workers AI models via env AI binding",
210 Effect.gen(function* () {
211 const out = yield* stack;
212 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
213
214 const res = yield* client.get(`${out.asyncUrl}/models`).pipe(
215 Effect.retry({
216 schedule: Schedule.exponential("500 millis"),
217 times: 10,
218 }),
219 );
220 expect(res.status).toBe(200);
221
222 const body = (yield* res.json) as {
223 mode: string;
224 count: number;
225 names: string[];
226 };
227 expect(body.mode).toBe("async");
228 expect(body.count).toBeGreaterThan(0);
229 expect(body.names.some((name) => name.includes("llama-3.3"))).toBe(true);
230 }).pipe(logLevel),
231 {
232 tags: [
233 "provider:cloudflare",
234 "provider:cloudflare:ai",
235 "provider:cloudflare:worker",
236 "live",
237 ],
238 timeout: 180_000,
239 },
240);
241
242test(
243 "deployed worker streams text via AI-binding-backed LanguageModel",
244 Effect.gen(function* () {
245 const out = yield* stack;
246 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
247
248 // A cold or transient call can answer 200 with an empty SSE (no tokens
249 // streamed), which `filterStatusOk` does not catch. Fold the parse into
250 // the retried effect so the same backoff rides out the blip.
251 const parts = yield* client
252 .get(`${out.url}/stream?prompt=${encodeURIComponent("Say pong.")}`)
253 .pipe(
254 Effect.flatMap((res) => res.text),
255 Effect.map(parseSse),
256 Effect.flatMap((parts) =>
257 parts.some((p) => p.type === "text-delta") &&
258 parts.some((p) => p.type === "finish")
259 ? Effect.succeed(parts)
260 : Effect.fail(new Error("AI stream not ready: empty/unfinished")),
261 ),
262 Effect.retry({
263 schedule: Schedule.exponential("500 millis"),
264 times: 10,
265 }),
266 );
267
268 const text = parts
269 .filter((p) => p.type === "text-delta")
270 .map((p) => p.delta ?? "")
271 .join("");
272 const finish = parts.find((p) => p.type === "finish");
273
274 expect(parts.length).toBeGreaterThan(0);
275 expect(text.length).toBeGreaterThan(0);
276 expect(finish).toBeDefined();
277 }).pipe(logLevel),
278 {
279 tags: [
280 "provider:cloudflare",
281 "provider:cloudflare:ai",
282 "provider:cloudflare:worker",
283 "live",
284 ],
285 timeout: 180_000,
286 },
287);