alchemy 2.0.0-beta.53 → 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.
Files changed (94) hide show
  1. package/bin/exec.js +1 -1
  2. package/bin/exec.js.map +1 -1
  3. package/lib/Cloudflare/AiGateway/AiGatewaySpendingLimit.d.ts +165 -0
  4. package/lib/Cloudflare/AiGateway/AiGatewaySpendingLimit.d.ts.map +1 -0
  5. package/lib/Cloudflare/AiGateway/AiGatewaySpendingLimit.js +232 -0
  6. package/lib/Cloudflare/AiGateway/AiGatewaySpendingLimit.js.map +1 -0
  7. package/lib/Cloudflare/AiGateway/index.d.ts +1 -0
  8. package/lib/Cloudflare/AiGateway/index.d.ts.map +1 -1
  9. package/lib/Cloudflare/AiGateway/index.js +1 -0
  10. package/lib/Cloudflare/AiGateway/index.js.map +1 -1
  11. package/lib/Cloudflare/Bridge.d.ts +1 -0
  12. package/lib/Cloudflare/Bridge.d.ts.map +1 -1
  13. package/lib/Cloudflare/Bridge.js +1 -0
  14. package/lib/Cloudflare/Bridge.js.map +1 -1
  15. package/lib/Cloudflare/Fetcher.d.ts +2 -2
  16. package/lib/Cloudflare/Fetcher.d.ts.map +1 -1
  17. package/lib/Cloudflare/Fetcher.js.map +1 -1
  18. package/lib/Cloudflare/Hyperdrive/Hyperdrive.d.ts.map +1 -1
  19. package/lib/Cloudflare/Hyperdrive/Hyperdrive.js +6 -6
  20. package/lib/Cloudflare/Hyperdrive/Hyperdrive.js.map +1 -1
  21. package/lib/Cloudflare/Local.js +5 -2
  22. package/lib/Cloudflare/Local.js.map +1 -1
  23. package/lib/Cloudflare/LocalRuntime.d.ts +21 -0
  24. package/lib/Cloudflare/LocalRuntime.d.ts.map +1 -0
  25. package/lib/Cloudflare/LocalRuntime.js +40 -0
  26. package/lib/Cloudflare/LocalRuntime.js.map +1 -0
  27. package/lib/Cloudflare/Providers.d.ts +1 -1
  28. package/lib/Cloudflare/Providers.d.ts.map +1 -1
  29. package/lib/Cloudflare/Providers.js +3 -1
  30. package/lib/Cloudflare/Providers.js.map +1 -1
  31. package/lib/Cloudflare/Queue/Queue.d.ts +4 -1
  32. package/lib/Cloudflare/Queue/Queue.d.ts.map +1 -1
  33. package/lib/Cloudflare/Queue/Queue.js +61 -2
  34. package/lib/Cloudflare/Queue/Queue.js.map +1 -1
  35. package/lib/Cloudflare/Queue/QueueConsumer.d.ts +32 -26
  36. package/lib/Cloudflare/Queue/QueueConsumer.d.ts.map +1 -1
  37. package/lib/Cloudflare/Queue/QueueConsumer.js +73 -3
  38. package/lib/Cloudflare/Queue/QueueConsumer.js.map +1 -1
  39. package/lib/Cloudflare/Workers/Assets.js +2 -2
  40. package/lib/Cloudflare/Workers/Assets.js.map +1 -1
  41. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts +3 -5
  42. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -1
  43. package/lib/Cloudflare/Workers/LocalWorkerProvider.js +32 -25
  44. package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -1
  45. package/lib/Cloudflare/Workers/Worker.d.ts +2 -4
  46. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  47. package/lib/Cloudflare/Workers/Worker.js +5 -7
  48. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  49. package/lib/Local/ProviderLayer.d.ts +10 -0
  50. package/lib/Local/ProviderLayer.d.ts.map +1 -0
  51. package/lib/Local/ProviderLayer.js +7 -0
  52. package/lib/Local/ProviderLayer.js.map +1 -0
  53. package/lib/Local/RpcProvider.d.ts +4 -3
  54. package/lib/Local/RpcProvider.d.ts.map +1 -1
  55. package/lib/Local/RpcProvider.js +6 -2
  56. package/lib/Local/RpcProvider.js.map +1 -1
  57. package/lib/Local/RpcProviderProxy.js.map +1 -1
  58. package/lib/Local/RpcSerialization.js +2 -2
  59. package/lib/Local/RpcSerialization.js.map +1 -1
  60. package/lib/Planetscale/Branch.d.ts.map +1 -1
  61. package/lib/Planetscale/Branch.js +25 -3
  62. package/lib/Planetscale/Branch.js.map +1 -1
  63. package/lib/Planetscale/Database.d.ts +1 -1
  64. package/lib/Planetscale/Postgres/PostgresClusterSize.d.ts +1 -1
  65. package/lib/Planetscale/Postgres/PostgresClusterSize.d.ts.map +1 -1
  66. package/lib/Planetscale/Postgres/PostgresClusterSize.js +13 -3
  67. package/lib/Planetscale/Postgres/PostgresClusterSize.js.map +1 -1
  68. package/lib/Planetscale/Util.d.ts +1 -1
  69. package/lib/Planetscale/Util.d.ts.map +1 -1
  70. package/lib/Planetscale/Util.js +7 -5
  71. package/lib/Planetscale/Util.js.map +1 -1
  72. package/lib/tsconfig.test.tsbuildinfo +1 -1
  73. package/package.json +12 -12
  74. package/src/Cloudflare/AiGateway/AiGatewaySpendingLimit.ts +368 -0
  75. package/src/Cloudflare/AiGateway/index.ts +1 -0
  76. package/src/Cloudflare/Bridge.ts +1 -0
  77. package/src/Cloudflare/Fetcher.ts +8 -4
  78. package/src/Cloudflare/Hyperdrive/Hyperdrive.ts +6 -8
  79. package/src/Cloudflare/Local.ts +9 -5
  80. package/src/Cloudflare/LocalRuntime.ts +70 -0
  81. package/src/Cloudflare/Providers.ts +4 -0
  82. package/src/Cloudflare/Queue/Queue.ts +73 -2
  83. package/src/Cloudflare/Queue/QueueConsumer.ts +130 -28
  84. package/src/Cloudflare/Workers/Assets.ts +2 -2
  85. package/src/Cloudflare/Workers/LocalWorkerProvider.ts +39 -30
  86. package/src/Cloudflare/Workers/Worker.ts +5 -26
  87. package/src/Local/ProviderLayer.ts +22 -0
  88. package/src/Local/RpcProvider.ts +11 -5
  89. package/src/Local/RpcProviderProxy.ts +2 -2
  90. package/src/Local/RpcSerialization.ts +2 -2
  91. package/src/Planetscale/Branch.ts +38 -10
  92. package/src/Planetscale/Database.ts +1 -1
  93. package/src/Planetscale/Postgres/PostgresClusterSize.ts +16 -2
  94. package/src/Planetscale/Util.ts +7 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "2.0.0-beta.53",
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",
297
- "@distilled.cloud/cloudflare-rolldown-plugin": "0.10.11",
298
- "@distilled.cloud/cloudflare-runtime": "0.10.11",
299
- "@distilled.cloud/cloudflare-vite-plugin": "0.10.11",
300
- "@distilled.cloud/core": "^0.24.5",
301
- "@distilled.cloud/neon": "^0.24.5",
302
- "@distilled.cloud/planetscale": "^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
+ "@distilled.cloud/cloudflare-rolldown-plugin": "0.10.12",
298
+ "@distilled.cloud/cloudflare-runtime": "0.10.12",
299
+ "@distilled.cloud/cloudflare-vite-plugin": "0.10.12",
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",
@@ -326,16 +326,16 @@
326
326
  },
