alchemy 2.0.0-beta.54 → 2.0.0-beta.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "2.0.0-beta.54",
3
+ "version": "2.0.0-beta.55",
4
4
  "homepage": "https://alchemy.run",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sam Goodwin <sam@alchemy.run>",
@@ -291,15 +291,15 @@
291
291
  "@alchemy.run/node-utils": "0.0.5",
292
292
  "@aws-sdk/credential-providers": "^3.0.0",
293
293
  "@clack/prompts": "^0.11.0",
294
- "@distilled.cloud/aws": "^0.24.5",
295
- "@distilled.cloud/axiom": "^0.24.5",
296
- "@distilled.cloud/cloudflare": "^0.24.5",
294
+ "@distilled.cloud/aws": "^0.24.8",
295
+ "@distilled.cloud/axiom": "^0.24.8",
296
+ "@distilled.cloud/cloudflare": "^0.24.8",
297
297
  "@distilled.cloud/cloudflare-rolldown-plugin": "0.10.12",
298
298
  "@distilled.cloud/cloudflare-runtime": "0.10.12",
299
299
  "@distilled.cloud/cloudflare-vite-plugin": "0.10.12",
300
- "@distilled.cloud/core": "^0.24.5",
301
- "@distilled.cloud/neon": "^0.24.5",
302
- "@distilled.cloud/planetscale": "^0.24.5",
300
+ "@distilled.cloud/core": "^0.24.8",
301
+ "@distilled.cloud/neon": "^0.24.8",
302
+ "@distilled.cloud/planetscale": "^0.24.8",
303
303
  "@effect/vitest": ">=4.0.0-beta.78 || >=4.0.0",
304
304
  "@libsql/client": "^0.17.0",
305
305
  "@octokit/rest": "^22.0.1",
