1import * as aiGateway from "@distilled.cloud/cloudflare/ai-gateway";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.AI.GatewayProvider" as const;
14type TypeId = typeof TypeId;
15
16export type GatewayProviderProps = {
17 /**
18 * The AI Gateway the provider config (BYOK key) belongs to. The gateway
19 * must have its `storeId` set to a Secrets Store id — Cloudflare resolves
20 * the key inside that store. Changing the gateway triggers a replacement.
21 */
22 gatewayId: string;
23 /**
24 * The upstream provider the key authenticates against (e.g. `openai`,
25 * `anthropic`, `workers-ai`). Changing the provider triggers a
26 * replacement.
27 */
28 providerSlug: string;
29 /**
30 * Alias distinguishing multiple keys for the same provider. If omitted, a
31 * unique name is generated from the app, stage, and logical ID.
32 *
33 * Cloudflare requires the referenced Secrets Store secret to be named
34 * exactly `{gatewayId}_{providerSlug}_{alias}` and scoped to
35 * `ai_gateway`. Changing the alias triggers a replacement.
36 * @default ${app}-${stage}-${id}
37 */
38 alias?: string;
39 /**
40 * The Secrets Store secret holding the provider API key. The secret must
41 * live in the gateway's `storeId` store, be scoped to `ai_gateway`, and
42 * be named `{gatewayId}_{providerSlug}_{alias}`. Changing the secret
43 * triggers a replacement.
44 */
45 secretId: string;
46 /**
47 * Whether this key is the gateway's default credential for the provider
48 * (used when a request does not name a specific key).
49 * @default false
50 */
51 defaultConfig?: boolean;
52 /**
53 * Maximum number of requests allowed per `rateLimitPeriod` through this
54 * key. Omit for no limit. Changing the limit triggers a replacement
55 * (Cloudflare exposes no update API for provider configs).
56 */
57 rateLimit?: number;
58 /**
59 * The rate limit window in seconds.
60 * @default 60
61 */
62 rateLimitPeriod?: number;
63};
64
65export type GatewayProviderAttributes = {
66 /**
67 * Server-generated gateway provider identifier.
68 */
69 providerConfigId: string;
70 /**
71 * The Cloudflare account the gateway provider belongs to.
72 */
73 accountId: string;
74 /**
75 * The AI Gateway the gateway provider belongs to.
76 */
77 gatewayId: string;
78 /**
79 * Alias distinguishing multiple keys for the same provider.
80 */
81 alias: string;
82 /**
83 * The upstream provider the key authenticates against.
84 */
85 providerSlug: string;
86 /**
87 * The Secrets Store secret holding the gateway provider API key.
88 */
89 secretId: string;
90 /**
91 * Masked preview of the secret value.
92 */
93 secretPreview: string;
94 /**
95 * Whether this key is the gateway's default credential for the gateway provider.
96 */
97 defaultConfig: boolean;
98 /**
99 * Maximum number of requests allowed per `rateLimitPeriod`, if limited, for the gateway provider.
100 */
101 rateLimit: number | undefined;
102 /**
103 * The rate limit window in seconds for the gateway provider.
104 */
105 rateLimitPeriod: number | undefined;
106 /**
107 * When the gateway provider was last modified.
108 */
109 modifiedAt: string;
110};
111
112export type GatewayProvider = Resource<
113 TypeId,
114 GatewayProviderProps,
115 GatewayProviderAttributes,
116 never,
117 Providers
118>;
119
120/**
121 * A BYOK (bring-your-own-key) provider credential on a Cloudflare AI
122 * Gateway.
123 *
124 * Provider configs let the gateway authenticate against upstream model
125 * providers (OpenAI, Anthropic, Workers AI, ...) with your own API key,
126 * stored in Cloudflare Secrets Store. Cloudflare exposes no update API for
127 * provider configs, so every prop change replaces the config (the old one
128 * is deleted first — a gateway allows only one config per provider slug
129 * and alias).
130 *
131 * Cloudflare imposes a strict naming contract: the gateway must reference a
132 * Secrets Store via its `storeId`, and the secret must be scoped to
133 * `ai_gateway` and named exactly `{gatewayId}_{providerSlug}_{alias}`.
134 * ### Creating a Provider Config
135 * **Example:** Bring your own OpenAI key
136 * ```typescript
137 * const store = yield* Cloudflare.SecretsStore.Store("Store");
138 *
139 * const gateway = yield* Cloudflare.AI.Gateway("Gateway", {
140 * id: "my-gateway",
141 * storeId: store.storeId,
142 * });
143 *
144 * // The secret name must be `{gatewayId}_{providerSlug}_{alias}`.
145 * // Prefer `Cloudflare.AI.ProviderKey` to wire this secret automatically.
146 * const secret = yield* Cloudflare.SecretsStore.Secret("OpenAiKey", {
147 * store,
148 * name: "my-gateway_openai_default",
149 * value: yield* Config.Redacted("OPENAI_API_KEY"),
150 * scopes: ["ai_gateway"],
151 * });
152 *
153 * const byok = yield* Cloudflare.AI.GatewayProvider("OpenAi", {
154 * gatewayId: gateway.gatewayId,
155 * providerSlug: "openai",
156 * alias: "default",
157 * secretId: secret.secretId,
158 * defaultConfig: true,
159 * });
160 * ```
161 *
162 * **Example:** Rate-limit a key
163 * ```typescript
164 * const byok = yield* Cloudflare.AI.GatewayProvider("OpenAi", {
165 * gatewayId: gateway.gatewayId,
166 * providerSlug: "openai",
167 * alias: "default",
168 * secretId: secret.secretId,
169 * rateLimit: 100,
170 * rateLimitPeriod: 60,
171 * });
172 * ```
173 *
174 * @see https://developers.cloudflare.com/ai-gateway/configuration/bring-your-own-keys/
175 *
176 * @resource
177 * @product AI Gateway
178 * @category AI
179 */
180export const GatewayProvider = Resource<GatewayProvider>(TypeId, {
181 aliases: ["Cloudflare.AiGateway.ProviderConfig"],
182});
183
184/**
185 * Returns true if the given value is a GatewayProvider resource.
186 */
187export const isGatewayProvider = (value: unknown): value is GatewayProvider =>
188 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
189
190export const GatewayProviderProvider = () =>
191 Provider.succeed(GatewayProvider, {
192 stables: ["providerConfigId", "accountId", "gatewayId"],
193 diff: Effect.fn(function* ({ id, olds, news, output }) {
194 if (!isResolved(news)) return undefined;
195 const { accountId } = yield* yield* CloudflareEnvironment;
196 if ((output?.accountId ?? accountId) !== accountId) {
197 return { action: "replace", deleteFirst: true } as const;
198 }
199 if (output === undefined) return undefined;
200 // Provider configs have no update API — any change is a replacement.
201 // Delete first: a gateway rejects a second config for the same
202 // provider slug/alias with "already exists".
203 // Auto-generated aliases are engine-owned: the deployed alias stays
204 // authoritative even if the generator would name this id differently
205 // today. Only an explicit user-provided alias can force a replace.
206 const newAlias = news.alias ?? output.alias;
207 if (
208 output.gatewayId !== news.gatewayId ||
209 output.providerSlug !== news.providerSlug ||
210 output.alias !== newAlias ||
211 output.secretId !== news.secretId ||
212 output.defaultConfig !== (news.defaultConfig ?? false) ||
213 output.rateLimit !== news.rateLimit ||
214 (output.rateLimitPeriod ?? 60) !== (news.rateLimitPeriod ?? 60)
215 ) {
216 return { action: "replace", deleteFirst: true } as const;
217 }
218 return undefined;
219 }),
220 read: Effect.fn(function* ({ id, olds, output }) {
221 const { accountId } = yield* yield* CloudflareEnvironment;
222 const acct = output?.accountId ?? accountId;
223 const gatewayId =
224 output?.gatewayId ?? (olds?.gatewayId as string | undefined);
225 if (gatewayId === undefined) return undefined;
226
227 const configs = yield* listProviderConfigs(acct, gatewayId);
228 const match = output?.providerConfigId
229 ? configs.find((c) => c.id === output.providerConfigId)
230 : // Cold read — recover from lost state by matching the
231 // deterministic alias.
232 yield* Effect.gen(function* () {
233 const alias = yield* createAlias(id, olds?.alias);
234 return configs.find(
235 (c) => c.alias === alias && c.providerSlug === olds?.providerSlug,
236 );
237 });
238 return match ? toAttributes(match, acct) : undefined;
239 }),
240 reconcile: Effect.fn(function* ({ id, news, output }) {
241 const { accountId } = yield* yield* CloudflareEnvironment;
242 // An explicit user-provided alias always wins (an alias change can
243 // reach reconcile as an update when diff saw unresolved inputs, e.g.
244 // a secretId still pending from a same-deploy secret replacement).
245 // Absent that, prefer the deployed alias: regenerating would target a
246 // different resource if the generator's output for this id ever
247 // drifts.
248 const alias =
249 news.alias ?? output?.alias ?? (yield* createAlias(id, undefined));
250 return yield* reconcileProviderConfig({
251 accountId,
252 gatewayId: news.gatewayId as string,
253 alias,
254 providerSlug: news.providerSlug as string,
255 secretId: news.secretId as string,
256 defaultConfig: news.defaultConfig ?? false,
257 rateLimit: news.rateLimit,
258 rateLimitPeriod: news.rateLimitPeriod,
259 currentId: output?.providerConfigId,
260 });
261 }),
262 delete: Effect.fn(function* ({ output }) {
263 yield* aiGateway
264 .deleteProviderConfig({
265 accountId: output.accountId,
266 gatewayId: output.gatewayId,
267 id: output.providerConfigId,
268 })
269 // Cloudflare reports both a missing config and a missing parent
270 // gateway with code 7002 — either way it's already gone.
271 .pipe(Effect.catchTag("ProviderConfigNotFound", () => Effect.void));
272 }),
273 // Provider configs are nested under an AI Gateway, with no account-wide
274 // collection endpoint. Enumerate every gateway in the account, then fan
275 // out (bounded) over them and exhaustively paginate each gateway's
276 // provider configs, hydrating each item into the same Attributes shape
277 // `read` produces (via `toAttributes`). A gateway that disappears between
278 // enumeration and listing returns an empty list, so no per-item not-found
279 // mapping is needed.
280 list: Effect.fn(function* () {
281 const { accountId } = yield* yield* CloudflareEnvironment;
282
283 const gatewayIds = yield* aiGateway.listAiGateways
284 .pages({ accountId })
285 .pipe(
286 Stream.runCollect,
287 Effect.map((chunk) =>
288 Array.from(chunk).flatMap((page) =>
289 (page.result ?? []).map((gateway) => gateway.id),
290 ),
291 ),
292 );
293
294 const rows = yield* Effect.forEach(
295 gatewayIds,
296 (gatewayId) =>
297 aiGateway.listProviderConfigs.pages({ accountId, gatewayId }).pipe(
298 Stream.runCollect,
299 Effect.map((chunk) =>
300 Array.from(chunk).flatMap((page) =>
301 (page.result ?? []).map((config) =>
302 toAttributes(config, accountId),
303 ),
304 ),
305 ),
306 ),
307 { concurrency: 10 },
308 );
309 return rows.flat();
310 }),
311 });
312
313/**
314 * Reconcile a single BYOK provider config toward the desired props.
315 *
316 * A gateway allows only ONE config per (providerSlug, alias). There is no
317 * update API and no get endpoint, so we observe through the list and:
318 * - already-desired occupant → adopt it (idempotent no-op / re-run)
319 * - stale occupant → delete it, then create the desired one
320 * - no occupant → create the desired one
321 *
322 * `createProviderConfig` can still race and fail with
323 * `ProviderConfigAlreadyExists` when a leftover/sibling config the list had
324 * not yet surfaced (eventual consistency) occupies the slot. Retrying the
325 * whole observe→delete→create flow re-observes the now-visible occupant and
326 * converges it, so re-runs and leftover state self-heal instead of failing.
327 * Bounded so the engine never hangs.
328 */
329const reconcileProviderConfig = (desired: {
330 accountId: string;
331 gatewayId: string;
332 alias: string;
333 providerSlug: string;
334 secretId: string;
335 defaultConfig: boolean;
336 rateLimit: number | undefined;
337 rateLimitPeriod: number | undefined;
338 currentId: string | undefined;
339}) => {
340 const {
341 accountId,
342 gatewayId,
343 alias,
344 providerSlug,
345 secretId,
346 defaultConfig,
347 rateLimit,
348 rateLimitPeriod,
349 currentId,
350 } = desired;
351
352 const matchesDesired = (attrs: GatewayProviderAttributes) =>
353 attrs.alias === alias &&
354 attrs.providerSlug === providerSlug &&
355 attrs.secretId === secretId &&
356 attrs.defaultConfig === defaultConfig &&
357 attrs.rateLimit === rateLimit &&
358 (attrs.rateLimitPeriod ?? 60) === (rateLimitPeriod ?? 60);
359
360 return Effect.gen(function* () {
361 // Observe the config currently occupying this gateway's (slug, alias).
362 const configs = yield* listProviderConfigs(accountId, gatewayId);
363 const observed =
364 configs.find((c) => c.id === currentId) ??
365 configs.find((c) => c.alias === alias && c.providerSlug === providerSlug);
366
367 if (observed) {
368 const attrs = toAttributes(observed, accountId);
369 if (matchesDesired(attrs)) {
370 return attrs;
371 }
372 // No update API — delete the stale occupant before recreating.
373 yield* aiGateway
374 .deleteProviderConfig({ accountId, gatewayId, id: observed.id })
375 .pipe(Effect.catchTag("ProviderConfigNotFound", () => Effect.void));
376 }
377
378 // Ensure — create. The referenced Secrets Store secret deploys
379 // asynchronously (status `pending` → `active`), so retry the typed
380 // "secret was not found" error with bounded backoff.
381 const created = yield* aiGateway
382 .createProviderConfig({
383 accountId,
384 gatewayId,
385 alias,
386 providerSlug,
387 secretId,
388 defaultConfig,
389 ...(rateLimit !== undefined && { rateLimit }),
390 ...(rateLimitPeriod !== undefined && { rateLimitPeriod }),
391 })
392 .pipe(
393 Effect.retry({
394 while: (e) => e._tag === "ProviderConfigSecretNotFound",
395 schedule: Schedule.spaced("5 seconds"),
396 times: 10,
397 }),
398 );
399 return toAttributes(created, accountId);
400 }).pipe(
401 Effect.retry({
402 while: (e) => e._tag === "ProviderConfigAlreadyExists",
403 schedule: Schedule.spaced("1 second"),
404 times: 5,
405 }),
406 );
407};
408
409/**
410 * List all provider configs on a gateway. A missing gateway returns an
411 * empty list on this endpoint, so no not-found mapping is needed.
412 */
413const listProviderConfigs = (accountId: string, gatewayId: string) =>
414 aiGateway
415 .listProviderConfigs({ accountId, gatewayId, perPage: 50 })
416 .pipe(Effect.map((page) => page.result));
417
418const createAlias = (id: string, alias: string | undefined) =>
419 Effect.gen(function* () {
420 return alias ?? (yield* createPhysicalName({ id, lowercase: true }));
421 });
422
423const toAttributes = (
424 config:
425 | aiGateway.CreateProviderConfigResponse
426 | aiGateway.ListProviderConfigsResponse["result"][number],
427 accountId: string,
428): GatewayProviderAttributes => ({
429 providerConfigId: config.id,
430 accountId,
431 gatewayId: config.gatewayId,
432 alias: config.alias,
433 providerSlug: config.providerSlug,
434 secretId: config.secretId,
435 secretPreview: config.secretPreview,
436 // Cloudflare returns 0/1 here, not booleans — normalize at the boundary.
437 defaultConfig: Boolean(config.defaultConfig),
438 rateLimit: config.rateLimit ?? undefined,
439 rateLimitPeriod: config.rateLimitPeriod ?? undefined,
440 modifiedAt: config.modifiedAt,
441});