| 1 | import * as aiGateway from "@distilled.cloud/cloudflare/ai-gateway"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { isResourceOfType, Resource } from "../../Resource.ts"; |
| 9 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | export type GatewayRateLimitingTechnique = "fixed" | "sliding"; |
| 13 | |
| 14 | export type GatewayLogManagementStrategy = "STOP_INSERTING" | "DELETE_OLDEST"; |
| 15 | |
| 16 | export 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 | |
| 63 | export 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 | |
| 83 | export 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 | */ |
| 104 | export 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 | */ |
| 156 | export 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 | |
| 167 | export 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 | |
| 267 | export 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. |
| 301 | const nullIfZero = (value: number | null | undefined): number | null => |
| 302 | value == null || value === 0 ? null : value; |
| 303 | |
| 304 | export 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 | */ |
| 471 | export const Gateway = Resource<Gateway>("Cloudflare.AI.Gateway", { |
| 472 | aliases: ["Cloudflare.AiGateway"], |
| 473 | }); |
| 474 | |
| 475 | export 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 | |
| 594 | const 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 | }); |
| 603 | const 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. |
| 636 | const 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 | |
| 649 | const 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. |
| 697 | const 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 | |
| 739 | const 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. |
| 764 | const 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 | }; |
| 782 | const 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 | |
| 807 | const 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 | }); |