@@ -0,0 +1,368 @@
1
+ import * as aiGateway from "@distilled.cloud/cloudflare/ai-gateway";
2
+ import * as Data from "effect/Data";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Schedule from "effect/Schedule";
5
+ import { deepEqual, isResolved } from "../../Diff.ts";
6
+ import * as Provider from "../../Provider.ts";
7
+ import { Resource } from "../../Resource.ts";
8
+ import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9
+ import type { Providers } from "../Providers.ts";
10
+
11
+ /** Reset cadence for the spend window. */
12
+ export type AiGatewaySpendingLimitDuration = "daily" | "weekly" | "monthly";
13
+
14
+ /**
15
+ * Enforcement algorithm — `fixed` resets the counter on the window boundary,
16
+ * `sliding` tracks a rolling window. Mirrors the gateway's rate-limiting
17
+ * `technique` semantics.
18
+ */
19
+ export type AiGatewaySpendingLimitStrategy = "fixed" | "sliding";
20
+
21
+ export type AiGatewaySpendingLimitTopUp = {
22
+ /**
23
+ * Top-up amount, **in cents** (Cloudflare's minimum is `10_00` =
24
+ * $10.00). Used both for the one-time bootstrap charge and as the
25
+ * recharge amount when `autoRecharge` is enabled. Charged to the
26
+ * account's default payment method via a Stripe PaymentIntent.
27
+ */
28
+ amount: number;
29
+ /**
30
+ * Automatically recharge the account by `amount` whenever the credit
31
+ * balance drops below `threshold` (Cloudflare's auto top-up config,
32
+ * `POST /ai-gateway/billing/topup/config`). Set to `false` to remove
33
+ * any auto top-up config from the account.
34
+ *
35
+ * @default true
36
+ */
37
+ autoRecharge?: boolean;
38
+ /**
39
+ * Balance threshold, **in cents**, that triggers an auto recharge
40
+ * (Cloudflare's minimum is `5_00` = $5.00). Only meaningful while
41
+ * `autoRecharge` is enabled.
42
+ *
43
+ * @default 5_00 ($5.00)
44
+ */
45
+ threshold?: number;
46
+ };
47
+
48
+ export type AiGatewaySpendingLimitProps = {
49
+ /**
50
+ * Spending limit, **in cents** (Cloudflare's native unit; minimum `1_00` =
51
+ * $1.00). Tracks cumulative AI Gateway spend across the account.
52
+ *
53
+ * @default 10_00 ($10.00)
54
+ */
55
+ amount?: number;
56
+ /**
57
+ * Window over which `amount` accumulates before reset/roll-off.
58
+ */
59
+ duration: AiGatewaySpendingLimitDuration;
60
+ /**
61
+ * Enforcement algorithm.
62
+ *
63
+ * @default "fixed"
64
+ */
65
+ strategy?: AiGatewaySpendingLimitStrategy;
66
+ /**
67
+ * Cloudflare refuses to set an account spending limit until the account
68
+ * has loaded Unified Billing credits via at least one **manual top-up**
69
+ * (`NO_MANUAL_TOPUP`). The provider reconciles this against the live
70
+ * billing state: it observes `first_topup_success` on the billing API
71
+ * and only makes the one-time credit top-up (charging the account's
72
+ * default payment method) when the account has never topped up. Once
73
+ * the account is bootstrapped this prop is inert and never charges
74
+ * again.
75
+ *
76
+ * Requires a default payment method on the account; if the payment
77
+ * needs interactive confirmation (e.g. 3-D Secure), the resource fails
78
+ * with `AiGatewaySpendingLimitTopupRequired` and the top-up must be
79
+ * completed in the dashboard.
80
+ */
81
+ topUp: AiGatewaySpendingLimitTopUp;
82
+ };
83
+
84
+ export type AiGatewaySpendingLimit = Resource<
85
+ "Cloudflare.AiGatewaySpendingLimit",
86
+ AiGatewaySpendingLimitProps,
87
+ {
88
+ accountId: string;
89
+ amount: number;
90
+ duration: AiGatewaySpendingLimitDuration;
91
+ strategy: AiGatewaySpendingLimitStrategy;
92
+ /** Whether Cloudflare currently reports the limit as active. */
93
+ enabled: boolean;
94
+ /**
95
+ * The auto top-up config active on the account, or `undefined` when
96
+ * auto recharge is disabled.
97
+ */
98
+ autoRecharge: { amount: number; threshold: number } | undefined;
99
+ },
100
+ never,
101
+ Providers
102
+ >;
103
+
104
+ /**
105
+ * The account has never loaded Unified Billing credits via a manual top-up,
106
+ * so Cloudflare refuses to set an account-level spending limit
107
+ * (`NO_MANUAL_TOPUP`, code 1000). Resolve by setting the `topUp` prop so
108
+ * the provider bootstraps the account automatically, or by making a
109
+ * one-time credit top-up in the dashboard (AI > AI Gateway > Billing).
110
+ */
111
+ export class AiGatewaySpendingLimitTopupRequired extends Data.TaggedError(
112
+ "AiGatewaySpendingLimitTopupRequired",
113
+ )<{
114
+ readonly accountId: string;
115
+ readonly message: string;
116
+ }> {}
117
+
118
+ export const isAiGatewaySpendingLimit = (
119
+ value: unknown,
120
+ ): value is AiGatewaySpendingLimit =>
121
+ typeof value === "object" &&
122
+ value !== null &&
123
+ "Type" in value &&
124
+ (value as AiGatewaySpendingLimit).Type ===
125
+ "Cloudflare.AiGatewaySpendingLimit";
126
+
127
+ /**
128
+ * The account-level Cloudflare AI Gateway spending limit — a hard dollar cap
129
+ * on cumulative spend across every gateway in the account's Unified Billing.
130
+ *
131
+ * This is a **per-account singleton**: Cloudflare stores a single limit per
132
+ * account (`/accounts/{account_id}/ai-gateway/billing/spending-limit`), so
133
+ * declaring more than one `AiGatewaySpendingLimit` against the same account
134
+ * will make them fight over the same remote object. Declare exactly one.
135
+ *
136
+ * @section Setting a spend cap
137
+ * @example Monthly cap
138
+ * ```ts
139
+ * import * as Cloudflare from "alchemy/Cloudflare";
140
+ *
141
+ * // Cloudflare requires one manual credit top-up before a spending limit
142
+ * // can be set. `topUp` reconciles that requirement: the provider observes
143
+ * // the billing state and only charges the account's default payment
144
+ * // method if the account has never topped up.
145
+ * const cap = yield* Cloudflare.AiGatewaySpendingLimit("ai-spend-cap", {
146
+ * amount: 250_00, // cents -> $250.00 (minimum 1_00 = $1.00)
147
+ * duration: "monthly",
148
+ * topUp: { amount: 10_00 }, // cents -> $10.00 (Cloudflare minimum)
149
+ * });
150
+ * ```
151
+ *
152
+ * @example Sliding daily window
153
+ * ```ts
154
+ * const cap = yield* Cloudflare.AiGatewaySpendingLimit("ai-spend-cap", {
155
+ * amount: 50_00, // cents -> $50.00
156
+ * duration: "daily",
157
+ * strategy: "sliding",
158
+ * topUp: { amount: 10_00 },
159
+ * });
160
+ * ```
161
+ *
162
+ * @section Auto recharge
163
+ * @example Customize the recharge threshold
164
+ * ```ts
165
+ * // Auto recharge is on by default: when the credit balance drops below
166
+ * // `threshold`, Cloudflare recharges by `amount` automatically.
167
+ * const cap = yield* Cloudflare.AiGatewaySpendingLimit("ai-spend-cap", {
168
+ * amount: 250_00,
169
+ * duration: "monthly",
170
+ * topUp: { amount: 20_00, threshold: 10_00 }, // recharge $20 below $10
171
+ * });
172
+ * ```
173
+ *
174
+ * @example Disable auto recharge
175
+ * ```ts
176
+ * const cap = yield* Cloudflare.AiGatewaySpendingLimit("ai-spend-cap", {
177
+ * amount: 250_00,
178
+ * duration: "monthly",
179
+ * topUp: { amount: 10_00, autoRecharge: false }, // one-time bootstrap only
180
+ * });
181
+ * ```
182
+ */
183
+ export const AiGatewaySpendingLimit = Resource<AiGatewaySpendingLimit>(
184
+ "Cloudflare.AiGatewaySpendingLimit",
185
+ );
186
+
187
+ export const AiGatewaySpendingLimitProvider = () =>
188
+ Provider.succeed(AiGatewaySpendingLimit, {
189
+ stables: ["accountId"],
190
+ diff: Effect.fn(function* ({ olds = {}, news, output }) {
191
+ const { accountId } = yield* yield* CloudflareEnvironment;
192
+ if (!isResolved(news)) return undefined;
193
+ // The limit is keyed solely by account; an account change is the
194
+ // only structural identity change and forces a replace.
195
+ if ((output?.accountId ?? accountId) !== accountId) {
196
+ return { action: "replace" } as const;
197
+ }
198
+ const oldMutable = mutable(output ?? desired(olds));
199
+ const nextMutable = mutable(desired(news));
200
+ if (!deepEqual(oldMutable, nextMutable)) {
201
+ return { action: "update" } as const;
202
+ }
203
+ }),
204
+ reconcile: Effect.fn(function* ({ news }) {
205
+ const { accountId } = yield* yield* CloudflareEnvironment;
206
+ const next = desired(news);
207
+
208
+ // Observe — Cloudflare gates the account spending limit behind
209
+ // Unified Billing: the account must have completed one manual
210
+ // credit top-up or the limit POST is rejected (NoManualTopup).
211
+ // Read the live billing state to know whether it's bootstrapped.
212
+ const billing = yield* aiGateway.creditBalanceBilling({ accountId });
213
+
214
+ // Ensure — one-time bootstrap charge, only when the observed state
215
+ // says the account has never topped up. This is the only call that
216
+ // charges the card; once `firstTopupSuccess` is true it is never
217
+ // made again.
218
+ if (billing.firstTopupSuccess !== true) {
219
+ yield* aiGateway.createBillingTopup({
220
+ accountId,
221
+ amount: news.topUp.amount,
222
+ });
223
+ }
224
+
225
+ // Sync — upsert the desired shape onto this per-account singleton;
226
+ // adoption, drift, and routine updates all converge through the one
227
+ // POST. A freshly submitted top-up settles asynchronously, so retry
228
+ // while Cloudflare still reports NoManualTopup — bounded, so a
229
+ // payment stuck on interactive confirmation fails fast with an
230
+ // actionable error.
231
+ yield* aiGateway
232
+ .createBillingSpendingLimit({
233
+ accountId,
234
+ amount: next.amount,
235
+ duration: next.duration,
236
+ strategy: next.strategy,
237
+ })
238
+ .pipe(
239
+ Effect.retry({
240
+ schedule: Schedule.exponential("1 second"),
241
+ while: (e) => e._tag === "NoManualTopup",
242
+ times: 8,
243
+ }),
244
+ Effect.catchTag("NoManualTopup", () =>
245
+ Effect.fail(
246
+ new AiGatewaySpendingLimitTopupRequired({
247
+ accountId,
248
+ message:
249
+ `A credit top-up of ${news.topUp.amount} cents was submitted for ` +
250
+ `account ${accountId} but Cloudflare still reports NO_MANUAL_TOPUP. ` +
251
+ "The payment may require interactive confirmation (e.g. 3-D Secure) — " +
252
+ "complete it in the Cloudflare dashboard (AI > AI Gateway > Billing) " +
253
+ "and retry.",
254
+ }),
255
+ ),
256
+ ),
257
+ );
258
+
259
+ // Sync — auto-recharge config: diff the observed `topupConfig`
260
+ // (already read above) against the desired shape and apply only the
261
+ // delta, skipping the API entirely on a no-op. Configuring auto
262
+ // top-up does not charge by itself — Cloudflare only charges later
263
+ // if the balance ever drops below the threshold.
264
+ const observedRecharge = observedAutoRecharge(billing.topupConfig);
265
+ if (next.autoRecharge === undefined) {
266
+ if (observedRecharge !== undefined) {
267
+ yield* aiGateway.deleteBillingTopupConfig({ accountId });
268
+ }
269
+ } else if (!deepEqual(observedRecharge, next.autoRecharge)) {
270
+ yield* aiGateway.createBillingTopupConfig({
271
+ accountId,
272
+ amount: next.autoRecharge.amount,
273
+ threshold: next.autoRecharge.threshold,
274
+ });
275
+ }
276
+
277
+ // The create returns no body and the stored state is exactly what
278
+ // we send, so report the applied shape directly rather than paying
279
+ // a read-back GET.
280
+ return { accountId, ...next, enabled: true };
281
+ }),
282
+ delete: Effect.fn(function* ({ output }) {
283
+ // DELETE on the spending-limit endpoint is idempotent — removing an
284
+ // absent limit is a server-side no-op.
285
+ yield* aiGateway.deleteBillingSpendingLimit({
286
+ accountId: output.accountId,
287
+ });
288
+ // The auto-recharge config is owned by this resource; remove it so
289
+ // the account stops recharging once the cap is gone.
290
+ if (output.autoRecharge !== undefined) {
291
+ yield* aiGateway.deleteBillingTopupConfig({
292
+ accountId: output.accountId,
293
+ });
294
+ }
295
+ }),
296
+ read: Effect.fn(function* ({ olds, output }) {
297
+ const { accountId } = yield* yield* CloudflareEnvironment;
298
+ const acct = output?.accountId ?? accountId;
299
+ const current = yield* aiGateway.getBillingSpendingLimit({
300
+ accountId: acct,
301
+ });
302
+ // A disabled limit means there is no resource to track.
303
+ if (!current.enabled) return undefined;
304
+ const billing = yield* aiGateway.creditBalanceBilling({
305
+ accountId: acct,
306
+ });
307
+ // GET config fields are nullable; prefer the server's view and fall
308
+ // back to the prior known shape.
309
+ const prior = output ?? desired(olds);
310
+ return {
311
+ accountId: acct,
312
+ amount: current.config.amount ?? prior.amount,
313
+ duration: (current.config.duration ??
314
+ prior.duration) as AiGatewaySpendingLimitDuration,
315
+ strategy: (current.config.strategy ??
316
+ prior.strategy) as AiGatewaySpendingLimitStrategy,
317
+ enabled: current.enabled,
318
+ autoRecharge: observedAutoRecharge(billing.topupConfig),
319
+ };
320
+ }),
321
+ });
322
+
323
+ // Normalize the observed `topup_config` into the attribute shape. An
324
+ // unset config comes back as zeros/nulls rather than being absent.
325
+ const observedAutoRecharge = (config: {
326
+ amount: number | null;
327
+ threshold: number | null;
328
+ }) =>
329
+ (config.amount ?? 0) > 0
330
+ ? { amount: config.amount!, threshold: config.threshold ?? 0 }
331
+ : undefined;
332
+
333
+ // Resolve the desired config from props (or from cached attributes /
334
+ // prior props), applying defaults. Accepts the loose overlap shared by
335
+ // Props (which carry `topUp`), Attributes (which carry the resolved
336
+ // `autoRecharge`), `olds`, and `output`.
337
+ const desired = (props?: {
338
+ amount?: number;
339
+ duration?: string;
340
+ strategy?: string;
341
+ topUp?: AiGatewaySpendingLimitTopUp;
342
+ autoRecharge?: { amount: number; threshold: number } | undefined;
343
+ }) => ({
344
+ amount: props?.amount ?? 10_00,
345
+ duration: (props?.duration ?? "monthly") as AiGatewaySpendingLimitDuration,
346
+ strategy: (props?.strategy ?? "fixed") as AiGatewaySpendingLimitStrategy,
347
+ autoRecharge:
348
+ props?.autoRecharge ??
349
+ (props?.topUp !== undefined && props.topUp.autoRecharge !== false
350
+ ? {
351
+ amount: props.topUp.amount,
352
+ threshold: props.topUp.threshold ?? 5_00,
353
+ }
354
+ : undefined),
355
+ });
356
+
357
+ // Fields that, when changed, drive an in-place update vs. a replace.
358
+ const mutable = (v: {
359
+ amount: number;
360
+ duration: string;
361
+ strategy: string;
362
+ autoRecharge?: { amount: number; threshold: number } | undefined;
363
+ }) => ({
364
+ amount: v.amount,
365
+ duration: v.duration,
366
+ strategy: v.strategy,
367
+ autoRecharge: v.autoRecharge,
368
+ });
@@ -1,3 +1,4 @@
1
1
  export * from "./AiGateway.ts";