327
327
  "devDependencies": {
328
328
  "@clack/prompts": "^0.11.0",
329
- "@cloudflare/workers-types": "^4.20250805.0",
330
329
  "@cloudflare/puppeteer": "^1.1.0",
330
+ "@cloudflare/workers-types": "^4.20250805.0",
331
331
  "@effect/platform-bun": ">=4.0.0-beta.78 || >=4.0.0",
332
332
  "@effect/platform-node": ">=4.0.0-beta.78 || >=4.0.0",
333
333
  "@effect/platform-node-shared": ">=4.0.0-beta.78 || >=4.0.0",
334
334
  "@oxc-project/types": "^0.127.0",
335
- "@types/pg": "^8.11.0",
336
335
  "@types/aws-lambda": "^8.10.152",
337
336
  "@types/bun": "latest",
338
337
  "@types/node": "latest",
338
+ "@types/pg": "^8.11.0",
339
339
  "@types/picomatch": "^4.0.0",
340
340
  "@types/react": "^19.2.2",
341
341
  "@types/ws": "^8.18.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;
@@ -9,6 +9,7 @@ import { createPhysicalName } from "../../PhysicalName.ts";
9
9
  import * as Provider from "../../Provider.ts";
10
10
  import { Resource } from "../../Resource.ts";
11
11
  import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12
+ import { generateLocalId, isLiveId } from "../LocalRuntime.ts";
12
13
  import type { Providers } from "../Providers.ts";
13
14
  import { HyperdriveBinding } from "./HyperdriveBinding.ts";
14
15
 
@@ -182,7 +183,7 @@ export const HyperdriveProvider = () =>
182
183
  if (oldName !== name) {
183
184
  return { action: "replace" } as const;
184
185
  }
185
- if (!isHyperdriveId(output?.hyperdriveId)) {
186
+ if (!isLiveId(output?.hyperdriveId)) {
186
187
  return { action: "update" };
187
188
  }
188
189
  }),
