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.
- package/bin/exec.js +1 -1
- package/bin/exec.js.map +1 -1
- package/lib/Cloudflare/AiGateway/AiGatewaySpendingLimit.d.ts +165 -0
- package/lib/Cloudflare/AiGateway/AiGatewaySpendingLimit.d.ts.map +1 -0
- package/lib/Cloudflare/AiGateway/AiGatewaySpendingLimit.js +232 -0
- package/lib/Cloudflare/AiGateway/AiGatewaySpendingLimit.js.map +1 -0
- package/lib/Cloudflare/AiGateway/index.d.ts +1 -0
- package/lib/Cloudflare/AiGateway/index.d.ts.map +1 -1
- package/lib/Cloudflare/AiGateway/index.js +1 -0
- package/lib/Cloudflare/AiGateway/index.js.map +1 -1
- package/lib/Cloudflare/Bridge.d.ts +1 -0
- package/lib/Cloudflare/Bridge.d.ts.map +1 -1
- package/lib/Cloudflare/Bridge.js +1 -0
- package/lib/Cloudflare/Bridge.js.map +1 -1
- package/lib/Cloudflare/Fetcher.d.ts +2 -2
- package/lib/Cloudflare/Fetcher.d.ts.map +1 -1
- package/lib/Cloudflare/Fetcher.js.map +1 -1
- package/lib/Cloudflare/Hyperdrive/Hyperdrive.d.ts.map +1 -1
- package/lib/Cloudflare/Hyperdrive/Hyperdrive.js +6 -6
- package/lib/Cloudflare/Hyperdrive/Hyperdrive.js.map +1 -1
- package/lib/Cloudflare/Local.js +5 -2
- package/lib/Cloudflare/Local.js.map +1 -1
- package/lib/Cloudflare/LocalRuntime.d.ts +21 -0
- package/lib/Cloudflare/LocalRuntime.d.ts.map +1 -0
- package/lib/Cloudflare/LocalRuntime.js +40 -0
- package/lib/Cloudflare/LocalRuntime.js.map +1 -0
- package/lib/Cloudflare/Providers.d.ts +1 -1
- package/lib/Cloudflare/Providers.d.ts.map +1 -1
- package/lib/Cloudflare/Providers.js +3 -1
- package/lib/Cloudflare/Providers.js.map +1 -1
- package/lib/Cloudflare/Queue/Queue.d.ts +4 -1
- package/lib/Cloudflare/Queue/Queue.d.ts.map +1 -1
- package/lib/Cloudflare/Queue/Queue.js +61 -2
- package/lib/Cloudflare/Queue/Queue.js.map +1 -1
- package/lib/Cloudflare/Queue/QueueConsumer.d.ts +32 -26
- package/lib/Cloudflare/Queue/QueueConsumer.d.ts.map +1 -1
- package/lib/Cloudflare/Queue/QueueConsumer.js +73 -3
- package/lib/Cloudflare/Queue/QueueConsumer.js.map +1 -1
- package/lib/Cloudflare/Workers/Assets.js +2 -2
- package/lib/Cloudflare/Workers/Assets.js.map +1 -1
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts +3 -5
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js +32 -25
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -1
- package/lib/Cloudflare/Workers/Worker.d.ts +2 -4
- package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Worker.js +5 -7
- package/lib/Cloudflare/Workers/Worker.js.map +1 -1
- package/lib/Local/ProviderLayer.d.ts +10 -0
- package/lib/Local/ProviderLayer.d.ts.map +1 -0
- package/lib/Local/ProviderLayer.js +7 -0
- package/lib/Local/ProviderLayer.js.map +1 -0
- package/lib/Local/RpcProvider.d.ts +4 -3
- package/lib/Local/RpcProvider.d.ts.map +1 -1
- package/lib/Local/RpcProvider.js +6 -2
- package/lib/Local/RpcProvider.js.map +1 -1
- package/lib/Local/RpcProviderProxy.js.map +1 -1
- package/lib/Local/RpcSerialization.js +2 -2
- package/lib/Local/RpcSerialization.js.map +1 -1
- package/lib/Planetscale/Branch.d.ts.map +1 -1
- package/lib/Planetscale/Branch.js +25 -3
- package/lib/Planetscale/Branch.js.map +1 -1
- package/lib/Planetscale/Database.d.ts +1 -1
- package/lib/Planetscale/Postgres/PostgresClusterSize.d.ts +1 -1
- package/lib/Planetscale/Postgres/PostgresClusterSize.d.ts.map +1 -1
- package/lib/Planetscale/Postgres/PostgresClusterSize.js +13 -3
- package/lib/Planetscale/Postgres/PostgresClusterSize.js.map +1 -1
- package/lib/Planetscale/Util.d.ts +1 -1
- package/lib/Planetscale/Util.d.ts.map +1 -1
- package/lib/Planetscale/Util.js +7 -5
- package/lib/Planetscale/Util.js.map +1 -1
- package/lib/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +12 -12
- package/src/Cloudflare/AiGateway/AiGatewaySpendingLimit.ts +368 -0
- package/src/Cloudflare/AiGateway/index.ts +1 -0
- package/src/Cloudflare/Bridge.ts +1 -0
- package/src/Cloudflare/Fetcher.ts +8 -4
- package/src/Cloudflare/Hyperdrive/Hyperdrive.ts +6 -8
- package/src/Cloudflare/Local.ts +9 -5
- package/src/Cloudflare/LocalRuntime.ts +70 -0
- package/src/Cloudflare/Providers.ts +4 -0
- package/src/Cloudflare/Queue/Queue.ts +73 -2
- package/src/Cloudflare/Queue/QueueConsumer.ts +130 -28
- package/src/Cloudflare/Workers/Assets.ts +2 -2
- package/src/Cloudflare/Workers/LocalWorkerProvider.ts +39 -30
- package/src/Cloudflare/Workers/Worker.ts +5 -26
- package/src/Local/ProviderLayer.ts +22 -0
- package/src/Local/RpcProvider.ts +11 -5
- package/src/Local/RpcProviderProxy.ts +2 -2
- package/src/Local/RpcSerialization.ts +2 -2
- package/src/Planetscale/Branch.ts +38 -10
- package/src/Planetscale/Database.ts +1 -1
- package/src/Planetscale/Postgres/PostgresClusterSize.ts +16 -2
- 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.
|
|
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.
|
|
295
|
-
"@distilled.cloud/axiom": "^0.24.
|
|
296
|
-
"@distilled.cloud/cloudflare": "^0.24.
|
|
297
|
-
"@distilled.cloud/cloudflare-rolldown-plugin": "0.10.
|
|
298
|
-
"@distilled.cloud/cloudflare-runtime": "0.10.
|
|
299
|
-
"@distilled.cloud/cloudflare-vite-plugin": "0.10.
|
|
300
|
-
"@distilled.cloud/core": "^0.24.
|
|
301
|
-
"@distilled.cloud/neon": "^0.24.
|
|
302
|
-
"@distilled.cloud/planetscale": "^0.24.
|
|
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
|
+
});
|
package/src/Cloudflare/Bridge.ts
CHANGED
|
@@ -65,11 +65,13 @@ export const toCloudflareFetcher = Effect.fnUntraced(function* (
|
|
|
65
65
|
} satisfies cf.Fetcher;
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
export const fromCloudflareFetcher = (
|
|
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
|
|
72
|
-
signal: signal
|
|
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 = (
|
|
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 (!
|
|
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 (
|
|
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 ??
|
|
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 =
|
|
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 (!
|
|
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
|
package/src/Cloudflare/Local.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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),
|