2
2
  export * from "./AiGatewayBinding.ts";
3
3
  export * from "./LanguageModel.ts";
4
+ export * from "./AiGatewaySpendingLimit.ts";
@@ -1,3 +1,4 @@
1
+ export * from "./Fetcher.ts";
1
2
  export * from "./Workers/InferEnv.ts";
2
3
  export * from "./Workers/Rpc.ts";
3
4
  export * from "./Workers/RpcAsync.ts";
@@ -65,11 +65,13 @@ export const toCloudflareFetcher = Effect.fnUntraced(function* (
65
65
  } satisfies cf.Fetcher;
66
66
  });
67
67
 
68
- export const fromCloudflareFetcher = (fetcher: cf.Fetcher): Fetcher => {
68
+ export const fromCloudflareFetcher = (
69
+ fetcher: cf.Fetcher | globalThis.Fetcher,
70
+ ): Fetcher => {
69
71
  const fetch = (request: Request) =>
70
72
  Effect.promise((signal) =>
71
- fetcher.fetch(request as any as cf.Request, {
72
- signal: signal as cf.AbortSignal,
73
+ (fetcher as globalThis.Fetcher).fetch(request, {
74
+ signal: signal,
73
75
  }),
74
76
  );
75
77
 
@@ -153,7 +155,9 @@ export const toHttpClient = (fetcher: {
153
155
  );
154
156
  });
155
157
 
156
- export const fromCloudflareSocket = (cfSocket: cf.Socket): Socket.Socket => {
158
+ export const fromCloudflareSocket = (
159
+ cfSocket: globalThis.Socket | cf.Socket,
160
+ ): Socket.Socket => {
157
161
  const latch = Latch.makeUnsafe(false);
158
162
  let currentFiberSet: FiberSet.FiberSet<any, any> | undefined;
159
163
  let writerRef: WritableStreamDefaultWriter<Uint8Array> | undefined;
@@ -61,6 +61,7 @@ export const providers = () =>
61
61
  ApiToken.AccountApiToken,
62
62
  ApiToken.UserApiToken,
63
63
  AiGateway.AiGateway,
64
+ AiGateway.AiGatewaySpendingLimit,
64
65
  AiGateway.AiGatewayBindingPolicy,
65
66
  AnalyticsEngine.AnalyticsEngineDatasetBindingPolicy,
66
67
  Artifacts.ArtifactsBindingPolicy,
@@ -118,6 +119,7 @@ export const providers = () =>
118
119
  ApiToken.AccountApiTokenProvider(),
119
120
  ApiToken.UserApiTokenProvider(),
120
121
  AiGateway.AiGatewayProvider(),
122
+ AiGateway.AiGatewaySpendingLimitProvider(),
121
123
  AiGateway.AiGatewayBindingPolicyLive,
122
124
  AnalyticsEngine.AnalyticsEngineDatasetBindingPolicyLive,
123
125
  Artifacts.ArtifactsBindingPolicyLive,
@@ -5,6 +5,7 @@ import * as Option from "effect/Option";
5
5
  import * as Predicate from "effect/Predicate";
6
6
  import type { Scope } from "effect/Scope";
7
7
  import * as Stream from "effect/Stream";
8
+ import { AlchemyContext } from "../AlchemyContext.ts";
8
9
  import { InstanceId } from "../InstanceId.ts";
9
10
  import type { Platform } from "../Platform.ts";
10
11
  import * as Provider from "../Provider.ts";
@@ -129,7 +130,8 @@ const layerFallback = <I, S>(
129
130
  */
130
131
  export const providerServices = <ROut, E, RIn>(
131
132
  self: Layer.Layer<ROut, E, RIn>,
132
- ): Layer.Layer<ROut, E, RIn> => providerServicesEffect(Effect.succeed(self));
133
+ ): Layer.Layer<ROut, E, RIn | AlchemyContext> =>
134
+ providerServicesEffect(Effect.succeed(self));
133
135
 
134
136
  /**
135
137
  * Conditionally constructs a layer for use by an RpcProvider.
@@ -139,10 +141,12 @@ export const providerServices = <ROut, E, RIn>(
139
141
  */
140
142
  export const providerServicesEffect = <A, E1, R1, E, R>(
141
143
  self: Effect.Effect<Layer.Layer<A, E1, R1>, E, R>,
142
- ): Layer.Layer<A, E | E1, R1 | Exclude<R, Scope>> =>
143
- Effect.serviceOption(RpcProviderProxy).pipe(
144
- Effect.flatMap((client) =>
145
- client._tag === "None" ? self : (Effect.succeed(Layer.empty) as never),
144
+ ): Layer.Layer<A, E | E1, R1 | Exclude<R, Scope> | AlchemyContext> =>
145
+ Effect.zip(AlchemyContext, Effect.serviceOption(RpcProviderProxy)).pipe(
146
+ Effect.flatMap(([context, client]) =>
147
+ context.dev && client._tag === "None"
148
+ ? self
149
+ : (Effect.succeed(Layer.empty) as never),
146
150
  ),
147
151
  Layer.unwrap,
148
152
  );