1import * as aiGateway from "@distilled.cloud/cloudflare/ai-gateway";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { isResourceOfType, Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12export type GatewayRateLimitingTechnique = "fixed" | "sliding";
13
14export type GatewayLogManagementStrategy = "STOP_INSERTING" | "DELETE_OLDEST";
15
16export type GatewayDlp =
17 | {
18 /**
19 * Action to take when a DLP profile matches.
20 */
21 action: "BLOCK" | "FLAG";
22 /**
23 * Whether DLP is enabled.
24 */
25 enabled: boolean;
26 /**
27 * DLP profile identifiers to apply.
28 */
29 profiles: string[];
30 }
31 | {
32 /**
33 * Whether DLP is enabled.
34 */
35 enabled: boolean;
36 /**
37 * DLP policies to apply.
38 */
39 policies: {
40 /**
41 * DLP policy identifier.
42 */
43 id: string;
44 /**
45 * Action to take when the policy matches.
46 */
47 action: "FLAG" | "BLOCK";
48 /**
49 * Request or response phases checked by the policy.
50 */
51 check: ("REQUEST" | "RESPONSE")[];
52 /**
53 * Whether the policy is enabled.
54 */
55 enabled: boolean;
56 /**
57 * DLP profile identifiers to apply.
58 */
59 profiles: string[];
60 }[];
61 };
62
63export type GatewayOtel = {
64 /**
65 * Authorization header value for the OpenTelemetry endpoint.
66 */
67 authorization?: string;
68 /**
69 * Additional headers sent to the OpenTelemetry endpoint.
70 */
71 headers: Record<string, unknown>;
72 /**
73 * OpenTelemetry endpoint URL.
74 */
75 url: string;
76 /**
77 * Payload encoding sent to the OpenTelemetry endpoint.
78 * @default "json"
79 */
80 contentType?: "json" | "protobuf";
81};
82
83export type GatewayStripe = {
84 /**
85 * Authorization header value for Stripe usage events.
86 */
87 authorization: string;
88 /**
89 * Stripe usage event payload definitions.
90 */
91 usageEvents: {
92 /**
93 * Usage event payload.
94 */
95 payload: string;
96 }[];
97};
98
99/**
100 * A single spend-limit rule. Caps cumulative spend (in cents) routed through
101 * the gateway over a rolling `window` (in seconds), optionally scoped to a set
102 * of models or providers.
103 */
104export type GatewaySpendLimitRule = {
105 /**
106 * Spend cap for this rule. The amount is in cents (`limitType: "cost"`).
107 */
108 limit: number;
109 /**
110 * The kind of limit. Only `"cost"` is currently supported.
111 */
112 limitType: "cost";
113 /**
114 * Rolling window over which `limit` accumulates. Accepts an Effect
115 * `Duration.Input` — e.g. `"1 minute"`, `"1 day"`, or a `Duration` — and is
116 * sent to Cloudflare as whole seconds.
117 */
118 window: Duration.Input;
119 /**
120 * Stable identifier for the rule. Cloudflare assigns one if omitted; pass
121 * it back to update an existing rule in place.
122 */
123 id?: string;
124 /**
125 * Whether this rule is enforced.
126 *
127 * @default true
128 */
129 enabled?: boolean;
130 /**
131 * Arbitrary metadata attached to the rule.
132 */
133 metadata?: Record<string, unknown>;
134 /**
135 * Restrict the rule to a set of model ids.
136 */
137 model?: { mode: "filter"; values: string[] };
138 /**
139 * Restrict the rule to a set of providers.
140 */
141 provider?: { mode: "filter"; values: string[] };
142 /**
143 * Enforcement algorithm — `fixed` resets on the window boundary, `sliding`
144 * tracks a rolling window.
145 */
146 technique?: GatewayRateLimitingTechnique;
147};
148
149/**
150 * Per-gateway spend limits — Cloudflare's replacement for the deprecated
151 * account-level AI Gateway spending limit. Attach cost caps directly to a
152 * gateway via {@link GatewayProps.spendLimits}.
153 *
154 * @see https://developers.cloudflare.com/ai-gateway/features/spend-limits/
155 */
156export type GatewaySpendLimits = {
157 /**
158 * Whether spend limiting is enabled for the gateway.
159 */
160 enabled?: boolean;
161 /**
162 * The cost-cap rules applied to requests routed through the gateway.
163 */
164 rules?: GatewaySpendLimitRule[];
165};
166
167export type GatewayProps = {
168 /**
169 * Gateway identifier. If omitted, a unique ID will be generated.
170 *
171 * Must be 1-64 characters and match Cloudflare's AI Gateway ID pattern:
172 * lowercase letters, numbers, underscores, and hyphens.
173 *
174 * @default ${app}-${stage}-${id}
175 */
176 id?: string;
177 /**
178 * Whether cached responses are invalidated when a request changes.
179 *
180 * @default false
181 */
182 cacheInvalidateOnUpdate?: boolean;
183 /**
184 * Cache time-to-live in seconds. Set to `null` to disable caching.
185 *
186 * @default null
187 */
188 cacheTtl?: number | null;
189 /**
190 * Whether AI Gateway stores request logs.
191 *
192 * @default true
193 */
194 collectLogs?: boolean;
195 /**
196 * Rate limiting interval in seconds. Set to `null` to disable rate limiting.
197 *
198 * @default null
199 */
200 rateLimitingInterval?: number | null;
201 /**
202 * Maximum requests allowed during the rate limiting interval. Set to `null`
203 * to disable rate limiting.
204 *
205 * @default null
206 */
207 rateLimitingLimit?: number | null;
208 /**
209 * Rate limiting algorithm.
210 *
211 * @default "fixed"
212 */
213 rateLimitingTechnique?: GatewayRateLimitingTechnique;
214 /**
215 * Whether gateway authentication is enabled.
216 */
217 authentication?: boolean;
218 /**
219 * DLP configuration. The installed distilled Cloudflare client applies this
220 * through the update API after gateway creation.
221 */
222 dlp?: GatewayDlp;
223 /**
224 * Whether this gateway is the account default.
225 */
226 isDefault?: boolean;
227 /**
228 * Maximum number of log entries to retain.
229 */
230 logManagement?: number | null;
231 /**
232 * Strategy used when retained logs reach `logManagement`.
233 */
234 logManagementStrategy?: GatewayLogManagementStrategy | null;
235 /**
236 * Whether Logpush is enabled for this gateway.
237 */
238 logpush?: boolean;
239 /**
240 * Public key used for Logpush encryption.
241 */
242 logpushPublicKey?: string | null;
243 /**
244 * OpenTelemetry export configuration.
245 */
246 otel?: GatewayOtel[] | null;
247 /**
248 * Store identifier used by the gateway.
249 */
250 storeId?: string | null;
251 /**
252 * Stripe usage export configuration.
253 */
254 stripe?: GatewayStripe | null;
255 /**
256 * Per-gateway spend limits (Cloudflare's replacement for the deprecated
257 * account-level spending limit). Applied through the update API after
258 * gateway creation.
259 */
260 spendLimits?: GatewaySpendLimits | null;
261 /**
262 * Whether Zero Data Retention is enabled.
263 */
264 zdr?: boolean;
265};
266
267export type Gateway = Resource<
268 "Cloudflare.AI.Gateway",
269 GatewayProps,
270 {
271 gatewayId: string;
272 accountId: string;
273 cacheInvalidateOnUpdate: boolean;
274 cacheTtl: number | null;
275 collectLogs: boolean;
276 createdAt: string;
277 modifiedAt: string;
278 rateLimitingInterval: number | null;
279 rateLimitingLimit: number | null;
280 rateLimitingTechnique: GatewayRateLimitingTechnique;
281 authentication: boolean;
282 dlp: GatewayDlp | undefined;
283 isDefault: boolean;
284 logManagement: number;
285 logManagementStrategy: GatewayLogManagementStrategy;
286 logpush: boolean;
287 logpushPublicKey: string | undefined;
288 otel: GatewayOtel[] | undefined;
289 storeId: string;
290 stripe: GatewayStripe | undefined;
291 spendLimits: GatewaySpendLimits | undefined;
292 zdr: boolean;
293 },
294 never,
295 Providers
296>;
297
298// Cloudflare's AI Gateway API uses 0 to mean "disabled" for cache TTL and
299// rate limiting fields. Normalize back to null so user-facing semantics
300// match what was passed in.
301const nullIfZero = (value: number | null | undefined): number | null =>
302 value == null || value === 0 ? null : value;
303
304export const isAiGateway = (value: unknown): value is Gateway =>
305 isResourceOfType(value, "Cloudflare.AI.Gateway");
306
307/**
308 * A Cloudflare.AI. Gateway for observability, caching, rate limiting, and
309 * governance across AI provider requests.
310 *
311 * AI Gateway gives your application a stable gateway ID and account-scoped
312 * endpoint that can route model requests through Cloudflare. Once bound to a
313 * Worker, `aiGateway.model({...})` returns an `effect/ai`
314 * `LanguageModel` Layer so you use the standard `generateText` / `streamText`
315 * APIs — provider-agnostic, with caching, rate limiting, retries, and a
316 * unified request log handled by the gateway.
317 * ### Creating a Gateway
318 * **Example:** Basic gateway
319 * ```typescript
320 * const gateway = yield* Cloudflare.AI.Gateway("Gateway");
321 * ```
322 *
323 * **Example:** Gateway with caching and rate limiting
324 * ```typescript
325 * const gateway = yield* Cloudflare.AI.Gateway("Gateway", {
326 * id: "my-gateway",
327 * cacheTtl: 300,
328 * cacheInvalidateOnUpdate: true,
329 * rateLimitingInterval: 60,
330 * rateLimitingLimit: 100,
331 * rateLimitingTechnique: "sliding",
332 * });
333 * ```
334 *
335 * ### Logging
336 * **Example:** Gateway with log retention
337 * ```typescript
338 * const gateway = yield* Cloudflare.AI.Gateway("Gateway", {
339 * collectLogs: true,
340 * logManagement: 10000,
341 * logManagementStrategy: "STOP_INSERTING",
342 * });
343 * ```
344 *
345 * ### Binding into a Worker
346 * **Example:** Bind the gateway and provide the runtime layer
347 * `Cloudflare.AI.QueryGateway(gateway)` returns a typed, Effect-native client during the
348 * Worker's Init phase. Provide `Cloudflare.AI.QueryGatewayBinding` once at the
349 * bottom of the Init layer chain so every `QueryGateway(...)` resolves at runtime.
350 * ```typescript
351 * import * as Cloudflare from "alchemy/Cloudflare";
352 * import * as Effect from "effect/Effect";
353 * import { Gateway } from "./Gateway.ts";
354 *
355 * export default class Api extends Cloudflare.Worker<Api>()(
356 * "Api",
357 * { main: import.meta.url },
358 * Effect.gen(function* () {
359 * const aiGateway = yield* Cloudflare.AI.QueryGateway(Gateway);
360 *
361 * return {
362 * fetch: Effect.gen(function* () {
363 * // …routes
364 * }),
365 * };
366 * }).pipe(Effect.provide(Cloudflare.AI.QueryGatewayBinding)),
367 * ) {}
368 * ```
369 *
370 * ### Building a LanguageModel
371 * **Example:** `aiGateway.model(...)` -> Effect AI `LanguageModel`
372 * Call `aiGateway.model({...})` with a Workers AI model id. It returns a
373 * `Layer<LanguageModel, never, RuntimeContext>` directly — no API key and no
374 * `Layer.unwrap`, since the binding handles auth and the gateway URL. Build it
375 * in the Init phase; construction is pure.
376 * ```typescript
377 * const aiGateway = yield* Cloudflare.AI.QueryGateway(Gateway);
378 *
379 * const languageModel = aiGateway.model({
380 * model: "@cf/meta/llama-3.1-8b-instruct",
381 * parameters: { temperature: 0.7, maxTokens: 1024 },
382 * });
383 * ```
384 *
385 * ### Generating Text
386 * **Example:** Generate text on a route
387 * Provide the `languageModel` layer to the handler and call
388 * `LanguageModel.generateText` like any other Effect. `Effect.orDie` collapses
389 * `AiError` to a defect (a 500); use `Effect.catchTag("AiError", …)` for typed
390 * handling instead.
391 * ```typescript
392 * import { LanguageModel } from "effect/ai";
393 * import * as HttpServerResponse from "effect/http/HttpServerResponse";
394 *
395 * fetch: Effect.gen(function* () {
396 * const response = yield* LanguageModel.generateText({
397 * prompt: "Say hello.",
398 * }).pipe(Effect.orDie);
399 * return yield* HttpServerResponse.json({
400 * text: response.text,
401 * usage: {
402 * inputTokens: response.usage.inputTokens.total,
403 * outputTokens: response.usage.outputTokens.total,
404 * },
405 * });
406 * }).pipe(Effect.provide(languageModel));
407 * ```
408 *
409 * ### Streaming Text
410 * **Example:** Stream tokens as Server-Sent Events
411 * `LanguageModel.streamText` returns a `Stream` of typed response parts.
412 * `Stream.provide(languageModel)` keeps the model available for the whole
413 * stream lifetime; pipe through `Sse.encode` for an SSE response.
414 * ```typescript
415 * import { LanguageModel } from "effect/ai";
416 * import * as Stream from "effect/Stream";
417 * import * as Sse from "effect/encoding/Sse";
418 * import * as HttpServerResponse from "effect/http/HttpServerResponse";
419 *
420 * const stream = LanguageModel.streamText({ prompt }).pipe(
421 * Stream.provide(languageModel),
422 * Sse.encode,
423 * );
424 * return HttpServerResponse.stream(stream, {
425 * headers: {
426 * "content-type": "text/event-stream",
427 * "cache-control": "no-cache",
428 * "x-accel-buffering": "no",
429 * },
430 * });
431 * ```
432 *
433 * ### Tuning the Gateway
434 * **Example:** Production-grade caching, rate limits, and DLP
435 * Every prop maps to an in-place update — no replacement, no downtime.
436 * ```typescript
437 * export const Gateway = Cloudflare.AI.Gateway("Gateway", {
438 * id: "prod-gateway",
439 * cacheTtl: 300,
440 * cacheInvalidateOnUpdate: true,
441 * rateLimitingInterval: 60,
442 * rateLimitingLimit: 100,
443 * rateLimitingTechnique: "sliding",
444 * collectLogs: true,
445 * logManagement: 100_000,
446 * logManagementStrategy: "DELETE_OLDEST",
447 * authentication: true,
448 * });
449 * ```
450 *
451 * ### Spend Limits
452 * **Example:** Cap cost per rolling window
453 * Per-gateway spend limits replace the deprecated account-level spending
454 * limit. Each rule caps cumulative cost (in cents) over a rolling `window`
455 * (in seconds), optionally scoped to specific models or providers.
456 * ```typescript
457 * const gateway = yield* Cloudflare.AI.Gateway("Gateway", {
458 * spendLimits: {
459 * enabled: true,
460 * rules: [
461 * { limitType: "cost", limit: 500_00, window: "1 day" }, // $500/day
462 * ],
463 * },
464 * });
465 * ```
466 *
467 * @resource
468 * @product AI Gateway
469 * @category AI
470 */
471export const Gateway = Resource<Gateway>("Cloudflare.AI.Gateway", {
472 aliases: ["Cloudflare.AiGateway"],
473});
474
475export const GatewayResourceProvider = () =>
476 Provider.succeed(Gateway, {
477 stables: ["gatewayId", "accountId"],
478 diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) {
479 if (!isResolved(news)) return undefined;
480 const { accountId } = yield* yield* CloudflareEnvironment;
481
482 const next = yield* desired(id, news);
483 const oldGatewayId =
484 output?.gatewayId ?? (yield* createGatewayId(id, olds.id));
485 // Auto-generated ids are engine-owned: the deployed id stays
486 // authoritative even if the generator would name this id differently
487 // today. Only an explicit user-provided id can force a replace.
488 const newGatewayId = news.id ?? oldGatewayId;
489 if (
490 (output?.accountId ?? accountId) !== accountId ||
491 oldGatewayId !== newGatewayId
492 ) {
493 return { action: "replace" } as const;
494 }
495
496 const oldMutable = mutable(
497 output ?? ((yield* desired(id, olds)) as Gateway["Attributes"]),
498 );
499 const nextMutable = mutable(next as Gateway["Attributes"]);
500 if (!deepEqual(oldMutable, nextMutable)) {
501 return { action: "update" } as const;
502 }
503 }),
504 reconcile: Effect.fn(function* ({ id, news = {}, output }) {
505 const { accountId } = yield* yield* CloudflareEnvironment;
506
507 const acct = output?.accountId ?? accountId;
508 const gatewayId =
509 output?.gatewayId ?? (yield* createGatewayId(id, news.id));
510
511 // Observe — fetch the gateway's current state. The Cloudflare API
512 // returns 404 when the gateway is missing, which we tolerate so the
513 // reconciler can fall through to create.
514 const observed = yield* aiGateway
515 .getAiGateway({
516 accountId: acct,
517 id: gatewayId,
518 })
519 .pipe(
520 Effect.catchTag("GatewayNotFound", () => Effect.succeed(undefined)),
521 );
522
523 // Ensure — create if missing. Tolerate `GatewayAlreadyExists` for
524 // idempotency: a peer reconciler may have created it concurrently,
525 // or state persistence may have failed after a previous create.
526 if (observed === undefined) {
527 // Prefer the deployed id: regenerating would target a different
528 // resource if the generator's output for this id ever drifts.
529 const request = { ...(yield* createRequest(id, news)), id: gatewayId };
530 yield* aiGateway
531 .createAiGateway(request)
532 .pipe(
533 Effect.catchTag("GatewayAlreadyExists", () =>
534 aiGateway.getAiGateway({ accountId: acct, id: request.id }),
535 ),
536 );
537 }
538
539 // Sync — the Cloudflare.AI. Gateway update API is a full PATCH that
540 // overwrites all mutable fields. We always apply the desired shape
541 // so adoption, drift, and routine updates all converge.
542 // Prefer the deployed id: regenerating would target a different
543 // resource if the generator's output for this id ever drifts.
544 const update = {
545 ...(yield* updateRequest(id, news, acct)),
546 id: gatewayId,
547 };
548 const gateway = yield* aiGateway.updateAiGateway(update);
549 return mapGateway(gateway, acct);
550 }),
551 delete: Effect.fn(function* ({ output }) {
552 yield* aiGateway
553 .deleteAiGateway({
554 accountId: output.accountId,
555 id: output.gatewayId,
556 })
557 .pipe(Effect.catchTag("GatewayNotFound", () => Effect.void));
558 }),
559 read: Effect.fn(function* ({ id, olds, output }) {
560 const { accountId } = yield* yield* CloudflareEnvironment;
561
562 const gatewayId =
563 output?.gatewayId ?? (yield* createGatewayId(id, olds?.id));
564 const acct = output?.accountId ?? accountId;
565 return yield* aiGateway
566 .getAiGateway({
567 accountId: acct,
568 id: gatewayId,
569 })
570 .pipe(
571 Effect.map((gateway) => mapGateway(gateway, acct)),
572 Effect.catchTag("GatewayNotFound", () => Effect.succeed(undefined)),
573 );
574 }),
575 // AI Gateways are account-scoped; the list API returns the full gateway
576 // object for every gateway in the account in one paginated collection.
577 // Exhaustively paginate and hydrate each item into the same Attributes
578 // shape `read` produces (via `mapGateway`).
579 list: Effect.fn(function* () {
580 const { accountId } = yield* yield* CloudflareEnvironment;
581 return yield* aiGateway.listAiGateways.pages({ accountId }).pipe(
582 Stream.runCollect,
583 Effect.map((chunk) =>
584 Array.from(chunk).flatMap((page) =>
585 (page.result ?? []).map((gateway) =>
586 mapGateway(gateway, accountId),
587 ),
588 ),
589 ),
590 );
591 }),
592 });
593
594const createGatewayId = (id: string, gatewayId: string | undefined) =>
595 Effect.gen(function* () {
596 if (gatewayId) return gatewayId;
597 return yield* createPhysicalName({
598 id,
599 maxLength: 64,
600 lowercase: true,
601 });
602 });
603const desired = (id: string, props: GatewayProps | undefined) =>
604 Effect.gen(function* () {
605 return {
606 gatewayId: yield* createGatewayId(id, props?.id),
607 cacheInvalidateOnUpdate: props?.cacheInvalidateOnUpdate ?? false,
608 cacheTtl: props?.cacheTtl ?? null,
609 collectLogs: props?.collectLogs ?? true,
610 rateLimitingInterval: props?.rateLimitingInterval ?? null,
611 rateLimitingLimit: props?.rateLimitingLimit ?? null,
612 rateLimitingTechnique: props?.rateLimitingTechnique ?? "fixed",
613 // Defaults align with what Cloudflare's API returns for an
614 // unconfigured gateway, so the reconciler converges to noop
615 // when the user didn't explicitly set the field.
616 authentication: props?.authentication ?? false,
617 dlp: props?.dlp ?? undefined,
618 isDefault: props?.isDefault ?? false,
619 logManagement: props?.logManagement ?? 100_000,
620 logManagementStrategy: props?.logManagementStrategy ?? "STOP_INSERTING",
621 logpush: props?.logpush ?? false,
622 logpushPublicKey: props?.logpushPublicKey ?? undefined,
623 otel: props?.otel ?? undefined,
624 storeId: props?.storeId ?? "",
625 stripe: props?.stripe ?? undefined,
626 spendLimits: resolveSpendLimits(props?.spendLimits),
627 zdr: props?.zdr ?? false,
628 };
629 });
630
631// Resolve the user-facing spend limits into the API request shape: each rule's
632// `window` is decoded from an Effect `Duration.Input` (e.g. `"1 day"`) into
633// whole seconds. Done once here so both the create/update request body and the
634// diff baseline carry seconds (matching what the API echoes back), keeping the
635// reconciler's diff stable.
636const resolveSpendLimits = (
637 spendLimits: GatewaySpendLimits | null | undefined,
638) => {
639 if (spendLimits == null) return undefined;
640 return {
641 enabled: spendLimits.enabled,
642 rules: spendLimits.rules?.map((rule) => ({
643 ...rule,
644 window: Math.round(Duration.toSeconds(rule.window)),
645 })),
646 };
647};
648
649const mapGateway = (
650 gateway:
651 | aiGateway.GetAiGatewayResponse
652 | aiGateway.CreateAiGatewayResponse
653 | aiGateway.UpdateAiGatewayResponse,
654 accountId: string,
655): Gateway["Attributes"] => ({
656 gatewayId: gateway.id,
657 accountId,
658 // accountTag: gateway.accountTag ?? undefined,
659 // internalId: gateway.internalId ?? undefined,
660 cacheInvalidateOnUpdate: gateway.cacheInvalidateOnUpdate,
661 cacheTtl: nullIfZero(gateway.cacheTtl),
662 collectLogs: gateway.collectLogs,
663 createdAt: gateway.createdAt,
664 modifiedAt: gateway.modifiedAt,
665 rateLimitingInterval: nullIfZero(gateway.rateLimitingInterval),
666 rateLimitingLimit: nullIfZero(gateway.rateLimitingLimit),
667 rateLimitingTechnique: gateway.rateLimitingTechnique ?? "fixed",
668 authentication: gateway.authentication ?? false,
669 // Distilled widened generated string enums to open unions (`string & {}`).
670 dlp: (gateway.dlp ?? undefined) as GatewayDlp | undefined,
671 isDefault: gateway.isDefault ?? false,
672 logManagement: gateway.logManagement ?? 100_000,
673 logManagementStrategy: gateway.logManagementStrategy ?? "STOP_INSERTING",
674 logpush: gateway.logpush ?? false,
675 logpushPublicKey: gateway.logpushPublicKey ?? undefined,
676 // The wire shape uses explicit nulls and an open content-type union —
677 // normalize into our prop shape so attributes diff cleanly against props.
678 otel: gateway.otel?.map((o): GatewayOtel => ({
679 url: o.url,
680 headers: o.headers,
681 ...(o.authorization != null ? { authorization: o.authorization } : {}),
682 ...(o.contentType != null
683 ? { contentType: o.contentType as "json" | "protobuf" }
684 : {}),
685 })),
686 storeId: gateway.storeId ?? "",
687 stripe: gateway.stripe ?? undefined,
688 spendLimits: normalizeSpendLimits(gateway.spendLimits),
689 zdr: gateway.zdr ?? false,
690});
691
692// Normalize spend limits into the user-facing shape and drop server-managed
693// noise so attributes diff cleanly against props. Cloudflare assigns a stable
694// `id` to each rule and echoes back an explicit `enabled` flag — fold the
695// per-rule `enabled` default (`true`) and `null`s away so a gateway the user
696// configured without ids converges to a no-op instead of perpetual drift.
697const normalizeSpendLimits = (
698 spendLimits:
699 | {
700 enabled?: boolean | null;
701 rules?:
702 | {
703 limit: number;
704 limitType: "cost";
705 window: number;
706 id?: string | null;
707 enabled?: boolean | null;
708 metadata?: Record<string, unknown> | null;
709 model?: { mode: "filter"; values: string[] } | null;
710 provider?: { mode: "filter"; values: string[] } | null;
711 technique?: string | null;
712 }[]
713 | null;
714 }
715 | null
716 | undefined,
717): GatewaySpendLimits | undefined => {
718 if (spendLimits == null) return undefined;
719 return {
720 enabled: spendLimits.enabled ?? false,
721 rules: (spendLimits.rules ?? []).map((rule): GatewaySpendLimitRule => ({
722 limit: rule.limit,
723 limitType: rule.limitType,
724 window: rule.window,
725 enabled: rule.enabled ?? true,
726 ...(rule.id != null ? { id: rule.id } : {}),
727 ...(rule.metadata != null && Object.keys(rule.metadata).length > 0
728 ? { metadata: rule.metadata }
729 : {}),
730 ...(rule.model != null ? { model: rule.model } : {}),
731 ...(rule.provider != null ? { provider: rule.provider } : {}),
732 ...(rule.technique != null
733 ? { technique: rule.technique as GatewayRateLimitingTechnique }
734 : {}),
735 })),
736 };
737};
738
739const mutable = (gateway: Gateway["Attributes"]) => ({
740 cacheInvalidateOnUpdate: gateway.cacheInvalidateOnUpdate,
741 cacheTtl: gateway.cacheTtl,
742 collectLogs: gateway.collectLogs,
743 rateLimitingInterval: gateway.rateLimitingInterval,
744 rateLimitingLimit: gateway.rateLimitingLimit,
745 rateLimitingTechnique: gateway.rateLimitingTechnique,
746 authentication: gateway.authentication,
747 dlp: gateway.dlp,
748 isDefault: gateway.isDefault,
749 logManagement: gateway.logManagement,
750 logManagementStrategy: gateway.logManagementStrategy,
751 logpush: gateway.logpush,
752 logpushPublicKey: gateway.logpushPublicKey,
753 otel: gateway.otel,
754 storeId: gateway.storeId,
755 stripe: gateway.stripe,
756 spendLimits: spendLimitsForDiff(gateway.spendLimits),
757 zdr: gateway.zdr,
758});
759
760// Compare spend limits by their user-meaningful shape: drop the
761// server-assigned rule `id` (absent on freshly-declared props) and apply the
762// per-rule `enabled` default so desired (from props) and observed (from the
763// API) converge to a no-op when semantically equal.
764const spendLimitsForDiff = (spendLimits: GatewaySpendLimits | undefined) => {
765 if (spendLimits == null) return undefined;
766 return {
767 enabled: spendLimits.enabled ?? false,
768 rules: (spendLimits.rules ?? []).map((rule) => ({
769 limit: rule.limit,
770 limitType: rule.limitType,
771 window: rule.window,
772 enabled: rule.enabled ?? true,
773 ...(rule.metadata != null && Object.keys(rule.metadata).length > 0
774 ? { metadata: rule.metadata }
775 : {}),
776 ...(rule.model != null ? { model: rule.model } : {}),
777 ...(rule.provider != null ? { provider: rule.provider } : {}),
778 ...(rule.technique != null ? { technique: rule.technique } : {}),
779 })),
780 };
781};
782const createRequest = Effect.fn(function* (
783 id: string,
784 props: GatewayProps | undefined,
785) {
786 const next = yield* desired(id, props);
787 const { accountId } = yield* yield* CloudflareEnvironment;
788
789 return {
790 accountId,
791 id: next.gatewayId,
792 cacheInvalidateOnUpdate: next.cacheInvalidateOnUpdate,
793 cacheTtl: next.cacheTtl,
794 collectLogs: next.collectLogs,
795 rateLimitingInterval: next.rateLimitingInterval,
796 rateLimitingLimit: next.rateLimitingLimit,
797 rateLimitingTechnique: next.rateLimitingTechnique,
798 authentication: next.authentication,
799 logManagement: next.logManagement,
800 logManagementStrategy: next.logManagementStrategy,
801 logpush: next.logpush,
802 logpushPublicKey: next.logpushPublicKey,
803 zdr: next.zdr,
804 } satisfies aiGateway.CreateAiGatewayRequest;
805});
806
807const updateRequest = Effect.fn(function* (
808 id: string,
809 props: GatewayProps | undefined,
810 accountId: string,
811) {
812 const next = yield* desired(id, props);
813 return {
814 accountId,
815 id: next.gatewayId,
816 cacheInvalidateOnUpdate: next.cacheInvalidateOnUpdate,
817 cacheTtl: next.cacheTtl,
818 collectLogs: next.collectLogs,
819 rateLimitingInterval: next.rateLimitingInterval,
820 rateLimitingLimit: next.rateLimitingLimit,
821 rateLimitingTechnique: next.rateLimitingTechnique,
822 authentication: next.authentication,
823 dlp: next.dlp,
824 logManagement: next.logManagement,
825 logManagementStrategy: next.logManagementStrategy,
826 logpush: next.logpush,
827 logpushPublicKey: next.logpushPublicKey,
828 otel: next.otel,
829 storeId: next.storeId,
830 stripe: next.stripe,
831 spendLimits: next.spendLimits,
832 zdr: next.zdr,
833 } satisfies aiGateway.UpdateAiGatewayRequest;
834});