1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Alchemy from "@/index.ts";
4import * as Provider from "@/Provider";
5import { State } from "@/State";
6import * as Test from "@/Test/Alchemy";
7import * as aiGateway from "@distilled.cloud/cloudflare/ai-gateway";
8import { expect } from "alchemy-test";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import * as HttpClient from "effect/http/HttpClient";
14import { Gateway } from "./fixtures/Gateway.ts";
15import TestWorker from "./fixtures/TestWorker.ts";
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
26test.provider(
27 "create and delete ai gateway with default props",
28 (stack) =>
29 Effect.gen(function* () {
30 const { accountId } = yield* yield* CloudflareEnvironment;
31
32 yield* stack.destroy();
33
34 const gateway = yield* stack.deploy(
35 Effect.gen(function* () {
36 return yield* Cloudflare.AI.Gateway("DefaultGateway", {
37 id: "alchemy-test-ai-gateway-default",
38 });
39 }),
40 );
41
42 expect(gateway.gatewayId).toEqual("alchemy-test-ai-gateway-default");
43 expect(gateway.cacheInvalidateOnUpdate).toEqual(false);
44 expect(gateway.cacheTtl).toEqual(null);
45 expect(gateway.collectLogs).toEqual(true);
46 expect(gateway.rateLimitingInterval).toEqual(null);
47 expect(gateway.rateLimitingLimit).toEqual(null);
48 expect(gateway.rateLimitingTechnique).toEqual("fixed");
49
50 const actualGateway = yield* aiGateway.getAiGateway({
51 accountId,
52 id: gateway.gatewayId,
53 });
54 expect(actualGateway.id).toEqual(gateway.gatewayId);
55
56 yield* stack.destroy();
57
58 yield* waitForGatewayToBeDeleted(gateway.gatewayId, accountId);
59 }).pipe(logLevel),
60 {
61 tags: [
62 "provider:cloudflare",
63 "provider:cloudflare:ai",
64 "provider:cloudflare:worker",
65 "live",
66 ],
67 },
68);
69
70test.provider(
71 "create, update, delete ai gateway",
72 (stack) =>
73 Effect.gen(function* () {
74 const { accountId } = yield* yield* CloudflareEnvironment;
75
76 yield* stack.destroy();
77
78 const gateway = yield* stack.deploy(
79 Effect.gen(function* () {
80 return yield* Cloudflare.AI.Gateway("TestGateway", {
81 id: "alchemy-test-ai-gateway",
82 cacheTtl: 60,
83 collectLogs: true,
84 rateLimitingInterval: 60,
85 rateLimitingLimit: 100,
86 rateLimitingTechnique: "fixed",
87 });
88 }),
89 );
90
91 const actualGateway = yield* aiGateway.getAiGateway({
92 accountId,
93 id: gateway.gatewayId,
94 });
95 expect(actualGateway.id).toEqual(gateway.gatewayId);
96 expect(actualGateway.cacheTtl).toEqual(60);
97 expect(actualGateway.rateLimitingLimit).toEqual(100);
98
99 const updatedGateway = yield* stack.deploy(
100 Effect.gen(function* () {
101 return yield* Cloudflare.AI.Gateway("TestGateway", {
102 id: "alchemy-test-ai-gateway",
103 cacheTtl: 120,
104 collectLogs: true,
105 rateLimitingInterval: 120,
106 rateLimitingLimit: 200,
107 rateLimitingTechnique: "sliding",
108 });
109 }),
110 );
111
112 const actualUpdatedGateway = yield* aiGateway.getAiGateway({
113 accountId,
114 id: updatedGateway.gatewayId,
115 });
116 expect(actualUpdatedGateway.cacheTtl).toEqual(120);
117 expect(actualUpdatedGateway.rateLimitingInterval).toEqual(120);
118 expect(actualUpdatedGateway.rateLimitingLimit).toEqual(200);
119 expect(actualUpdatedGateway.rateLimitingTechnique).toEqual("sliding");
120
121 yield* stack.destroy();
122
123 yield* waitForGatewayToBeDeleted(gateway.gatewayId, accountId);
124 }).pipe(logLevel),
125 {
126 tags: [
127 "provider:cloudflare",
128 "provider:cloudflare:ai",
129 "provider:cloudflare:worker",
130 "live",
131 ],
132 },
133);
134
135// Per-gateway spend limits replace the deprecated account-level spending
136// limit. Verify they are applied on create, mutate in place, and that a
137// redeploy of identical props is a no-op (exercising the diff normalization
138// that drops the server-assigned rule id and per-rule `enabled` default).
139test.provider(
140 "create, update spend limits, delete ai gateway",
141 (stack) =>
142 Effect.gen(function* () {
143 const { accountId } = yield* yield* CloudflareEnvironment;
144
145 yield* stack.destroy();
146
147 const gateway = yield* stack.deploy(
148 Effect.gen(function* () {
149 return yield* Cloudflare.AI.Gateway("SpendGateway", {
150 id: "alchemy-test-ai-gateway-spend",
151 spendLimits: {
152 enabled: true,
153 // Effect Duration.Input is decoded to whole seconds for the API.
154 rules: [{ limitType: "cost", limit: 500_00, window: "1 day" }],
155 },
156 });
157 }),
158 );
159
160 expect(gateway.spendLimits?.enabled).toEqual(true);
161 expect(gateway.spendLimits?.rules?.[0]?.limit).toEqual(500_00);
162 expect(gateway.spendLimits?.rules?.[0]?.window).toEqual(86_400);
163
164 const live = yield* aiGateway.getAiGateway({
165 accountId,
166 id: gateway.gatewayId,
167 });
168 expect(live.spendLimits?.enabled).toEqual(true);
169 expect(live.spendLimits?.rules?.[0]?.limit).toEqual(500_00);
170 expect(live.spendLimits?.rules?.[0]?.window).toEqual(86_400);
171
172 // Re-deploying identical props must be a no-op (no perpetual drift from
173 // the server-assigned rule id / enabled default).
174 const again = yield* stack.deploy(
175 Effect.gen(function* () {
176 return yield* Cloudflare.AI.Gateway("SpendGateway", {
177 id: "alchemy-test-ai-gateway-spend",
178 spendLimits: {
179 enabled: true,
180 rules: [{ limitType: "cost", limit: 500_00, window: "1 day" }],
181 },
182 });
183 }),
184 );
185 expect(again.spendLimits?.rules?.[0]?.limit).toEqual(500_00);
186
187 // Update the cap in place.
188 const updated = yield* stack.deploy(
189 Effect.gen(function* () {
190 return yield* Cloudflare.AI.Gateway("SpendGateway", {
191 id: "alchemy-test-ai-gateway-spend",
192 spendLimits: {
193 enabled: true,
194 rules: [{ limitType: "cost", limit: 1_000_00, window: "1 hour" }],
195 },
196 });
197 }),
198 );
199 expect(updated.spendLimits?.rules?.[0]?.limit).toEqual(1_000_00);
200
201 const liveUpdated = yield* aiGateway.getAiGateway({
202 accountId,
203 id: gateway.gatewayId,
204 });
205 expect(liveUpdated.spendLimits?.rules?.[0]?.limit).toEqual(1_000_00);
206 expect(liveUpdated.spendLimits?.rules?.[0]?.window).toEqual(3_600);
207
208 yield* stack.destroy();
209 yield* waitForGatewayToBeDeleted(gateway.gatewayId, accountId);
210 }).pipe(logLevel),
211 {
212 tags: [
213 "provider:cloudflare",
214 "provider:cloudflare:ai",
215 "provider:cloudflare:worker",
216 "live",
217 ],
218 },
219);
220
221test.provider(
222 "list enumerates the deployed ai gateway",
223 (stack) =>
224 Effect.gen(function* () {
225 yield* stack.destroy();
226
227 const gatewayId = "alchemy-test-ai-gateway-list";
228
229 const deployed = yield* stack.deploy(
230 Effect.gen(function* () {
231 return yield* Cloudflare.AI.Gateway("ListGateway", {
232 id: gatewayId,
233 });
234 }),
235 );
236
237 const provider = yield* Provider.findProvider(Cloudflare.AI.Gateway);
238 const all = yield* provider.list();
239
240 expect(all.some((g) => g.gatewayId === deployed.gatewayId)).toBe(true);
241
242 yield* stack.destroy();
243 yield* waitForGatewayToBeDeleted(deployed.gatewayId, deployed.accountId);
244 }).pipe(logLevel),
245 {
246 tags: [
247 "provider:cloudflare",
248 "provider:cloudflare:ai",
249 "provider:cloudflare:worker",
250 "live",
251 ],
252 },
253);
254
255// Engine-level adoption: AI Gateways have no ownership signal (Cloudflare
256// doesn't expose tags on AI Gateways), so a name match in `read` is treated
257// as silent adoption.
258test.provider(
259 "existing ai gateway (matching id) is silently adopted without --adopt",
260 (stack) =>
261 Effect.gen(function* () {
262 const { accountId } = yield* yield* CloudflareEnvironment;
263
264 yield* stack.destroy();
265
266 // Phase 1: deploy normally so a real AI Gateway exists. No explicit
267 // `id` — the engine generates a random-suffixed physical id
268 // (collision-free across concurrent runs); the deploy output hands
269 // back the real id, which pins the gateway's identity for the
270 // adoption phase below.
271 const initial = yield* stack.deploy(
272 Effect.gen(function* () {
273 return yield* Cloudflare.AI.Gateway("AdoptableGateway");
274 }),
275 );
276 const gatewayId = initial.gatewayId;
277
278 // Phase 2: wipe local state — the gateway stays on Cloudflare.
279 yield* Effect.gen(function* () {
280 const state = yield* yield* State;
281 yield* state.delete({
282 stack: stack.name,
283 stage: stack.stage,
284 fqn: "AdoptableGateway",
285 });
286 }).pipe(Effect.provide(stack.state));
287
288 // Phase 3: redeploy without `adopt(true)`. The engine calls
289 // `provider.read`, which fetches the gateway by id and returns plain
290 // attrs — silent adoption.
291 const adopted = yield* stack.deploy(
292 Effect.gen(function* () {
293 return yield* Cloudflare.AI.Gateway("AdoptableGateway", {
294 id: gatewayId,
295 });
296 }),
297 );
298
299 expect(adopted.gatewayId).toEqual(gatewayId);
300
301 const persisted = yield* Effect.gen(function* () {
302 const state = yield* yield* State;
303 return yield* state.get({
304 stack: stack.name,
305 stage: stack.stage,
306 fqn: "AdoptableGateway",
307 });
308 }).pipe(Effect.provide(stack.state));
309
310 expect((persisted as any)?.attr).toMatchObject({ gatewayId });
311
312 yield* stack.destroy();
313 yield* waitForGatewayToBeDeleted(gatewayId, accountId);
314 }).pipe(logLevel),
315 {
316 tags: [
317 "provider:cloudflare",
318 "provider:cloudflare:ai",
319 "provider:cloudflare:worker",
320 "live",
321 ],
322 },
323);
324
325const waitForGatewayToBeDeleted = Effect.fn(function* (
326 gatewayId: string,
327 accountId: string,
328) {
329 yield* aiGateway
330 .getAiGateway({
331 accountId,
332 id: gatewayId,
333 })
334 .pipe(
335 Effect.flatMap(() => Effect.fail(new GatewayStillExists())),
336 Effect.retry({
337 while: (e): e is GatewayStillExists => e instanceof GatewayStillExists,
338 schedule: Schedule.exponential(100),
339 }),
340 Effect.catchTag("GatewayNotFound", () => Effect.void),
341 );
342});
343
344class GatewayStillExists extends Data.TaggedError("GatewayStillExists") {}
345
346const Stack = Alchemy.Stack(
347 "AiGatewayBindingStack",
348 {
349 providers: Cloudflare.providers(),
350 state: Cloudflare.state(),
351 },
352 Effect.gen(function* () {
353 const gateway = yield* Gateway;
354 const worker = yield* TestWorker;
355 return {
356 gatewayId: gateway.gatewayId,
357 url: worker.url.as<string>(),
358 };
359 }),
360);
361
362const stack = beforeAll(deploy(Stack));
363afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
364
365test(
366 "deployed worker can call AiGateway binding (effect-native getUrl)",
367 Effect.gen(function* () {
368 const out = yield* stack;
369 const workerUrl = out.url;
370 expect(workerUrl).toBeTypeOf("string");
371 expect(out.gatewayId).toBeTypeOf("string");
372 expect(out.gatewayId.length).toBeGreaterThan(0);
373
374 const client = yield* HttpClient.HttpClient;
375 const res = yield* client.get(`${workerUrl}/url`).pipe(
376 Effect.flatMap((res) =>
377 res.status === 200
378 ? Effect.succeed(res)
379 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
380 ),
381 Effect.retry({
382 schedule: Schedule.exponential("500 millis"),
383 times: 15,
384 }),
385 );
386 expect(res.status).toBe(200);
387 const body = (yield* res.json) as { url: string };
388 // The runtime gateway exposes a stable account-scoped URL like
389 // https://gateway.ai.cloudflare.com/v1/<accountId>/<gatewayId>
390 expect(body.url).toContain(out.gatewayId);
391 expect(body.url).toContain("gateway.ai.cloudflare.com");
392 }).pipe(logLevel),
393 {
394 tags: [
395 "provider:cloudflare",
396 "provider:cloudflare:ai",
397 "provider:cloudflare:worker",
398 "live",
399 ],
400 timeout: 180_000,
401 },
402);