@@ -193,7 +194,7 @@ export const HyperdriveProvider = () =>
193
194
  return output;
194
195
  }
195
196
 
196
- if (isHyperdriveId(output?.hyperdriveId)) {
197
+ if (isLiveId(output?.hyperdriveId)) {
197
198
  return yield* hyperdrive
198
199
  .getConfig({
199
200
  accountId: output.accountId,
@@ -248,7 +249,7 @@ export const HyperdriveProvider = () =>
248
249
  const ctx = yield* AlchemyContext;
249
250
  if (ctx.dev) {
250
251
  return {
251
- hyperdriveId: output?.hyperdriveId ?? `dev:${crypto.randomUUID()}`,
252
+ hyperdriveId: output?.hyperdriveId ?? generateLocalId(),
252
253
  name,
253
254
  accountId: output?.accountId ?? accountId,
254
255
  origin: news.origin,
@@ -268,7 +269,7 @@ export const HyperdriveProvider = () =>
268
269
  // to update; otherwise we createConfig and fall back to "find by
269
270
  // name then update" if Cloudflare reports the name is already in
270
271
  // use (race or a cold-start adoption).
271
- const synced = isHyperdriveId(output?.hyperdriveId)
272
+ const synced = isLiveId(output?.hyperdriveId)
272
273
  ? yield* hyperdrive.updateConfig({
273
274
  accountId: output.accountId,
274
275
  hyperdriveId: output.hyperdriveId,
@@ -304,7 +305,7 @@ export const HyperdriveProvider = () =>
304
305
  };
305
306
  }),
306
307
  delete: Effect.fn(function* ({ output }) {
307
- if (!isHyperdriveId(output.hyperdriveId)) return;
308
+ if (!isLiveId(output.hyperdriveId)) return;
308
309
 
309
310
  yield* hyperdrive
310
311
  .deleteConfig({
@@ -341,9 +342,6 @@ export const defaultPort = (scheme: HyperdriveScheme): number =>
341
342
  const unwrap = (v: string | Redacted.Redacted<string>): string =>
342
343
  Redacted.isRedacted(v) ? Redacted.value(v) : v;
343
344
 
344
- const isHyperdriveId = (maybeId: string | undefined): maybeId is string =>
345
- typeof maybeId === "string" && !maybeId.startsWith("dev:");
346
-
347
345
  /**
348
346
  * Build the request body shape that the distilled `createConfig`/`updateConfig`
349
347
  * methods accept. Secrets are unwrapped here because the distilled TS types
@@ -3,10 +3,10 @@ import * as RpcServer from "../Local/RpcServer.ts";
3
3
  import { CloudflareAuth } from "./Auth/AuthProvider.ts";
4
4
  import * as CloudflareEnvironment from "./CloudflareEnvironment.ts";
5
5
  import * as Credentials from "./Credentials.ts";
6
- import {
7
- LocalWorkerProvider,
8
- localRuntimeServices,
9
- } from "./Workers/LocalWorkerProvider.ts";
6
+ import { localRuntimeServices } from "./LocalRuntime.ts";
7
+ import { QueueProviderLocal } from "./Queue/Queue.ts";
8
+ import { QueueConsumerProviderLocal } from "./Queue/QueueConsumer.ts";
9
+ import { LocalWorkerProvider } from "./Workers/LocalWorkerProvider.ts";
10
10
 
11
11
  const cloudflareServices = Layer.provide(
12
12
  Layer.merge(
@@ -16,7 +16,11 @@ const cloudflareServices = Layer.provide(
16
16
  CloudflareAuth,
17
17
  );
18
18
 
19
- LocalWorkerProvider().pipe(
19
+ Layer.mergeAll(
20
+ LocalWorkerProvider(),
21
+ QueueProviderLocal(),
22
+ QueueConsumerProviderLocal(),
23
+ ).pipe(
20
24
  Layer.provide(localRuntimeServices()),
21
25
  Layer.provide(cloudflareServices),
22
26
  RpcServer.launch,
@@ -0,0 +1,70 @@
1
+ import { layerRuntime } from "@distilled.cloud/cloudflare-runtime";
2
+ import * as Context from "effect/Context";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Layer from "effect/Layer";
5
+ import * as MutableHashMap from "effect/MutableHashMap";
6
+ import * as Path from "effect/Path";
7
+ import { AlchemyContext } from "../AlchemyContext.ts";
8
+ import * as RpcProvider from "../Local/RpcProvider.ts";
9
+ import { CloudflareEnvironment } from "./CloudflareEnvironment.ts";
10
+ import type { Queue } from "./Queue/Queue.ts";
11
+ import type { QueueConsumer } from "./Queue/QueueConsumer.ts";
12
+
13
+ export const LOCAL_ENTRY_URL = import.meta.resolve(
14
+ // `import.meta.resolve(<string>)` is a runtime API — TypeScript's
15
+ // `rewriteRelativeImportExtensions` does NOT touch the string literal, so
16
+ // we have to pick the right extension ourselves. `import.meta.url` reflects
17
+ // the actual on-disk extension of *this* file (`.ts` when loaded from
18
+ // `src/` under Bun or vitest, `.js` when loaded from the compiled `lib/`
19
+ // under Node), which is exactly the signal we need.
20
+ import.meta.url.endsWith(".ts") ? "./Local.ts" : "./Local.js",
21
+ import.meta.url,
22
+ );
23
+
24
+ export class LocalRuntimeState extends Context.Service<
25
+ LocalRuntimeState,
26
+ {
27
+ readonly queues: MutableHashMap.MutableHashMap<
28
+ Queue["Attributes"]["queueId"],
29
+ Queue["Attributes"]
30
+ >;
31
+ readonly queueConsumers: MutableHashMap.MutableHashMap<
32
+ QueueConsumer["Attributes"]["consumerId"],
33
+ QueueConsumer["Attributes"]
34
+ >;
35
+ }
36
+ >()("alchemy/cloudflare/LocalRuntimeState") {}
37
+
38
+ const LocalRuntimeStateLive = Layer.succeed(
39
+ LocalRuntimeState,
40
+ LocalRuntimeState.of({
41
+ queues: MutableHashMap.empty(),
42
+ queueConsumers: MutableHashMap.empty(),
43
+ }),
44
+ );
45
+
46
+ export const localRuntimeServices = () =>
47
+ RpcProvider.providerServicesEffect(
48
+ Effect.gen(function* () {
49
+ const getEnv = yield* CloudflareEnvironment;
50
+ const { dotAlchemy } = yield* AlchemyContext;
51
+ const path = yield* Path.Path;
52
+ return Layer.merge(
53
+ LocalRuntimeStateLive,
54
+ layerRuntime({
55
+ api: {
56
+ accountId: getEnv.pipe(Effect.map((env) => env.accountId)),
57
+ },
58
+ storage: {
59
+ directory: path.join(dotAlchemy, "local"),
60
+ },
61
+ }),
62
+ );
63
+ }),
64
+ );
65
+
66
+ export const isLocalId = (id: string | undefined): id is string =>
67
+ typeof id === "string" && id.startsWith("dev:");
68
+ export const isLiveId = (id: string | undefined): id is string =>
69
+ typeof id === "string" && !id.startsWith("dev:");
70
+ export const generateLocalId = (): string => `dev:${crypto.randomUUID()}`;
@@ -29,6 +29,7 @@ import * as Email from "./Email/index.ts";
29
29
  import * as Hyperdrive from "./Hyperdrive/index.ts";
30
30
  import * as Images from "./Images/index.ts";
31
31
  import * as KV from "./KV/index.ts";
32
+ import { localRuntimeServices } from "./LocalRuntime.ts";
32
33
  import * as Queue from "./Queue/index.ts";
33
34
  import * as R2 from "./R2/index.ts";
34
35
  import * as RateLimit from "./RateLimit/index.ts";
@@ -60,6 +61,7 @@ export const providers = () =>
60
61
  ApiToken.AccountApiToken,
61
62
  ApiToken.UserApiToken,
62
63
  AiGateway.AiGateway,
64
+ AiGateway.AiGatewaySpendingLimit,
63
65
  AiGateway.AiGatewayBindingPolicy,
64
66
  AnalyticsEngine.AnalyticsEngineDatasetBindingPolicy,
65
67
  Artifacts.ArtifactsBindingPolicy,
@@ -117,6 +119,7 @@ export const providers = () =>
117
119
  ApiToken.AccountApiTokenProvider(),
118
120
  ApiToken.UserApiTokenProvider(),
119
121
  AiGateway.AiGatewayProvider(),
122
+ AiGateway.AiGatewaySpendingLimitProvider(),
120
123
  AiGateway.AiGatewayBindingPolicyLive,
121
124
  AnalyticsEngine.AnalyticsEngineDatasetBindingPolicyLive,
122
125
  Artifacts.ArtifactsBindingPolicyLive,
@@ -173,6 +176,7 @@ export const providers = () =>
173
176
  RandomProvider(),
174
177
  ),
175
178
  ),
179
+ Layer.provideMerge(localRuntimeServices()),
176
180
  Layer.provideMerge(Credentials.fromAuthProvider()),
177
181
  Layer.provideMerge(CloudflareEnvironment.fromProfile()),
178
182
  Layer.provideMerge(CloudflareAuth),