1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import { poll } from "@/Util/poll.ts";
6import * as aiGateway from "@distilled.cloud/cloudflare/ai-gateway";
7import { expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const GATEWAY_ID = "alchemy-test-aigw-routing";
21const GATEWAY_ID_B = "alchemy-test-aigw-routing-b";
22
23const graph = (
24 model: string,
25 retries: number,
26): Cloudflare.AI.RouteElement[] => [
27 {
28 id: "start",
29 type: "start",
30 outputs: { next: { elementId: "model" } },
31 },
32 {
33 id: "model",
34 type: "model",
35 properties: {
36 provider: "workers-ai",
37 model,
38 retries,
39 timeout: 30_000,
40 },
41 outputs: {
42 success: { elementId: "end" },
43 fallback: { elementId: "end" },
44 },
45 },
46 { id: "end", type: "end", outputs: {} },
47];
48
49class RouteStillExists extends Data.TaggedError("RouteStillExists") {}
50
51// A deleted route surfaces as `RouteNotFound` (Cloudflare error code 7005);
52// once the parent gateway is destroyed the same probe fails with
53// `GatewayNotFound` (code 7002). Either way the route is gone.
54const expectGone = (accountId: string, gatewayId: string, routeId: string) =>
55 aiGateway.getDynamicRouting({ accountId, gatewayId, id: routeId }).pipe(
56 Effect.flatMap(() => Effect.fail(new RouteStillExists())),
57 Effect.retry({
58 while: (e): e is RouteStillExists => e instanceof RouteStillExists,
59 schedule: Schedule.max([
60 Schedule.exponential("250 millis"),
61 Schedule.recurs(10),
62 ]),
63 }),
64 Effect.catchTag("RouteNotFound", () => Effect.void),
65 Effect.catchTag("GatewayNotFound", () => Effect.void),
66 );
67
68test.provider(
69 "create, update elements (new deployed version), rename, delete",
70 (stack) =>
71 Effect.gen(function* () {
72 const { accountId } = yield* yield* CloudflareEnvironment;
73
74 yield* stack.destroy();
75
76 const initial = yield* stack.deploy(
77 Effect.gen(function* () {
78 const gateway = yield* Cloudflare.AI.Gateway("RoutingGateway", {
79 id: GATEWAY_ID,
80 });
81 const route = yield* Cloudflare.AI.GatewayDynamicRouting("Route", {
82 gatewayId: gateway.gatewayId,
83 name: "alchemy-test-route",
84 elements: graph("@cf/meta/llama-3.1-8b-instruct", 1),
85 });
86 return { gateway, route };
87 }),
88 );
89
90 expect(initial.route.routeId).toBeDefined();
91 expect(initial.route.accountId).toEqual(accountId);
92 expect(initial.route.gatewayId).toEqual(GATEWAY_ID);
93 expect(initial.route.name).toEqual("alchemy-test-route");
94 // Creation auto-deploys version 1.
95 expect(initial.route.versionId).toBeDefined();
96 expect(initial.route.deploymentId).toBeDefined();
97 expect(initial.route.elements).toEqual(
98 graph("@cf/meta/llama-3.1-8b-instruct", 1),
99 );
100
101 // Verify out-of-band via the API: deployed version matches.
102 const live = yield* aiGateway.getDynamicRouting({
103 accountId,
104 gatewayId: GATEWAY_ID,
105 id: initial.route.routeId,
106 });
107 expect(live.name).toEqual("alchemy-test-route");
108 expect(live.deployment.versionId).toEqual(initial.route.versionId);
109 expect(live.version.data).toEqual(
110 graph("@cf/meta/llama-3.1-8b-instruct", 1),
111 );
112
113 // Update the element graph and rename — same route id, but a new
114 // version must be created AND deployed.
115 const updated = yield* stack.deploy(
116 Effect.gen(function* () {
117 const gateway = yield* Cloudflare.AI.Gateway("RoutingGateway", {
118 id: GATEWAY_ID,
119 });
120 const route = yield* Cloudflare.AI.GatewayDynamicRouting("Route", {
121 gatewayId: gateway.gatewayId,
122 name: "alchemy-test-route-v2",
123 elements: graph("@cf/meta/llama-3.1-8b-instruct", 2),
124 });
125 return { gateway, route };
126 }),
127 );
128
129 expect(updated.route.routeId).toEqual(initial.route.routeId);
130 expect(updated.route.name).toEqual("alchemy-test-route-v2");
131 expect(updated.route.versionId).not.toEqual(initial.route.versionId);
132 expect(updated.route.elements).toEqual(
133 graph("@cf/meta/llama-3.1-8b-instruct", 2),
134 );
135
136 const liveUpdated = yield* aiGateway.getDynamicRouting({
137 accountId,
138 gatewayId: GATEWAY_ID,
139 id: initial.route.routeId,
140 });
141 expect(liveUpdated.name).toEqual("alchemy-test-route-v2");
142 expect(liveUpdated.version.active).toBe(true);
143 expect(liveUpdated.deployment.versionId).toEqual(updated.route.versionId);
144 expect(liveUpdated.version.data).toEqual(
145 graph("@cf/meta/llama-3.1-8b-instruct", 2),
146 );
147
148 // Redeploying identical props is a no-op (same deployed version).
149 const noop = yield* stack.deploy(
150 Effect.gen(function* () {
151 const gateway = yield* Cloudflare.AI.Gateway("RoutingGateway", {
152 id: GATEWAY_ID,
153 });
154 const route = yield* Cloudflare.AI.GatewayDynamicRouting("Route", {
155 gatewayId: gateway.gatewayId,
156 name: "alchemy-test-route-v2",
157 elements: graph("@cf/meta/llama-3.1-8b-instruct", 2),
158 });
159 return { gateway, route };
160 }),
161 );
162 expect(noop.route.routeId).toEqual(initial.route.routeId);
163 expect(noop.route.versionId).toEqual(updated.route.versionId);
164
165 yield* stack.destroy();
166
167 yield* expectGone(accountId, GATEWAY_ID, initial.route.routeId);
168 }).pipe(logLevel),
169 { tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"] },
170);
171
172test.provider(
173 "replaces route when the gateway changes",
174 (stack) =>
175 Effect.gen(function* () {
176 const { accountId } = yield* yield* CloudflareEnvironment;
177
178 yield* stack.destroy();
179
180 const initial = yield* stack.deploy(
181 Effect.gen(function* () {
182 const gatewayA = yield* Cloudflare.AI.Gateway("RouteGatewayA", {
183 id: GATEWAY_ID,
184 });
185 yield* Cloudflare.AI.Gateway("RouteGatewayB", {
186 id: GATEWAY_ID_B,
187 });
188 const route = yield* Cloudflare.AI.GatewayDynamicRouting(
189 "ReplaceRoute",
190 {
191 gatewayId: gatewayA.gatewayId,
192 name: "alchemy-test-route-replace",
193 elements: graph("@cf/meta/llama-3.1-8b-instruct", 1),
194 },
195 );
196 return { route };
197 }),
198 );
199 expect(initial.route.gatewayId).toEqual(GATEWAY_ID);
200
201 // Moving the route to another gateway is a replacement: new id, new
202 // parent, and the old route is removed from gateway A.
203 const moved = yield* stack.deploy(
204 Effect.gen(function* () {
205 yield* Cloudflare.AI.Gateway("RouteGatewayA", {
206 id: GATEWAY_ID,
207 });
208 const gatewayB = yield* Cloudflare.AI.Gateway("RouteGatewayB", {
209 id: GATEWAY_ID_B,
210 });
211 const route = yield* Cloudflare.AI.GatewayDynamicRouting(
212 "ReplaceRoute",
213 {
214 gatewayId: gatewayB.gatewayId,
215 name: "alchemy-test-route-replace",
216 elements: graph("@cf/meta/llama-3.1-8b-instruct", 1),
217 },
218 );
219 return { route };
220 }),
221 );
222
223 expect(moved.route.gatewayId).toEqual(GATEWAY_ID_B);
224 expect(moved.route.routeId).not.toEqual(initial.route.routeId);
225
226 // The replaced route is gone from gateway A.
227 yield* expectGone(accountId, GATEWAY_ID, initial.route.routeId);
228
229 yield* stack.destroy();
230
231 yield* expectGone(accountId, GATEWAY_ID_B, moved.route.routeId);
232 }).pipe(logLevel),
233 { tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"] },
234);
235
236test.provider(
237 "recreates a route after out-of-band delete",
238 (stack) =>
239 Effect.gen(function* () {
240 const { accountId } = yield* yield* CloudflareEnvironment;
241
242 yield* stack.destroy();
243
244 const initial = yield* stack.deploy(
245 Effect.gen(function* () {
246 const gateway = yield* Cloudflare.AI.Gateway("HealRouteGateway", {
247 id: GATEWAY_ID,
248 });
249 const route = yield* Cloudflare.AI.GatewayDynamicRouting(
250 "HealRoute",
251 {
252 gatewayId: gateway.gatewayId,
253 name: "alchemy-test-route-heal",
254 elements: graph("@cf/meta/llama-3.1-8b-instruct", 1),
255 },
256 );
257 return { route };
258 }),
259 );
260
261 // Delete the route out-of-band. A redeploy with identical props is a
262 // planner no-op, so change a prop to force reconcile — it must observe
263 // the route as missing and recreate it instead of failing on a 404.
264 yield* aiGateway.deleteDynamicRouting({
265 accountId,
266 gatewayId: GATEWAY_ID,
267 id: initial.route.routeId,
268 });
269
270 const healed = yield* stack.deploy(
271 Effect.gen(function* () {
272 const gateway = yield* Cloudflare.AI.Gateway("HealRouteGateway", {
273 id: GATEWAY_ID,
274 });
275 const route = yield* Cloudflare.AI.GatewayDynamicRouting(
276 "HealRoute",
277 {
278 gatewayId: gateway.gatewayId,
279 name: "alchemy-test-route-heal",
280 elements: graph("@cf/meta/llama-3.1-8b-instruct", 3),
281 },
282 );
283 return { route };
284 }),
285 );
286
287 expect(healed.route.routeId).not.toEqual(initial.route.routeId);
288 expect(healed.route.elements).toEqual(
289 graph("@cf/meta/llama-3.1-8b-instruct", 3),
290 );
291
292 yield* stack.destroy();
293
294 yield* expectGone(accountId, GATEWAY_ID, healed.route.routeId);
295 }).pipe(logLevel),
296 { tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"] },
297);
298
299// Canonical `list()` test (parent fan-out): routes are scoped under a gateway
300// with no account-wide route API, so `list()` enumerates every account gateway
301// and exhaustively lists each gateway's routes. Deploy a gateway + route, then
302// assert the deployed route appears in the exhaustively-paginated result.
303test.provider(
304 "list enumerates routes across all gateways",
305 (stack) =>
306 Effect.gen(function* () {
307 yield* stack.destroy();
308
309 const deployed = yield* stack.deploy(
310 Effect.gen(function* () {
311 const gateway = yield* Cloudflare.AI.Gateway("ListRouteGateway", {
312 id: GATEWAY_ID,
313 });
314 const route = yield* Cloudflare.AI.GatewayDynamicRouting(
315 "ListRoute",
316 {
317 gatewayId: gateway.gatewayId,
318 name: "alchemy-test-route-list",
319 elements: graph("@cf/meta/llama-3.1-8b-instruct", 1),
320 },
321 );
322 return { route };
323 }),
324 );
325
326 const provider = yield* Provider.findProvider(
327 Cloudflare.AI.GatewayDynamicRouting,
328 );
329
330 // The route appears in list() shortly after deploy, but its element graph
331 // is materialized from a separate deployed-version lookup that propagates
332 // with its own eventual-consistency lag. Poll until the route is present
333 // AND its graph has propagated before asserting.
334 const all = yield* poll({
335 description:
336 "list() includes the deployed route with its element graph",
337 effect: provider.list(),
338 predicate: (all) =>
339 (all.find((r) => r.routeId === deployed.route.routeId)?.elements
340 ?.length ?? 0) > 0,
341 // Bound the poll so it converges (or fails with a clear PredicateFailed)
342 // well within the test timeout below — the default schedule (50 × 5s)
343 // outruns the timeout and surfaces as an opaque "Test timed out".
344 schedule: Schedule.max([
345 Schedule.spaced("3 seconds"),
346 Schedule.recurs(40),
347 ]),
348 });
349
350 const found = all.find((r) => r.routeId === deployed.route.routeId);
351 expect(found).toBeDefined();
352 expect(found?.gatewayId).toEqual(GATEWAY_ID);
353 expect(found?.name).toEqual("alchemy-test-route-list");
354 expect(found?.elements).toEqual(
355 graph("@cf/meta/llama-3.1-8b-instruct", 1),
356 );
357
358 yield* stack.destroy();
359 }).pipe(logLevel),
360 {
361 tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"],
362 timeout: 180_000,
363 },
364);