1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as aiGateway from "@distilled.cloud/cloudflare/ai-gateway";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
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
20// A gateway allows only one provider config per (providerSlug, alias). The
21// two cases in this file run concurrently (test.provider), so they each get
22// their OWN gateway to avoid colliding on the same slug+alias.
23const LIFECYCLE_GATEWAY_ID = "alchemy-test-aigw-pc";
24const LIST_GATEWAY_ID = "alchemy-test-aigw-pc-list";
25const PROVIDER_SLUG = "openai";
26const ALIAS = "default";
27// Cloudflare requires the Secrets Store secret backing a provider config to
28// be named exactly `{gatewayId}_{providerSlug}_{alias}`.
29const secretName = (gatewayId: string) =>
30 `${gatewayId}_${PROVIDER_SLUG}_${ALIAS}`;
31const SECRET_VALUE = "sk-alchemy-test-provider-config-1234567890";
32
33class ProviderConfigStillExists extends Data.TaggedError(
34 "ProviderConfigStillExists",
35) {}
36
37// There is no get endpoint for provider configs — verify through the list.
38// A missing parent gateway returns an empty list, so this also covers
39// post-destroy verification.
40const expectGone = (
41 accountId: string,
42 gatewayId: string,
43 providerConfigId: string,
44) =>
45 aiGateway.listProviderConfigs({ accountId, gatewayId, perPage: 50 }).pipe(
46 Effect.flatMap((page) =>
47 page.result.some((c) => c.id === providerConfigId)
48 ? Effect.fail(new ProviderConfigStillExists())
49 : Effect.void,
50 ),
51 Effect.retry({
52 while: (e): e is ProviderConfigStillExists =>
53 e instanceof ProviderConfigStillExists,
54 schedule: Schedule.max([
55 Schedule.exponential("250 millis"),
56 Schedule.recurs(10),
57 ]),
58 }),
59 );
60
61test.provider(
62 "create, noop, replace, delete a BYOK provider config",
63 (stack) =>
64 Effect.gen(function* () {
65 const { accountId } = yield* yield* CloudflareEnvironment;
66
67 yield* stack.destroy();
68
69 const program = (rateLimit?: number) =>
70 Effect.gen(function* () {
71 // Cloudflare allows a single Secrets Store per account — the
72 // resource adopts the existing one and never deletes it.
73 const store = yield* Cloudflare.SecretsStore.Store("PcStore");
74 const gateway = yield* Cloudflare.AI.Gateway("PcGateway", {
75 id: LIFECYCLE_GATEWAY_ID,
76 // BYOK resolution requires the gateway to reference the store.
77 storeId: store.storeId,
78 });
79 const secret = yield* Cloudflare.SecretsStore.Secret("PcSecret", {
80 store,
81 name: secretName(LIFECYCLE_GATEWAY_ID),
82 value: Redacted.make(SECRET_VALUE),
83 scopes: ["ai_gateway"],
84 });
85 const config = yield* Cloudflare.AI.GatewayProvider("Byok", {
86 gatewayId: gateway.gatewayId,
87 providerSlug: PROVIDER_SLUG,
88 alias: ALIAS,
89 secretId: secret.secretId,
90 defaultConfig: true,
91 ...(rateLimit !== undefined && {
92 rateLimit,
93 rateLimitPeriod: 60,
94 }),
95 });
96 return { secret, config };
97 });
98
99 const initial = yield* stack.deploy(program());
100
101 expect(initial.config.providerConfigId).toBeDefined();
102 expect(initial.config.accountId).toEqual(accountId);
103 expect(initial.config.gatewayId).toEqual(LIFECYCLE_GATEWAY_ID);
104 expect(initial.config.providerSlug).toEqual(PROVIDER_SLUG);
105 expect(initial.config.alias).toEqual(ALIAS);
106 expect(initial.config.secretId).toEqual(initial.secret.secretId);
107 expect(initial.config.defaultConfig).toBe(true);
108 expect(initial.config.rateLimit).toBeUndefined();
109
110 // Verify out-of-band via the API.
111 const live = yield* aiGateway.listProviderConfigs({
112 accountId,
113 gatewayId: LIFECYCLE_GATEWAY_ID,
114 perPage: 50,
115 });
116 const liveConfig = live.result.find(
117 (c) => c.id === initial.config.providerConfigId,
118 );
119 expect(liveConfig).toBeDefined();
120 expect(liveConfig!.alias).toEqual(ALIAS);
121 expect(liveConfig!.providerSlug).toEqual(PROVIDER_SLUG);
122 expect(liveConfig!.secretId).toEqual(initial.secret.secretId);
123
124 // Redeploying identical props is a no-op (still the same config).
125 const noop = yield* stack.deploy(program());
126 expect(noop.config.providerConfigId).toEqual(
127 initial.config.providerConfigId,
128 );
129
130 // Provider configs have no update API — adding a rate limit is a
131 // delete-first replacement (same provider slug + alias).
132 const limited = yield* stack.deploy(program(100));
133 expect(limited.config.providerConfigId).not.toEqual(
134 initial.config.providerConfigId,
135 );
136 expect(limited.config.rateLimit).toEqual(100);
137 expect(limited.config.rateLimitPeriod).toEqual(60);
138
139 // The replaced config is gone.
140 yield* expectGone(
141 accountId,
142 LIFECYCLE_GATEWAY_ID,
143 initial.config.providerConfigId,
144 );
145
146 yield* stack.destroy();
147
148 yield* expectGone(
149 accountId,
150 LIFECYCLE_GATEWAY_ID,
151 limited.config.providerConfigId,
152 );
153 }).pipe(logLevel),
154 {
155 tags: [
156 "provider:cloudflare",
157 "provider:cloudflare:ai",
158 "provider:cloudflare:secretsstore",
159 "live",
160 ],
161 },
162);
163
164test.provider(
165 "list enumerates the deployed provider config",
166 (stack) =>
167 Effect.gen(function* () {
168 yield* stack.destroy();
169
170 const deployed = yield* stack.deploy(
171 Effect.gen(function* () {
172 const store = yield* Cloudflare.SecretsStore.Store("PcListStore");
173 const gateway = yield* Cloudflare.AI.Gateway("PcListGateway", {
174 id: LIST_GATEWAY_ID,
175 storeId: store.storeId,
176 });
177 const secret = yield* Cloudflare.SecretsStore.Secret("PcListSecret", {
178 store,
179 name: secretName(LIST_GATEWAY_ID),
180 value: Redacted.make(SECRET_VALUE),
181 scopes: ["ai_gateway"],
182 });
183 return yield* Cloudflare.AI.GatewayProvider("ByokList", {
184 gatewayId: gateway.gatewayId,
185 providerSlug: PROVIDER_SLUG,
186 alias: ALIAS,
187 secretId: secret.secretId,
188 defaultConfig: true,
189 });
190 }),
191 );
192
193 const provider = yield* Provider.findProvider(
194 Cloudflare.AI.GatewayProvider,
195 );
196 const all = yield* provider.list();
197
198 expect(
199 all.some((c) => c.providerConfigId === deployed.providerConfigId),
200 ).toBe(true);
201 const found = all.find(
202 (c) => c.providerConfigId === deployed.providerConfigId,
203 )!;
204 expect(found.gatewayId).toEqual(LIST_GATEWAY_ID);
205 expect(found.providerSlug).toEqual(PROVIDER_SLUG);
206 expect(found.alias).toEqual(ALIAS);
207
208 yield* stack.destroy();
209 }).pipe(logLevel),
210 {
211 tags: [
212 "provider:cloudflare",
213 "provider:cloudflare:ai",
214 "provider:cloudflare:secretsstore",
215 "live",
216 ],
217 },
218);