@convex-dev/ai-budget 0.0.2-alpha.18 → 0.0.2-alpha.19
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/README.md +42 -5
- package/dist/client/index.d.ts +38 -5
- package/dist/client/index.js +13 -1
- package/dist/component/_generated/component.d.ts +6 -2
- package/dist/component/lib.d.ts +13 -2
- package/dist/component/lib.js +119 -43
- package/dist/component/schema.d.ts +20 -4
- package/dist/component/schema.js +28 -2
- package/package.json +1 -1
- package/src/client/index.ts +19 -4
- package/src/component/_generated/component.ts +9 -2
- package/src/component/lib.test.ts +49 -2
- package/src/component/lib.ts +134 -49
- package/src/component/schema.ts +28 -2
package/README.md
CHANGED
|
@@ -251,7 +251,10 @@ await ai.meter(ctx,
|
|
|
251
251
|
});
|
|
252
252
|
```
|
|
253
253
|
|
|
254
|
-
### `ai.decisions` — structured decisions (Jev)
|
|
254
|
+
### `ai.decisions` — structured decisions (Jev) · **experimental**
|
|
255
|
+
|
|
256
|
+
> **Experimental** — the Decisions endpoint is alpha on the gateway; this API may
|
|
257
|
+
> change without a major version bump.
|
|
255
258
|
|
|
256
259
|
Budget the gateway's Decisions endpoint ([Jev](https://docs.typesafe.ai)) — typed
|
|
257
260
|
`choice` / `score` / `boolean` questions evaluated against a `state` — with the
|
|
@@ -280,7 +283,10 @@ defaults to `defaultEvalModel` (`"typesafe/jev-1.13"`). Requires
|
|
|
280
283
|
`experimental_evaluate` — both imported lazily, so callers who don't use
|
|
281
284
|
`decisions` are unaffected.
|
|
282
285
|
|
|
283
|
-
### `ai.begin` / `ai.settle` — long async jobs (video)
|
|
286
|
+
### `ai.begin` / `ai.settle` — long async jobs (video) · **experimental**
|
|
287
|
+
|
|
288
|
+
> **Experimental** — video generation depends on alpha gateway features; the
|
|
289
|
+
> async surface (`reserveTtlMs`, `registerWebhook`) may change without a major bump.
|
|
284
290
|
|
|
285
291
|
A video job is submit → wait minutes → poll/webhook → done, spanning multiple
|
|
286
292
|
Convex functions, so the synchronous `meter` bracket doesn't fit. Reserve with
|
|
@@ -432,8 +438,11 @@ await ai.users.adjust(ctx, { userId, deltaNanos: -5 * 1_000_000_000, reason: "go
|
|
|
432
438
|
await ai.users.adjustments(ctx, { userId }); // the audit log
|
|
433
439
|
```
|
|
434
440
|
|
|
435
|
-
|
|
436
|
-
|
|
441
|
+
Positive = an extra charge (adds to **gross** spend). Negative = a credit/refund,
|
|
442
|
+
which accrues in a **separate** credits balance and never reduces gross spend — so
|
|
443
|
+
gross spend and durable history stay consistent. Caps enforce on **net = gross −
|
|
444
|
+
credits**, so a credit gives the bucket real headroom. Both are written to the
|
|
445
|
+
audit log.
|
|
437
446
|
|
|
438
447
|
### Alerts
|
|
439
448
|
|
|
@@ -490,9 +499,12 @@ ai.requests.get(ctx, { requestId }) // one request
|
|
|
490
499
|
### Global cap
|
|
491
500
|
|
|
492
501
|
```ts
|
|
502
|
+
// enforcement: "approximate" (default, best-effort block) | "soft" (warn only).
|
|
503
|
+
// Pass null to clear a field; only the fields you pass change.
|
|
493
504
|
ai.global.setLimits(ctx, { dailySpendLimitNanos?, lifetimeSpendLimitNanos?, enforcement? })
|
|
494
|
-
ai.global.status(ctx) // {
|
|
505
|
+
ai.global.status(ctx) // { dailySpendLimitNanos, lifetimeSpendLimitNanos, enforcement, spentTodayNanos, spentTotalNanos, … }
|
|
495
506
|
ai.global.bump(ctx, { dailyNanos?, lifetimeNanos? })
|
|
507
|
+
ai.global.setPolicy(ctx, { allowUnpricedModels?, storeContent? }) // deployment data/pricing policy
|
|
496
508
|
```
|
|
497
509
|
|
|
498
510
|
A best-effort killswitch across everything. Backed by a sharded counter for
|
|
@@ -755,6 +767,31 @@ metadata use their creation period and current capped buckets as a compatibility
|
|
|
755
767
|
fallback. Exact historical hold ownership cannot be reconstructed if those caps
|
|
756
768
|
changed before the upgrade. New requests always store explicit ownership.
|
|
757
769
|
|
|
770
|
+
## Stability (v1)
|
|
771
|
+
|
|
772
|
+
**Stable (frozen for 1.0)** — these keep backward compatibility within the 1.x line:
|
|
773
|
+
`ai.chat`, `ai.meter`, `ai.begin`/`ai.settle`, `ai.languageModel`, and the admin
|
|
774
|
+
namespaces `ai.users` / `ai.actions` / `ai.tag` / `ai.global` / `ai.models` /
|
|
775
|
+
`ai.prices` / `ai.requests`, plus `ai.registerRoutes`. The stored schema is frozen;
|
|
776
|
+
new fields will only be added as optional.
|
|
777
|
+
|
|
778
|
+
**Experimental (may change without a major bump):** `ai.decisions` (the Jev /
|
|
779
|
+
Decisions endpoint) and video generation (`begin`/`settle` with `reserveTtlMs` +
|
|
780
|
+
`registerWebhook`). Both depend on gateway features that are still alpha.
|
|
781
|
+
|
|
782
|
+
**Semantics worth knowing:**
|
|
783
|
+
- **Spend caps** admit on an *estimate*, so a token-priced cap can be exceeded by
|
|
784
|
+
one request's estimate-vs-actual delta; pass `estimatedCostNanos` for an exact
|
|
785
|
+
reservation. The **global cap** is a best-effort killswitch (`enforcement:
|
|
786
|
+
"approximate"` | `"soft"`), not a to-the-dollar ceiling.
|
|
787
|
+
- **Credits** (`ai.tag(d).adjust` / negative `deltaNanos`) accrue in a separate
|
|
788
|
+
balance and never reduce gross spend; caps enforce on **net = gross − credits**,
|
|
789
|
+
so a credit grants real headroom while spend history stays consistent.
|
|
790
|
+
- **Deployment policy** (`ai.global.setPolicy`): `allowUnpricedModels: false`
|
|
791
|
+
rejects models with no configured price under hard enforcement (default charges
|
|
792
|
+
the conservative fallback); `storeContent: false` persists metadata but no
|
|
793
|
+
prompt/response content (for teams that want zero prompt retention).
|
|
794
|
+
|
|
758
795
|
## Development
|
|
759
796
|
|
|
760
797
|
```sh
|
package/dist/client/index.d.ts
CHANGED
|
@@ -495,6 +495,9 @@ export declare class AIBudget {
|
|
|
495
495
|
lifetimeBumpNanos?: number | undefined;
|
|
496
496
|
bumpDayStamp?: string | undefined;
|
|
497
497
|
bumpMonthStamp?: string | undefined;
|
|
498
|
+
creditsNanos?: number | undefined;
|
|
499
|
+
creditsTodayNanos?: number | undefined;
|
|
500
|
+
creditsThisMonthNanos?: number | undefined;
|
|
498
501
|
tokensToday?: number | undefined;
|
|
499
502
|
monthStamp?: string | undefined;
|
|
500
503
|
tokensThisMonth?: number | undefined;
|
|
@@ -536,6 +539,9 @@ export declare class AIBudget {
|
|
|
536
539
|
lifetimeBumpNanos?: number | undefined;
|
|
537
540
|
bumpDayStamp?: string | undefined;
|
|
538
541
|
bumpMonthStamp?: string | undefined;
|
|
542
|
+
creditsNanos?: number | undefined;
|
|
543
|
+
creditsTodayNanos?: number | undefined;
|
|
544
|
+
creditsThisMonthNanos?: number | undefined;
|
|
539
545
|
tokensToday?: number | undefined;
|
|
540
546
|
monthStamp?: string | undefined;
|
|
541
547
|
tokensThisMonth?: number | undefined;
|
|
@@ -632,6 +638,9 @@ export declare class AIBudget {
|
|
|
632
638
|
lifetimeBumpNanos?: number | undefined;
|
|
633
639
|
bumpDayStamp?: string | undefined;
|
|
634
640
|
bumpMonthStamp?: string | undefined;
|
|
641
|
+
creditsNanos?: number | undefined;
|
|
642
|
+
creditsTodayNanos?: number | undefined;
|
|
643
|
+
creditsThisMonthNanos?: number | undefined;
|
|
635
644
|
tokensToday?: number | undefined;
|
|
636
645
|
monthStamp?: string | undefined;
|
|
637
646
|
tokensThisMonth?: number | undefined;
|
|
@@ -673,6 +682,9 @@ export declare class AIBudget {
|
|
|
673
682
|
lifetimeBumpNanos?: number | undefined;
|
|
674
683
|
bumpDayStamp?: string | undefined;
|
|
675
684
|
bumpMonthStamp?: string | undefined;
|
|
685
|
+
creditsNanos?: number | undefined;
|
|
686
|
+
creditsTodayNanos?: number | undefined;
|
|
687
|
+
creditsThisMonthNanos?: number | undefined;
|
|
676
688
|
tokensToday?: number | undefined;
|
|
677
689
|
monthStamp?: string | undefined;
|
|
678
690
|
tokensThisMonth?: number | undefined;
|
|
@@ -768,6 +780,9 @@ export declare class AIBudget {
|
|
|
768
780
|
lifetimeBumpNanos?: number | undefined;
|
|
769
781
|
bumpDayStamp?: string | undefined;
|
|
770
782
|
bumpMonthStamp?: string | undefined;
|
|
783
|
+
creditsNanos?: number | undefined;
|
|
784
|
+
creditsTodayNanos?: number | undefined;
|
|
785
|
+
creditsThisMonthNanos?: number | undefined;
|
|
771
786
|
tokensToday?: number | undefined;
|
|
772
787
|
monthStamp?: string | undefined;
|
|
773
788
|
tokensThisMonth?: number | undefined;
|
|
@@ -809,6 +824,9 @@ export declare class AIBudget {
|
|
|
809
824
|
lifetimeBumpNanos?: number | undefined;
|
|
810
825
|
bumpDayStamp?: string | undefined;
|
|
811
826
|
bumpMonthStamp?: string | undefined;
|
|
827
|
+
creditsNanos?: number | undefined;
|
|
828
|
+
creditsTodayNanos?: number | undefined;
|
|
829
|
+
creditsThisMonthNanos?: number | undefined;
|
|
812
830
|
tokensToday?: number | undefined;
|
|
813
831
|
monthStamp?: string | undefined;
|
|
814
832
|
tokensThisMonth?: number | undefined;
|
|
@@ -886,17 +904,21 @@ export declare class AIBudget {
|
|
|
886
904
|
status: (ctx: RunQueryCtx) => Promise<{
|
|
887
905
|
dailySpendLimitNanos: number | null;
|
|
888
906
|
lifetimeSpendLimitNanos: number | null;
|
|
889
|
-
enforcement: "
|
|
907
|
+
enforcement: "soft" | "approximate";
|
|
890
908
|
spentTodayNanos: number;
|
|
891
909
|
spentTotalNanos: number;
|
|
892
910
|
retentionMs: number | null;
|
|
893
911
|
defaultWarnAtPct: number | null;
|
|
894
912
|
}>;
|
|
895
|
-
/**
|
|
913
|
+
/**
|
|
914
|
+
* A killswitch spend cap across all users/actions. Enforced
|
|
915
|
+
* **approximately** (bounded overshoot, no per-request reservation) — for
|
|
916
|
+
* an exact ceiling use a per-bucket cap. Pass `null` to clear a field.
|
|
917
|
+
*/
|
|
896
918
|
setLimits: (ctx: RunMutationCtx, args: {
|
|
897
|
-
dailySpendLimitNanos?: number;
|
|
898
|
-
lifetimeSpendLimitNanos?: number;
|
|
899
|
-
enforcement?: "
|
|
919
|
+
dailySpendLimitNanos?: number | null;
|
|
920
|
+
lifetimeSpendLimitNanos?: number | null;
|
|
921
|
+
enforcement?: "approximate" | "soft" | null;
|
|
900
922
|
}) => Promise<null>;
|
|
901
923
|
bump: (ctx: RunMutationCtx, args: {
|
|
902
924
|
dailyNanos?: number;
|
|
@@ -910,6 +932,17 @@ export declare class AIBudget {
|
|
|
910
932
|
setRetention: (ctx: RunMutationCtx, args: {
|
|
911
933
|
retentionMs: number;
|
|
912
934
|
}) => Promise<null>;
|
|
935
|
+
/**
|
|
936
|
+
* Deployment-wide data/pricing policy (only the fields you pass change):
|
|
937
|
+
* - `allowUnpricedModels: false` rejects models with no configured price
|
|
938
|
+
* under hard enforcement (default true — charge the conservative fallback).
|
|
939
|
+
* - `storeContent: false` stops persisting prompt/response content on
|
|
940
|
+
* request rows (default true).
|
|
941
|
+
*/
|
|
942
|
+
setPolicy: (ctx: RunMutationCtx, args: {
|
|
943
|
+
allowUnpricedModels?: boolean;
|
|
944
|
+
storeContent?: boolean;
|
|
945
|
+
}) => Promise<null>;
|
|
913
946
|
};
|
|
914
947
|
/** Model allow/deny policy. */
|
|
915
948
|
get models(): {
|
package/dist/client/index.js
CHANGED
|
@@ -648,13 +648,25 @@ export class AIBudget {
|
|
|
648
648
|
return {
|
|
649
649
|
/** Limits + spend today/total. */
|
|
650
650
|
status: (ctx) => ctx.runQuery(c.lib.getGlobalStatus, {}),
|
|
651
|
-
/**
|
|
651
|
+
/**
|
|
652
|
+
* A killswitch spend cap across all users/actions. Enforced
|
|
653
|
+
* **approximately** (bounded overshoot, no per-request reservation) — for
|
|
654
|
+
* an exact ceiling use a per-bucket cap. Pass `null` to clear a field.
|
|
655
|
+
*/
|
|
652
656
|
setLimits: (ctx, args) => ctx.runMutation(c.lib.setGlobalLimits, args),
|
|
653
657
|
bump: (ctx, args) => ctx.runMutation(c.lib.bumpGlobal, args),
|
|
654
658
|
/** Default approaching-limit alert threshold (fraction of a cap, e.g. 0.8). */
|
|
655
659
|
setAlertDefaults: (ctx, args) => ctx.runMutation(c.lib.setAlertDefaults, args),
|
|
656
660
|
/** Request-row retention window in ms (default 1h; 0 disables). */
|
|
657
661
|
setRetention: (ctx, args) => ctx.runMutation(c.lib.setRetention, args),
|
|
662
|
+
/**
|
|
663
|
+
* Deployment-wide data/pricing policy (only the fields you pass change):
|
|
664
|
+
* - `allowUnpricedModels: false` rejects models with no configured price
|
|
665
|
+
* under hard enforcement (default true — charge the conservative fallback).
|
|
666
|
+
* - `storeContent: false` stops persisting prompt/response content on
|
|
667
|
+
* request rows (default true).
|
|
668
|
+
*/
|
|
669
|
+
setPolicy: (ctx, args) => ctx.runMutation(c.lib.setDeploymentPolicy, args),
|
|
658
670
|
};
|
|
659
671
|
}
|
|
660
672
|
/** Model allow/deny policy. */
|
|
@@ -66,7 +66,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
66
66
|
getGlobalStatus: FunctionReference<"query", "internal", {}, {
|
|
67
67
|
dailySpendLimitNanos: number | null;
|
|
68
68
|
defaultWarnAtPct: number | null;
|
|
69
|
-
enforcement: "
|
|
69
|
+
enforcement: "approximate" | "soft";
|
|
70
70
|
lifetimeSpendLimitNanos: number | null;
|
|
71
71
|
retentionMs: number | null;
|
|
72
72
|
spentTodayNanos: number;
|
|
@@ -116,9 +116,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
116
116
|
value: string;
|
|
117
117
|
warnAtPct?: number;
|
|
118
118
|
}, null, Name>;
|
|
119
|
+
setDeploymentPolicy: FunctionReference<"mutation", "internal", {
|
|
120
|
+
allowUnpricedModels?: boolean;
|
|
121
|
+
storeContent?: boolean;
|
|
122
|
+
}, null, Name>;
|
|
119
123
|
setGlobalLimits: FunctionReference<"mutation", "internal", {
|
|
120
124
|
dailySpendLimitNanos?: number | null;
|
|
121
|
-
enforcement?: "
|
|
125
|
+
enforcement?: "approximate" | "soft" | null;
|
|
122
126
|
lifetimeSpendLimitNanos?: number | null;
|
|
123
127
|
}, null, Name>;
|
|
124
128
|
setModelPolicy: FunctionReference<"mutation", "internal", {
|
package/dist/component/lib.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export declare const foldTotals: import("convex/server").RegisteredMutation<"int
|
|
|
40
40
|
requestId: import("convex/values").GenericId<"requests">;
|
|
41
41
|
}, Promise<null>>;
|
|
42
42
|
export declare const reconcile: import("convex/server").RegisteredMutation<"internal", {}, Promise<null>>;
|
|
43
|
+
export declare const globalPhase: import("convex/server").RegisteredMutation<"internal", {}, Promise<null>>;
|
|
43
44
|
export declare const foldPhase: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
|
|
44
45
|
folded: number;
|
|
45
46
|
}>>;
|
|
@@ -242,6 +243,9 @@ export declare const listBuckets: import("convex/server").RegisteredQuery<"publi
|
|
|
242
243
|
lifetimeBumpNanos?: number | undefined;
|
|
243
244
|
bumpDayStamp?: string | undefined;
|
|
244
245
|
bumpMonthStamp?: string | undefined;
|
|
246
|
+
creditsNanos?: number | undefined;
|
|
247
|
+
creditsTodayNanos?: number | undefined;
|
|
248
|
+
creditsThisMonthNanos?: number | undefined;
|
|
245
249
|
tokensToday?: number | undefined;
|
|
246
250
|
monthStamp?: string | undefined;
|
|
247
251
|
tokensThisMonth?: number | undefined;
|
|
@@ -283,6 +287,9 @@ export declare const getBucket: import("convex/server").RegisteredQuery<"public"
|
|
|
283
287
|
lifetimeBumpNanos?: number | undefined;
|
|
284
288
|
bumpDayStamp?: string | undefined;
|
|
285
289
|
bumpMonthStamp?: string | undefined;
|
|
290
|
+
creditsNanos?: number | undefined;
|
|
291
|
+
creditsTodayNanos?: number | undefined;
|
|
292
|
+
creditsThisMonthNanos?: number | undefined;
|
|
286
293
|
tokensToday?: number | undefined;
|
|
287
294
|
monthStamp?: string | undefined;
|
|
288
295
|
tokensThisMonth?: number | undefined;
|
|
@@ -361,6 +368,10 @@ export declare const usageHistory: import("convex/server").RegisteredQuery<"publ
|
|
|
361
368
|
export declare const setAlertDefaults: import("convex/server").RegisteredMutation<"public", {
|
|
362
369
|
warnAtPct?: number | undefined;
|
|
363
370
|
}, Promise<null>>;
|
|
371
|
+
export declare const setDeploymentPolicy: import("convex/server").RegisteredMutation<"public", {
|
|
372
|
+
allowUnpricedModels?: boolean | undefined;
|
|
373
|
+
storeContent?: boolean | undefined;
|
|
374
|
+
}, Promise<null>>;
|
|
364
375
|
export declare const deleteBucket: import("convex/server").RegisteredMutation<"public", {
|
|
365
376
|
dimension: string;
|
|
366
377
|
value: string;
|
|
@@ -375,7 +386,7 @@ export declare const getModelPolicy: import("convex/server").RegisteredQuery<"pu
|
|
|
375
386
|
export declare const getGlobalStatus: import("convex/server").RegisteredQuery<"public", {}, Promise<{
|
|
376
387
|
dailySpendLimitNanos: number | null;
|
|
377
388
|
lifetimeSpendLimitNanos: number | null;
|
|
378
|
-
enforcement: "
|
|
389
|
+
enforcement: "soft" | "approximate";
|
|
379
390
|
spentTodayNanos: number;
|
|
380
391
|
spentTotalNanos: number;
|
|
381
392
|
retentionMs: number | null;
|
|
@@ -384,7 +395,7 @@ export declare const getGlobalStatus: import("convex/server").RegisteredQuery<"p
|
|
|
384
395
|
export declare const setGlobalLimits: import("convex/server").RegisteredMutation<"public", {
|
|
385
396
|
dailySpendLimitNanos?: number | null | undefined;
|
|
386
397
|
lifetimeSpendLimitNanos?: number | null | undefined;
|
|
387
|
-
enforcement?: "
|
|
398
|
+
enforcement?: "soft" | "approximate" | null | undefined;
|
|
388
399
|
}, Promise<null>>;
|
|
389
400
|
export declare const bumpGlobal: import("convex/server").RegisteredMutation<"public", {
|
|
390
401
|
dailyNanos?: number | undefined;
|
package/dist/component/lib.js
CHANGED
|
@@ -254,9 +254,9 @@ function evaluateCaps(o) {
|
|
|
254
254
|
// { code, projected usage (incl. this estimate), cap, human window label,
|
|
255
255
|
// whether it's a money cap (formatted as $), spend? for notices }
|
|
256
256
|
const checks = [
|
|
257
|
-
{ w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost, cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
|
|
258
|
-
{ w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost, cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
|
|
259
|
-
{ w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost, cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
|
|
257
|
+
{ w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost - (o.creditsToday ?? 0), cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
|
|
258
|
+
{ w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost - (o.creditsThisMonth ?? 0), cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
|
|
259
|
+
{ w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost - (o.creditsTotal ?? 0), cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
|
|
260
260
|
{ w: "daily_token_limit", used: o.tokensToday + o.reservedTokensToday + o.estTokens, cap: o.dailyTokenLimit, label: "daily token limit", money: false },
|
|
261
261
|
{ w: "monthly_token_limit", used: o.tokensThisMonth + o.reservedTokensMonth + o.estTokens, cap: o.monthlyTokenLimit, label: "monthly token limit", money: false },
|
|
262
262
|
{ w: "lifetime_token_limit", used: o.totalTokens + o.reservedTokensTotal + o.estTokens, cap: o.lifetimeTokenLimit, label: "lifetime token limit", money: false },
|
|
@@ -404,6 +404,9 @@ export const startRequest = mutation({
|
|
|
404
404
|
// (false) and the bucket admits unlimited spend. Reject it up front.
|
|
405
405
|
assertAmount(args.estimatedCostNanos, "estimatedCostNanos");
|
|
406
406
|
const extraTags = sanitizeExtraTags(args.tags);
|
|
407
|
+
// Set from settings below; when false we persist metadata but no prompt
|
|
408
|
+
// content (a deployment that opts out of storing prompts entirely).
|
|
409
|
+
let storeContent = true;
|
|
407
410
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
408
411
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
409
412
|
// rejections (rate limit, blocked user) that a client retries in a tight
|
|
@@ -416,7 +419,7 @@ export const startRequest = mutation({
|
|
|
416
419
|
actionName: args.actionName,
|
|
417
420
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
418
421
|
model: args.model,
|
|
419
|
-
messages: args.messages,
|
|
422
|
+
messages: storeContent ? args.messages : [],
|
|
420
423
|
rerunOf: args.rerunOf,
|
|
421
424
|
status: "blocked",
|
|
422
425
|
error: reason,
|
|
@@ -446,6 +449,7 @@ export const startRequest = mutation({
|
|
|
446
449
|
const notices = [];
|
|
447
450
|
// Model allow/deny policy (component-wide).
|
|
448
451
|
const settings = await getSettings(ctx);
|
|
452
|
+
storeContent = settings?.storeContent !== false;
|
|
449
453
|
const defaultWarnAtPct = settings?.defaultWarnAtPct;
|
|
450
454
|
if (settings) {
|
|
451
455
|
const mode = settings.modelMode ?? "open";
|
|
@@ -456,6 +460,11 @@ export const startRequest = mutation({
|
|
|
456
460
|
if (mode === "denylist" && list.includes(args.model)) {
|
|
457
461
|
return reject("model_denied", `Model "${args.model}" is denied`);
|
|
458
462
|
}
|
|
463
|
+
// Optionally refuse models with no known/override price rather than
|
|
464
|
+
// charging the conservative fallback (which under-counts premium models).
|
|
465
|
+
if (settings.allowUnpricedModels === false && !priceInfo.known) {
|
|
466
|
+
return reject("model_unpriced", `Model "${args.model}" has no configured price; set one with setPrice or allow unpriced models`);
|
|
467
|
+
}
|
|
459
468
|
}
|
|
460
469
|
// Fetch/create every bucket this request is attributed to (user, action,
|
|
461
470
|
// and any extra tags). Each may carry its own budget.
|
|
@@ -516,6 +525,9 @@ export const startRequest = mutation({
|
|
|
516
525
|
reservedSpendMonth: sameMonth ? b.reservedMonthNanos ?? 0 : 0,
|
|
517
526
|
totalSpend: b.totalSpendNanos,
|
|
518
527
|
reservedSpendTotal: b.reservedTotalNanos ?? 0,
|
|
528
|
+
creditsToday: sameDay ? b.creditsTodayNanos ?? 0 : 0,
|
|
529
|
+
creditsThisMonth: sameMonth ? b.creditsThisMonthNanos ?? 0 : 0,
|
|
530
|
+
creditsTotal: b.creditsNanos ?? 0,
|
|
519
531
|
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
520
532
|
reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
|
|
521
533
|
tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
|
|
@@ -542,35 +554,23 @@ export const startRequest = mutation({
|
|
|
542
554
|
// throughput. The sum is transactional, but excludes unsettled usage and
|
|
543
555
|
// has no cross-request reservation, so a hard global cap can overshoot. That's the deliberate consistency/throughput
|
|
544
556
|
// trade for a deployment-wide killswitch; it's the only approximate scope.
|
|
557
|
+
// H4: don't read the sharded counter here — that per-admission read
|
|
558
|
+
// contended with every fold that writes it. The reconciler compares the
|
|
559
|
+
// total to the cap out-of-band and records trip flags on `settings`;
|
|
560
|
+
// admission just reads those (already-loaded) flags. The killswitch lag is
|
|
561
|
+
// bounded by the reconcile interval, which is the point of "approximate".
|
|
545
562
|
if (settings &&
|
|
546
|
-
(settings.
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
spendThisMonth: 0, // global tracks daily + lifetime only
|
|
558
|
-
reservedSpendMonth: 0,
|
|
559
|
-
totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
560
|
-
reservedSpendTotal: 0,
|
|
561
|
-
tokensToday: 0,
|
|
562
|
-
reservedTokensToday: 0,
|
|
563
|
-
tokensThisMonth: 0,
|
|
564
|
-
reservedTokensMonth: 0,
|
|
565
|
-
totalTokens: 0,
|
|
566
|
-
reservedTokensTotal: 0,
|
|
567
|
-
dailySpendLimitNanos: withBump(settings.globalDailySpendLimitNanos, settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0),
|
|
568
|
-
lifetimeSpendLimitNanos: withBump(settings.globalLifetimeSpendLimitNanos, settings.globalLifetimeBumpNanos),
|
|
569
|
-
});
|
|
570
|
-
if (globalEval.hard)
|
|
571
|
-
return reject(globalEval.hard.code, globalEval.hard.reason);
|
|
572
|
-
warnings.push(...globalEval.warnings);
|
|
573
|
-
notices.push(...globalEval.notices);
|
|
563
|
+
(settings.globalTrippedDaily || settings.globalTrippedLifetime)) {
|
|
564
|
+
const enforcement = settings.globalEnforcement ?? "approximate";
|
|
565
|
+
if (enforcement === "soft") {
|
|
566
|
+
warnings.push(`Global spend limit reached for the deployment (allowed — soft)`);
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
return reject("global_spend_limit", "Global spend limit reached for the deployment");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
else if (settings?.globalNearLimit) {
|
|
573
|
+
notices.push("Deployment approaching its global spend limit");
|
|
574
574
|
}
|
|
575
575
|
// Consume only after every admission check succeeds, in the same
|
|
576
576
|
// transaction as the reservations and request insert. `throws: true` is
|
|
@@ -620,7 +620,7 @@ export const startRequest = mutation({
|
|
|
620
620
|
actionName: args.actionName,
|
|
621
621
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
622
622
|
model: args.model,
|
|
623
|
-
messages: args.messages,
|
|
623
|
+
messages: storeContent ? args.messages : [],
|
|
624
624
|
rerunOf: args.rerunOf,
|
|
625
625
|
...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
|
|
626
626
|
status: "pending",
|
|
@@ -686,12 +686,13 @@ export const finishRequest = mutation({
|
|
|
686
686
|
// tool fees); otherwise price from tokens — discounting the cached
|
|
687
687
|
// (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
|
|
688
688
|
// finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
|
|
689
|
+
const settings = await getSettings(ctx);
|
|
690
|
+
const storeContent = settings?.storeContent !== false;
|
|
689
691
|
let costNanos;
|
|
690
692
|
if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
|
|
691
693
|
costNanos = Math.round(args.costNanos);
|
|
692
694
|
}
|
|
693
695
|
else {
|
|
694
|
-
const settings = await getSettings(ctx);
|
|
695
696
|
const priced = settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model)) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
|
|
696
697
|
// Fail closed: if the settle carried NO usable cost signal — no tokens and
|
|
697
698
|
// no priced server-tool fee (an unpriced tool like `video_seconds`, or a
|
|
@@ -710,7 +711,7 @@ export const finishRequest = mutation({
|
|
|
710
711
|
reservationExpired: false,
|
|
711
712
|
expiresAt: undefined,
|
|
712
713
|
finishedAt: Date.now(),
|
|
713
|
-
responseText: args.responseText,
|
|
714
|
+
responseText: storeContent ? args.responseText : undefined,
|
|
714
715
|
error: args.error,
|
|
715
716
|
promptTokens,
|
|
716
717
|
completionTokens,
|
|
@@ -815,6 +816,44 @@ export const reconcile = internalMutation({
|
|
|
815
816
|
await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
|
|
816
817
|
await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
|
|
817
818
|
await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
819
|
+
await ctx.scheduler.runAfter(0, internal.lib.globalPhase, {});
|
|
820
|
+
return null;
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
// H4: compute the deployment-wide global-cap trip flags out-of-band so admission
|
|
824
|
+
// never reads the sharded counter on its hot path. One cheap counter read here
|
|
825
|
+
// per interval; admission then consults the flags on `settings`.
|
|
826
|
+
export const globalPhase = internalMutation({
|
|
827
|
+
args: {},
|
|
828
|
+
returns: v.null(),
|
|
829
|
+
handler: async (ctx) => {
|
|
830
|
+
const s = await getSettings(ctx);
|
|
831
|
+
if (!s)
|
|
832
|
+
return null;
|
|
833
|
+
const hasCap = s.globalDailySpendLimitNanos !== undefined ||
|
|
834
|
+
s.globalLifetimeSpendLimitNanos !== undefined;
|
|
835
|
+
if (!hasCap) {
|
|
836
|
+
// Clear stale flags when no global cap is configured.
|
|
837
|
+
if (s.globalTrippedDaily || s.globalTrippedLifetime || s.globalNearLimit)
|
|
838
|
+
await ctx.db.patch(s._id, {
|
|
839
|
+
globalTrippedDaily: false,
|
|
840
|
+
globalTrippedLifetime: false,
|
|
841
|
+
globalNearLimit: false,
|
|
842
|
+
});
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
const today = dayStamp();
|
|
846
|
+
const dailyCap = withBump(s.globalDailySpendLimitNanos, s.globalBumpDayStamp === today ? s.globalDailyBumpNanos : 0);
|
|
847
|
+
const lifetimeCap = withBump(s.globalLifetimeSpendLimitNanos, s.globalLifetimeBumpNanos);
|
|
848
|
+
const spentToday = await globalSpend.count(ctx, globalDayKey(today));
|
|
849
|
+
const spentTotal = await globalSpend.count(ctx, GLOBAL_TOTAL);
|
|
850
|
+
const pct = s.defaultWarnAtPct;
|
|
851
|
+
const near = (spent, cap) => cap !== undefined && pct !== undefined && pct > 0 && pct < 1 && spent >= pct * cap;
|
|
852
|
+
await ctx.db.patch(s._id, {
|
|
853
|
+
globalTrippedDaily: dailyCap !== undefined && spentToday >= dailyCap,
|
|
854
|
+
globalTrippedLifetime: lifetimeCap !== undefined && spentTotal >= lifetimeCap,
|
|
855
|
+
globalNearLimit: near(spentToday, dailyCap) || near(spentTotal, lifetimeCap),
|
|
856
|
+
});
|
|
818
857
|
return null;
|
|
819
858
|
},
|
|
820
859
|
});
|
|
@@ -1117,15 +1156,25 @@ export const adjustBucket = mutation({
|
|
|
1117
1156
|
const dSame = b.dayStamp === today;
|
|
1118
1157
|
const mSame = b.monthStamp === month;
|
|
1119
1158
|
const dt = tokens ?? 0;
|
|
1159
|
+
// Gross-plus-credits ledger: a positive delta is a real extra charge (adds
|
|
1160
|
+
// to GROSS spend); a negative delta is a credit/refund that accrues to a
|
|
1161
|
+
// separate credits balance and NEVER reduces gross spend — so gross spend
|
|
1162
|
+
// and durable history stay consistent, and net = gross - credits. Credits
|
|
1163
|
+
// grant headroom because admission subtracts them from the cap check.
|
|
1164
|
+
const debit = deltaNanos > 0 ? deltaNanos : 0;
|
|
1165
|
+
const credit = deltaNanos < 0 ? -deltaNanos : 0;
|
|
1120
1166
|
await ctx.db.patch(b._id, {
|
|
1121
|
-
totalSpendNanos:
|
|
1167
|
+
totalSpendNanos: b.totalSpendNanos + debit,
|
|
1122
1168
|
totalTokens: Math.max(0, b.totalTokens + dt),
|
|
1123
1169
|
dayStamp: today,
|
|
1124
1170
|
monthStamp: month,
|
|
1125
|
-
spendTodayNanos:
|
|
1171
|
+
spendTodayNanos: (dSame ? b.spendTodayNanos : 0) + debit,
|
|
1126
1172
|
tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
|
|
1127
|
-
spendThisMonthNanos:
|
|
1173
|
+
spendThisMonthNanos: (mSame ? b.spendThisMonthNanos ?? 0 : 0) + debit,
|
|
1128
1174
|
tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
|
|
1175
|
+
creditsNanos: (b.creditsNanos ?? 0) + credit,
|
|
1176
|
+
creditsTodayNanos: (dSame ? b.creditsTodayNanos ?? 0 : 0) + credit,
|
|
1177
|
+
creditsThisMonthNanos: (mSame ? b.creditsThisMonthNanos ?? 0 : 0) + credit,
|
|
1129
1178
|
// Advancing the window here must also clear the OLD window's reserved
|
|
1130
1179
|
// holds, or an in-flight request from the previous day/month would be
|
|
1131
1180
|
// treated as reserving against the new window and its later release would
|
|
@@ -1134,8 +1183,10 @@ export const adjustBucket = mutation({
|
|
|
1134
1183
|
...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
|
|
1135
1184
|
});
|
|
1136
1185
|
await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
|
|
1137
|
-
|
|
1138
|
-
|
|
1186
|
+
// Durable history tracks GROSS spend only (debits); credits live in the
|
|
1187
|
+
// adjustments log + bucket balance, so usage rollups never go negative.
|
|
1188
|
+
await addUsage(ctx, dimension, value, "day", today, debit, dt, 0);
|
|
1189
|
+
await addUsage(ctx, dimension, value, "month", month, debit, dt, 0);
|
|
1139
1190
|
return null;
|
|
1140
1191
|
},
|
|
1141
1192
|
});
|
|
@@ -1176,6 +1227,31 @@ export const setAlertDefaults = mutation({
|
|
|
1176
1227
|
return null;
|
|
1177
1228
|
},
|
|
1178
1229
|
});
|
|
1230
|
+
// Deployment-wide data/pricing policy. Only the fields you pass change.
|
|
1231
|
+
// - allowUnpricedModels: false rejects models with no configured price under
|
|
1232
|
+
// hard enforcement (default true — charge the conservative fallback).
|
|
1233
|
+
// - storeContent: false stops persisting prompt/response content on request
|
|
1234
|
+
// rows (default true).
|
|
1235
|
+
export const setDeploymentPolicy = mutation({
|
|
1236
|
+
args: {
|
|
1237
|
+
allowUnpricedModels: v.optional(v.boolean()),
|
|
1238
|
+
storeContent: v.optional(v.boolean()),
|
|
1239
|
+
},
|
|
1240
|
+
returns: v.null(),
|
|
1241
|
+
handler: async (ctx, args) => {
|
|
1242
|
+
const patch = {};
|
|
1243
|
+
if ("allowUnpricedModels" in args)
|
|
1244
|
+
patch.allowUnpricedModels = args.allowUnpricedModels;
|
|
1245
|
+
if ("storeContent" in args)
|
|
1246
|
+
patch.storeContent = args.storeContent;
|
|
1247
|
+
const existing = await getSettings(ctx);
|
|
1248
|
+
if (existing)
|
|
1249
|
+
await ctx.db.patch(existing._id, patch);
|
|
1250
|
+
else
|
|
1251
|
+
await ctx.db.insert("settings", { key: "singleton", ...patch });
|
|
1252
|
+
return null;
|
|
1253
|
+
},
|
|
1254
|
+
});
|
|
1179
1255
|
// Delete a bucket and (for the `user` dimension) all of that user's request
|
|
1180
1256
|
// rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
|
|
1181
1257
|
// self-reschedules so it never exceeds the per-transaction document limit.
|
|
@@ -1253,7 +1329,7 @@ export const getGlobalStatus = query({
|
|
|
1253
1329
|
returns: v.object({
|
|
1254
1330
|
dailySpendLimitNanos: v.union(v.number(), v.null()),
|
|
1255
1331
|
lifetimeSpendLimitNanos: v.union(v.number(), v.null()),
|
|
1256
|
-
enforcement: v.union(v.literal("
|
|
1332
|
+
enforcement: v.union(v.literal("approximate"), v.literal("soft")),
|
|
1257
1333
|
spentTodayNanos: v.number(),
|
|
1258
1334
|
spentTotalNanos: v.number(),
|
|
1259
1335
|
// deployment-wide config (surfaced for the admin dashboard)
|
|
@@ -1268,7 +1344,7 @@ export const getGlobalStatus = query({
|
|
|
1268
1344
|
return {
|
|
1269
1345
|
dailySpendLimitNanos: s?.globalDailySpendLimitNanos ?? null,
|
|
1270
1346
|
lifetimeSpendLimitNanos: s?.globalLifetimeSpendLimitNanos ?? null,
|
|
1271
|
-
enforcement: s?.globalEnforcement ?? "
|
|
1347
|
+
enforcement: s?.globalEnforcement ?? "approximate",
|
|
1272
1348
|
spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
|
|
1273
1349
|
spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
1274
1350
|
retentionMs: s?.retentionMs ?? null,
|
|
@@ -1284,7 +1360,7 @@ export const setGlobalLimits = mutation({
|
|
|
1284
1360
|
// other global controls by patching them to undefined.)
|
|
1285
1361
|
dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1286
1362
|
lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1287
|
-
enforcement: v.optional(v.union(v.literal("
|
|
1363
|
+
enforcement: v.optional(v.union(v.literal("approximate"), v.literal("soft"), v.null())),
|
|
1288
1364
|
},
|
|
1289
1365
|
returns: v.null(),
|
|
1290
1366
|
handler: async (ctx, args) => {
|
|
@@ -63,6 +63,9 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
63
63
|
lifetimeBumpNanos?: number | undefined;
|
|
64
64
|
bumpDayStamp?: string | undefined;
|
|
65
65
|
bumpMonthStamp?: string | undefined;
|
|
66
|
+
creditsNanos?: number | undefined;
|
|
67
|
+
creditsTodayNanos?: number | undefined;
|
|
68
|
+
creditsThisMonthNanos?: number | undefined;
|
|
66
69
|
tokensToday?: number | undefined;
|
|
67
70
|
monthStamp?: string | undefined;
|
|
68
71
|
spendThisMonthNanos?: number | undefined;
|
|
@@ -103,6 +106,9 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
103
106
|
totalSpendNanos: import("convex/values").VFloat64<number, "required">;
|
|
104
107
|
totalRequests: import("convex/values").VFloat64<number, "required">;
|
|
105
108
|
totalTokens: import("convex/values").VFloat64<number, "required">;
|
|
109
|
+
creditsNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
110
|
+
creditsTodayNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
111
|
+
creditsThisMonthNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
106
112
|
dayStamp: import("convex/values").VString<string, "required">;
|
|
107
113
|
spendTodayNanos: import("convex/values").VFloat64<number, "required">;
|
|
108
114
|
tokensToday: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
@@ -116,7 +122,7 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
116
122
|
reservedMonthTokens: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
117
123
|
reservedTotalTokens: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
118
124
|
pendingCount: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
119
|
-
}, "required", "dimension" | "value" | "requestsPerMinute" | "maxConcurrent" | "dailySpendLimitNanos" | "monthlySpendLimitNanos" | "lifetimeSpendLimitNanos" | "dailyTokenLimit" | "monthlyTokenLimit" | "lifetimeTokenLimit" | "blocked" | "warnAtPct" | "enforcement" | "dailyBumpNanos" | "monthlyBumpNanos" | "lifetimeBumpNanos" | "bumpDayStamp" | "bumpMonthStamp" | "totalSpendNanos" | "totalRequests" | "totalTokens" | "dayStamp" | "spendTodayNanos" | "tokensToday" | "monthStamp" | "spendThisMonthNanos" | "tokensThisMonth" | "reservedTodayNanos" | "reservedMonthNanos" | "reservedTotalNanos" | "reservedTodayTokens" | "reservedMonthTokens" | "reservedTotalTokens" | "pendingCount">, {
|
|
125
|
+
}, "required", "dimension" | "value" | "requestsPerMinute" | "maxConcurrent" | "dailySpendLimitNanos" | "monthlySpendLimitNanos" | "lifetimeSpendLimitNanos" | "dailyTokenLimit" | "monthlyTokenLimit" | "lifetimeTokenLimit" | "blocked" | "warnAtPct" | "enforcement" | "dailyBumpNanos" | "monthlyBumpNanos" | "lifetimeBumpNanos" | "bumpDayStamp" | "bumpMonthStamp" | "totalSpendNanos" | "totalRequests" | "totalTokens" | "creditsNanos" | "creditsTodayNanos" | "creditsThisMonthNanos" | "dayStamp" | "spendTodayNanos" | "tokensToday" | "monthStamp" | "spendThisMonthNanos" | "tokensThisMonth" | "reservedTodayNanos" | "reservedMonthNanos" | "reservedTotalNanos" | "reservedTodayTokens" | "reservedMonthTokens" | "reservedTotalTokens" | "pendingCount">, {
|
|
120
126
|
dim_value: ["dimension", "value", "_creationTime"];
|
|
121
127
|
dimension: ["dimension", "_creationTime"];
|
|
122
128
|
}, {}, {}>;
|
|
@@ -279,13 +285,18 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
279
285
|
models?: string[] | undefined;
|
|
280
286
|
globalDailySpendLimitNanos?: number | undefined;
|
|
281
287
|
globalLifetimeSpendLimitNanos?: number | undefined;
|
|
282
|
-
globalEnforcement?: "
|
|
288
|
+
globalEnforcement?: "soft" | "approximate" | undefined;
|
|
283
289
|
globalDailyBumpNanos?: number | undefined;
|
|
284
290
|
globalLifetimeBumpNanos?: number | undefined;
|
|
285
291
|
globalBumpDayStamp?: string | undefined;
|
|
292
|
+
globalTrippedDaily?: boolean | undefined;
|
|
293
|
+
globalTrippedLifetime?: boolean | undefined;
|
|
294
|
+
globalNearLimit?: boolean | undefined;
|
|
286
295
|
retentionMs?: number | undefined;
|
|
287
296
|
defaultWarnAtPct?: number | undefined;
|
|
288
297
|
serverToolPrices?: Record<string, number> | undefined;
|
|
298
|
+
allowUnpricedModels?: boolean | undefined;
|
|
299
|
+
storeContent?: boolean | undefined;
|
|
289
300
|
key: string;
|
|
290
301
|
}, {
|
|
291
302
|
key: import("convex/values").VString<string, "required">;
|
|
@@ -293,14 +304,19 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
293
304
|
models: import("convex/values").VArray<string[] | undefined, import("convex/values").VString<string, "required">, "optional">;
|
|
294
305
|
globalDailySpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
295
306
|
globalLifetimeSpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
296
|
-
globalEnforcement: import("convex/values").VUnion<"
|
|
307
|
+
globalEnforcement: import("convex/values").VUnion<"soft" | "approximate" | undefined, [import("convex/values").VLiteral<"approximate", "required">, import("convex/values").VLiteral<"soft", "required">], "optional", never>;
|
|
297
308
|
globalDailyBumpNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
298
309
|
globalLifetimeBumpNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
299
310
|
globalBumpDayStamp: import("convex/values").VString<string | undefined, "optional">;
|
|
311
|
+
globalTrippedDaily: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
312
|
+
globalTrippedLifetime: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
313
|
+
globalNearLimit: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
300
314
|
retentionMs: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
301
315
|
defaultWarnAtPct: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
302
316
|
serverToolPrices: import("convex/values").VRecord<Record<string, number> | undefined, import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">, "optional", string>;
|
|
303
|
-
|
|
317
|
+
allowUnpricedModels: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
318
|
+
storeContent: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
319
|
+
}, "required", "key" | "modelMode" | "models" | "globalDailySpendLimitNanos" | "globalLifetimeSpendLimitNanos" | "globalEnforcement" | "globalDailyBumpNanos" | "globalLifetimeBumpNanos" | "globalBumpDayStamp" | "globalTrippedDaily" | "globalTrippedLifetime" | "globalNearLimit" | "retentionMs" | "defaultWarnAtPct" | "serverToolPrices" | "allowUnpricedModels" | "storeContent" | `serverToolPrices.${string}`>, {
|
|
304
320
|
key: ["key", "_creationTime"];
|
|
305
321
|
}, {}, {}>;
|
|
306
322
|
}, true>;
|
package/dist/component/schema.js
CHANGED
|
@@ -56,10 +56,17 @@ export default defineSchema({
|
|
|
56
56
|
lifetimeBumpNanos: v.optional(v.number()),
|
|
57
57
|
bumpDayStamp: v.optional(v.string()),
|
|
58
58
|
bumpMonthStamp: v.optional(v.string()),
|
|
59
|
-
// settled
|
|
59
|
+
// settled GROSS spend (real charges + positive adjustments). Credits never
|
|
60
|
+
// reduce these — they accrue separately below — so gross spend and durable
|
|
61
|
+
// history always agree. Cap checks and "net" use (gross - credits).
|
|
60
62
|
totalSpendNanos: v.number(),
|
|
61
63
|
totalRequests: v.number(),
|
|
62
64
|
totalTokens: v.number(),
|
|
65
|
+
// manual credits (comps/refunds), tracked separately from gross spend and
|
|
66
|
+
// subtracted at admission so a credit grants real headroom under a cap.
|
|
67
|
+
creditsNanos: v.optional(v.number()),
|
|
68
|
+
creditsTodayNanos: v.optional(v.number()),
|
|
69
|
+
creditsThisMonthNanos: v.optional(v.number()),
|
|
63
70
|
// daily window
|
|
64
71
|
dayStamp: v.string(),
|
|
65
72
|
spendTodayNanos: v.number(),
|
|
@@ -191,10 +198,21 @@ export default defineSchema({
|
|
|
191
198
|
// killswitch; per-bucket concurrent admission is atomic via reserve/settle.
|
|
192
199
|
globalDailySpendLimitNanos: v.optional(v.number()),
|
|
193
200
|
globalLifetimeSpendLimitNanos: v.optional(v.number()),
|
|
194
|
-
|
|
201
|
+
// "approximate" (default): a best-effort killswitch — it blocks once the
|
|
202
|
+
// sharded total crosses the cap, but with bounded overshoot (no per-request
|
|
203
|
+
// reservation). "soft": warn only. There is deliberately no "hard": a true
|
|
204
|
+
// to-the-dollar ceiling is a per-bucket cap.
|
|
205
|
+
globalEnforcement: v.optional(v.union(v.literal("approximate"), v.literal("soft"))),
|
|
195
206
|
globalDailyBumpNanos: v.optional(v.number()),
|
|
196
207
|
globalLifetimeBumpNanos: v.optional(v.number()),
|
|
197
208
|
globalBumpDayStamp: v.optional(v.string()),
|
|
209
|
+
// H4: the reconciler compares the sharded global total to the cap and sets
|
|
210
|
+
// these, so admission reads ONE settings doc instead of the sharded counter
|
|
211
|
+
// (whose per-admission read contended with every fold). Killswitch lag is
|
|
212
|
+
// bounded by the reconcile interval — fine for a deployment-wide stop.
|
|
213
|
+
globalTrippedDaily: v.optional(v.boolean()),
|
|
214
|
+
globalTrippedLifetime: v.optional(v.boolean()),
|
|
215
|
+
globalNearLimit: v.optional(v.boolean()),
|
|
198
216
|
// request-row retention window in ms (default 1h); 0 disables sweeping.
|
|
199
217
|
retentionMs: v.optional(v.number()),
|
|
200
218
|
// default approaching-limit alert threshold (fraction of a cap) for buckets
|
|
@@ -203,5 +221,13 @@ export default defineSchema({
|
|
|
203
221
|
// per-call price (nanodollars) overrides for provider server tools, keyed by
|
|
204
222
|
// tool name (e.g. { web_search: 12_000_000 }). Merged over the defaults.
|
|
205
223
|
serverToolPrices: v.optional(v.record(v.string(), v.number())),
|
|
224
|
+
// When false, reject requests for a model with no known/override price under
|
|
225
|
+
// hard enforcement (instead of charging the conservative fallback). Default
|
|
226
|
+
// true (charge the fallback, keep the call working).
|
|
227
|
+
allowUnpricedModels: v.optional(v.boolean()),
|
|
228
|
+
// When false, don't persist prompt/response content on request rows (only
|
|
229
|
+
// metadata + cost). Default true. For teams that want zero prompt retention
|
|
230
|
+
// rather than short retention.
|
|
231
|
+
storeContent: v.optional(v.boolean()),
|
|
206
232
|
}).index("key", ["key"]),
|
|
207
233
|
});
|
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -991,13 +991,17 @@ export class AIBudget {
|
|
|
991
991
|
return {
|
|
992
992
|
/** Limits + spend today/total. */
|
|
993
993
|
status: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.getGlobalStatus, {}),
|
|
994
|
-
/**
|
|
994
|
+
/**
|
|
995
|
+
* A killswitch spend cap across all users/actions. Enforced
|
|
996
|
+
* **approximately** (bounded overshoot, no per-request reservation) — for
|
|
997
|
+
* an exact ceiling use a per-bucket cap. Pass `null` to clear a field.
|
|
998
|
+
*/
|
|
995
999
|
setLimits: (
|
|
996
1000
|
ctx: RunMutationCtx,
|
|
997
1001
|
args: {
|
|
998
|
-
dailySpendLimitNanos?: number;
|
|
999
|
-
lifetimeSpendLimitNanos?: number;
|
|
1000
|
-
enforcement?: "
|
|
1002
|
+
dailySpendLimitNanos?: number | null;
|
|
1003
|
+
lifetimeSpendLimitNanos?: number | null;
|
|
1004
|
+
enforcement?: "approximate" | "soft" | null;
|
|
1001
1005
|
}
|
|
1002
1006
|
) => ctx.runMutation(c.lib.setGlobalLimits, args),
|
|
1003
1007
|
bump: (
|
|
@@ -1010,6 +1014,17 @@ export class AIBudget {
|
|
|
1010
1014
|
/** Request-row retention window in ms (default 1h; 0 disables). */
|
|
1011
1015
|
setRetention: (ctx: RunMutationCtx, args: { retentionMs: number }) =>
|
|
1012
1016
|
ctx.runMutation(c.lib.setRetention, args),
|
|
1017
|
+
/**
|
|
1018
|
+
* Deployment-wide data/pricing policy (only the fields you pass change):
|
|
1019
|
+
* - `allowUnpricedModels: false` rejects models with no configured price
|
|
1020
|
+
* under hard enforcement (default true — charge the conservative fallback).
|
|
1021
|
+
* - `storeContent: false` stops persisting prompt/response content on
|
|
1022
|
+
* request rows (default true).
|
|
1023
|
+
*/
|
|
1024
|
+
setPolicy: (
|
|
1025
|
+
ctx: RunMutationCtx,
|
|
1026
|
+
args: { allowUnpricedModels?: boolean; storeContent?: boolean }
|
|
1027
|
+
) => ctx.runMutation(c.lib.setDeploymentPolicy, args),
|
|
1013
1028
|
};
|
|
1014
1029
|
}
|
|
1015
1030
|
|
|
@@ -95,7 +95,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
95
95
|
{
|
|
96
96
|
dailySpendLimitNanos: number | null;
|
|
97
97
|
defaultWarnAtPct: number | null;
|
|
98
|
-
enforcement: "
|
|
98
|
+
enforcement: "approximate" | "soft";
|
|
99
99
|
lifetimeSpendLimitNanos: number | null;
|
|
100
100
|
retentionMs: number | null;
|
|
101
101
|
spentTodayNanos: number;
|
|
@@ -181,12 +181,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
181
181
|
null,
|
|
182
182
|
Name
|
|
183
183
|
>;
|
|
184
|
+
setDeploymentPolicy: FunctionReference<
|
|
185
|
+
"mutation",
|
|
186
|
+
"internal",
|
|
187
|
+
{ allowUnpricedModels?: boolean; storeContent?: boolean },
|
|
188
|
+
null,
|
|
189
|
+
Name
|
|
190
|
+
>;
|
|
184
191
|
setGlobalLimits: FunctionReference<
|
|
185
192
|
"mutation",
|
|
186
193
|
"internal",
|
|
187
194
|
{
|
|
188
195
|
dailySpendLimitNanos?: number | null;
|
|
189
|
-
enforcement?: "
|
|
196
|
+
enforcement?: "approximate" | "soft" | null;
|
|
190
197
|
lifetimeSpendLimitNanos?: number | null;
|
|
191
198
|
},
|
|
192
199
|
null,
|
|
@@ -245,7 +245,7 @@ describe("durable usage history", () => {
|
|
|
245
245
|
});
|
|
246
246
|
|
|
247
247
|
describe("manual adjustments", () => {
|
|
248
|
-
test("a credit
|
|
248
|
+
test("a credit accrues separately from gross spend and grants headroom", async () => {
|
|
249
249
|
const t = initTest();
|
|
250
250
|
const r = await start(t, { userId: "u" });
|
|
251
251
|
await settleWith(t, r.requestId, { promptTokens: 1_000_000, completionTokens: 1_000_000 });
|
|
@@ -256,11 +256,26 @@ describe("manual adjustments", () => {
|
|
|
256
256
|
reason: "goodwill credit",
|
|
257
257
|
});
|
|
258
258
|
const u = await userOf(t, "u");
|
|
259
|
-
|
|
259
|
+
// Gross spend is unchanged (credits never reduce it); the credit is tracked
|
|
260
|
+
// separately. Net = gross - credits = 500M.
|
|
261
|
+
expect(u.totalSpendNanos).toBe(750_000_000);
|
|
262
|
+
expect(u.creditsNanos).toBe(250_000_000);
|
|
260
263
|
const log = await t.query(api.lib.listAdjustments, { dimension: "user", value: "u" });
|
|
261
264
|
expect(log.length).toBe(1);
|
|
262
265
|
expect(log[0].deltaNanos).toBe(-250_000_000);
|
|
263
266
|
});
|
|
267
|
+
|
|
268
|
+
test("a credit grants headroom under a cap; a debit consumes gross spend", async () => {
|
|
269
|
+
const t = initTest();
|
|
270
|
+
await setUserLimits(t, "u", { lifetimeSpendLimitNanos: 1_000_000_000 });
|
|
271
|
+
const r = await start(t, { userId: "u" });
|
|
272
|
+
await settleWith(t, r.requestId, { costNanos: 900_000_000 }); // $0.90 gross
|
|
273
|
+
// Right at the edge: a $0.20 estimate would exceed the $1 cap...
|
|
274
|
+
expect((await start(t, { userId: "u", estimatedCostNanos: 200_000_000 })).allowed).toBe(false);
|
|
275
|
+
// ...but a $0.30 credit (net spend $0.60) reopens headroom.
|
|
276
|
+
await t.mutation(api.lib.adjustBucket, { dimension: "user", value: "u", deltaNanos: -300_000_000 });
|
|
277
|
+
expect((await start(t, { userId: "u", estimatedCostNanos: 200_000_000 })).allowed).toBe(true);
|
|
278
|
+
});
|
|
264
279
|
});
|
|
265
280
|
|
|
266
281
|
describe("threshold alerts", () => {
|
|
@@ -639,6 +654,11 @@ describe("accounting lifecycle regressions", () => {
|
|
|
639
654
|
await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
|
|
640
655
|
expect((await t.query(api.lib.getGlobalStatus, {})).spentTotalNanos).toBe(100);
|
|
641
656
|
await t.mutation(api.lib.setGlobalLimits, { lifetimeSpendLimitNanos: 100 });
|
|
657
|
+
// The killswitch trips out-of-band (H4): the reconciler's globalPhase
|
|
658
|
+
// compares the sharded total to the cap and flags settings; admission reads
|
|
659
|
+
// the flag. So a request admits until the flag is set, then blocks.
|
|
660
|
+
expect((await start(t, { userId: "u", estimatedCostNanos: 1 })).allowed).toBe(true);
|
|
661
|
+
await t.mutation(internal.lib.globalPhase, {});
|
|
642
662
|
expect((await start(t, { userId: "u", estimatedCostNanos: 1 })).allowed).toBe(false);
|
|
643
663
|
});
|
|
644
664
|
|
|
@@ -844,3 +864,30 @@ describe("v1 hardening (round 3): reconcile phases", () => {
|
|
|
844
864
|
}
|
|
845
865
|
});
|
|
846
866
|
});
|
|
867
|
+
|
|
868
|
+
describe("v1: deployment policy (unpriced models, content storage)", () => {
|
|
869
|
+
test("blocking unpriced models rejects a model with no configured price", async () => {
|
|
870
|
+
const t = initTest();
|
|
871
|
+
await t.mutation(api.lib.setDeploymentPolicy, { allowUnpricedModels: false });
|
|
872
|
+
const r = await start(t, { userId: "u", model: "made/up-model" });
|
|
873
|
+
expect(r.allowed).toBe(false);
|
|
874
|
+
expect(r.code).toBe("model_unpriced");
|
|
875
|
+
// A priced model still admits.
|
|
876
|
+
expect((await start(t, { userId: "u", model: MODEL })).allowed).toBe(true);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
test("storeContent:false keeps metadata but drops prompt/response content", async () => {
|
|
880
|
+
const t = initTest();
|
|
881
|
+
await t.mutation(api.lib.setDeploymentPolicy, { storeContent: false });
|
|
882
|
+
const r = await start(t, { userId: "u" });
|
|
883
|
+
await settleWith(t, r.requestId, {
|
|
884
|
+
promptTokens: 10,
|
|
885
|
+
completionTokens: 5,
|
|
886
|
+
responseText: "secret answer",
|
|
887
|
+
});
|
|
888
|
+
const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
|
|
889
|
+
expect(req.messages).toEqual([]); // prompt not stored
|
|
890
|
+
expect(req.responseText ?? undefined).toBe(undefined); // response not stored
|
|
891
|
+
expect(req.promptTokens).toBe(10); // metadata/cost still recorded
|
|
892
|
+
});
|
|
893
|
+
});
|
package/src/component/lib.ts
CHANGED
|
@@ -316,7 +316,8 @@ function sanitizeExtraTags(
|
|
|
316
316
|
function evaluateCaps(o: {
|
|
317
317
|
label: string;
|
|
318
318
|
name: string;
|
|
319
|
-
|
|
319
|
+
// "approximate" is the global killswitch mode; it blocks like "hard" here.
|
|
320
|
+
enforcement: "hard" | "soft" | "approximate";
|
|
320
321
|
warnAtPct?: number;
|
|
321
322
|
estCost: number;
|
|
322
323
|
estTokens: number;
|
|
@@ -326,6 +327,10 @@ function evaluateCaps(o: {
|
|
|
326
327
|
reservedSpendMonth: number;
|
|
327
328
|
totalSpend: number;
|
|
328
329
|
reservedSpendTotal: number;
|
|
330
|
+
// Manual credits (subtracted from spend so a credit grants headroom).
|
|
331
|
+
creditsToday?: number;
|
|
332
|
+
creditsThisMonth?: number;
|
|
333
|
+
creditsTotal?: number;
|
|
329
334
|
tokensToday: number;
|
|
330
335
|
reservedTokensToday: number;
|
|
331
336
|
tokensThisMonth: number;
|
|
@@ -346,9 +351,9 @@ function evaluateCaps(o: {
|
|
|
346
351
|
// { code, projected usage (incl. this estimate), cap, human window label,
|
|
347
352
|
// whether it's a money cap (formatted as $), spend? for notices }
|
|
348
353
|
const checks = [
|
|
349
|
-
{ w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost, cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
|
|
350
|
-
{ w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost, cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
|
|
351
|
-
{ w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost, cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
|
|
354
|
+
{ w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost - (o.creditsToday ?? 0), cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
|
|
355
|
+
{ w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost - (o.creditsThisMonth ?? 0), cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
|
|
356
|
+
{ w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost - (o.creditsTotal ?? 0), cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
|
|
352
357
|
{ w: "daily_token_limit", used: o.tokensToday + o.reservedTokensToday + o.estTokens, cap: o.dailyTokenLimit, label: "daily token limit", money: false },
|
|
353
358
|
{ w: "monthly_token_limit", used: o.tokensThisMonth + o.reservedTokensMonth + o.estTokens, cap: o.monthlyTokenLimit, label: "monthly token limit", money: false },
|
|
354
359
|
{ w: "lifetime_token_limit", used: o.totalTokens + o.reservedTokensTotal + o.estTokens, cap: o.lifetimeTokenLimit, label: "lifetime token limit", money: false },
|
|
@@ -525,6 +530,9 @@ export const startRequest = mutation({
|
|
|
525
530
|
// (false) and the bucket admits unlimited spend. Reject it up front.
|
|
526
531
|
assertAmount(args.estimatedCostNanos, "estimatedCostNanos");
|
|
527
532
|
const extraTags = sanitizeExtraTags(args.tags);
|
|
533
|
+
// Set from settings below; when false we persist metadata but no prompt
|
|
534
|
+
// content (a deployment that opts out of storing prompts entirely).
|
|
535
|
+
let storeContent = true;
|
|
528
536
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
529
537
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
530
538
|
// rejections (rate limit, blocked user) that a client retries in a tight
|
|
@@ -537,7 +545,7 @@ export const startRequest = mutation({
|
|
|
537
545
|
actionName: args.actionName,
|
|
538
546
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
539
547
|
model: args.model,
|
|
540
|
-
messages: args.messages,
|
|
548
|
+
messages: storeContent ? args.messages : [],
|
|
541
549
|
rerunOf: args.rerunOf,
|
|
542
550
|
status: "blocked" as const,
|
|
543
551
|
error: reason,
|
|
@@ -569,6 +577,7 @@ export const startRequest = mutation({
|
|
|
569
577
|
|
|
570
578
|
// Model allow/deny policy (component-wide).
|
|
571
579
|
const settings = await getSettings(ctx);
|
|
580
|
+
storeContent = settings?.storeContent !== false;
|
|
572
581
|
const defaultWarnAtPct = settings?.defaultWarnAtPct;
|
|
573
582
|
if (settings) {
|
|
574
583
|
const mode = settings.modelMode ?? "open";
|
|
@@ -582,6 +591,14 @@ export const startRequest = mutation({
|
|
|
582
591
|
if (mode === "denylist" && list.includes(args.model)) {
|
|
583
592
|
return reject("model_denied", `Model "${args.model}" is denied`);
|
|
584
593
|
}
|
|
594
|
+
// Optionally refuse models with no known/override price rather than
|
|
595
|
+
// charging the conservative fallback (which under-counts premium models).
|
|
596
|
+
if (settings.allowUnpricedModels === false && !priceInfo.known) {
|
|
597
|
+
return reject(
|
|
598
|
+
"model_unpriced",
|
|
599
|
+
`Model "${args.model}" has no configured price; set one with setPrice or allow unpriced models`
|
|
600
|
+
);
|
|
601
|
+
}
|
|
585
602
|
}
|
|
586
603
|
|
|
587
604
|
// Fetch/create every bucket this request is attributed to (user, action,
|
|
@@ -660,6 +677,9 @@ export const startRequest = mutation({
|
|
|
660
677
|
reservedSpendMonth: sameMonth ? b.reservedMonthNanos ?? 0 : 0,
|
|
661
678
|
totalSpend: b.totalSpendNanos,
|
|
662
679
|
reservedSpendTotal: b.reservedTotalNanos ?? 0,
|
|
680
|
+
creditsToday: sameDay ? b.creditsTodayNanos ?? 0 : 0,
|
|
681
|
+
creditsThisMonth: sameMonth ? b.creditsThisMonthNanos ?? 0 : 0,
|
|
682
|
+
creditsTotal: b.creditsNanos ?? 0,
|
|
663
683
|
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
664
684
|
reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
|
|
665
685
|
tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
|
|
@@ -695,42 +715,26 @@ export const startRequest = mutation({
|
|
|
695
715
|
// throughput. The sum is transactional, but excludes unsettled usage and
|
|
696
716
|
// has no cross-request reservation, so a hard global cap can overshoot. That's the deliberate consistency/throughput
|
|
697
717
|
// trade for a deployment-wide killswitch; it's the only approximate scope.
|
|
718
|
+
// H4: don't read the sharded counter here — that per-admission read
|
|
719
|
+
// contended with every fold that writes it. The reconciler compares the
|
|
720
|
+
// total to the cap out-of-band and records trip flags on `settings`;
|
|
721
|
+
// admission just reads those (already-loaded) flags. The killswitch lag is
|
|
722
|
+
// bounded by the reconcile interval, which is the point of "approximate".
|
|
698
723
|
if (
|
|
699
724
|
settings &&
|
|
700
|
-
(settings.
|
|
701
|
-
settings.globalLifetimeSpendLimitNanos !== undefined)
|
|
725
|
+
(settings.globalTrippedDaily || settings.globalTrippedLifetime)
|
|
702
726
|
) {
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
715
|
-
reservedSpendTotal: 0,
|
|
716
|
-
tokensToday: 0,
|
|
717
|
-
reservedTokensToday: 0,
|
|
718
|
-
tokensThisMonth: 0,
|
|
719
|
-
reservedTokensMonth: 0,
|
|
720
|
-
totalTokens: 0,
|
|
721
|
-
reservedTokensTotal: 0,
|
|
722
|
-
dailySpendLimitNanos: withBump(
|
|
723
|
-
settings.globalDailySpendLimitNanos,
|
|
724
|
-
settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0
|
|
725
|
-
),
|
|
726
|
-
lifetimeSpendLimitNanos: withBump(
|
|
727
|
-
settings.globalLifetimeSpendLimitNanos,
|
|
728
|
-
settings.globalLifetimeBumpNanos
|
|
729
|
-
),
|
|
730
|
-
});
|
|
731
|
-
if (globalEval.hard) return reject(globalEval.hard.code, globalEval.hard.reason);
|
|
732
|
-
warnings.push(...globalEval.warnings);
|
|
733
|
-
notices.push(...globalEval.notices);
|
|
727
|
+
const enforcement = settings.globalEnforcement ?? "approximate";
|
|
728
|
+
if (enforcement === "soft") {
|
|
729
|
+
warnings.push(`Global spend limit reached for the deployment (allowed — soft)`);
|
|
730
|
+
} else {
|
|
731
|
+
return reject(
|
|
732
|
+
"global_spend_limit",
|
|
733
|
+
"Global spend limit reached for the deployment"
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
} else if (settings?.globalNearLimit) {
|
|
737
|
+
notices.push("Deployment approaching its global spend limit");
|
|
734
738
|
}
|
|
735
739
|
|
|
736
740
|
// Consume only after every admission check succeeds, in the same
|
|
@@ -781,7 +785,7 @@ export const startRequest = mutation({
|
|
|
781
785
|
actionName: args.actionName,
|
|
782
786
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
783
787
|
model: args.model,
|
|
784
|
-
messages: args.messages,
|
|
788
|
+
messages: storeContent ? args.messages : [],
|
|
785
789
|
rerunOf: args.rerunOf,
|
|
786
790
|
...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
|
|
787
791
|
status: "pending",
|
|
@@ -849,11 +853,12 @@ export const finishRequest = mutation({
|
|
|
849
853
|
// tool fees); otherwise price from tokens — discounting the cached
|
|
850
854
|
// (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
|
|
851
855
|
// finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
|
|
856
|
+
const settings = await getSettings(ctx);
|
|
857
|
+
const storeContent = settings?.storeContent !== false;
|
|
852
858
|
let costNanos: number;
|
|
853
859
|
if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
|
|
854
860
|
costNanos = Math.round(args.costNanos);
|
|
855
861
|
} else {
|
|
856
|
-
const settings = await getSettings(ctx);
|
|
857
862
|
const priced =
|
|
858
863
|
settleCost(
|
|
859
864
|
promptTokens,
|
|
@@ -879,7 +884,7 @@ export const finishRequest = mutation({
|
|
|
879
884
|
reservationExpired: false,
|
|
880
885
|
expiresAt: undefined,
|
|
881
886
|
finishedAt: Date.now(),
|
|
882
|
-
responseText: args.responseText,
|
|
887
|
+
responseText: storeContent ? args.responseText : undefined,
|
|
883
888
|
error: args.error,
|
|
884
889
|
promptTokens,
|
|
885
890
|
completionTokens,
|
|
@@ -986,6 +991,52 @@ export const reconcile = internalMutation({
|
|
|
986
991
|
await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
|
|
987
992
|
await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
|
|
988
993
|
await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
994
|
+
await ctx.scheduler.runAfter(0, internal.lib.globalPhase, {});
|
|
995
|
+
return null;
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// H4: compute the deployment-wide global-cap trip flags out-of-band so admission
|
|
1000
|
+
// never reads the sharded counter on its hot path. One cheap counter read here
|
|
1001
|
+
// per interval; admission then consults the flags on `settings`.
|
|
1002
|
+
export const globalPhase = internalMutation({
|
|
1003
|
+
args: {},
|
|
1004
|
+
returns: v.null(),
|
|
1005
|
+
handler: async (ctx) => {
|
|
1006
|
+
const s = await getSettings(ctx);
|
|
1007
|
+
if (!s) return null;
|
|
1008
|
+
const hasCap =
|
|
1009
|
+
s.globalDailySpendLimitNanos !== undefined ||
|
|
1010
|
+
s.globalLifetimeSpendLimitNanos !== undefined;
|
|
1011
|
+
if (!hasCap) {
|
|
1012
|
+
// Clear stale flags when no global cap is configured.
|
|
1013
|
+
if (s.globalTrippedDaily || s.globalTrippedLifetime || s.globalNearLimit)
|
|
1014
|
+
await ctx.db.patch(s._id, {
|
|
1015
|
+
globalTrippedDaily: false,
|
|
1016
|
+
globalTrippedLifetime: false,
|
|
1017
|
+
globalNearLimit: false,
|
|
1018
|
+
});
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
const today = dayStamp();
|
|
1022
|
+
const dailyCap = withBump(
|
|
1023
|
+
s.globalDailySpendLimitNanos,
|
|
1024
|
+
s.globalBumpDayStamp === today ? s.globalDailyBumpNanos : 0
|
|
1025
|
+
);
|
|
1026
|
+
const lifetimeCap = withBump(
|
|
1027
|
+
s.globalLifetimeSpendLimitNanos,
|
|
1028
|
+
s.globalLifetimeBumpNanos
|
|
1029
|
+
);
|
|
1030
|
+
const spentToday = await globalSpend.count(ctx, globalDayKey(today));
|
|
1031
|
+
const spentTotal = await globalSpend.count(ctx, GLOBAL_TOTAL);
|
|
1032
|
+
const pct = s.defaultWarnAtPct;
|
|
1033
|
+
const near = (spent: number, cap?: number) =>
|
|
1034
|
+
cap !== undefined && pct !== undefined && pct > 0 && pct < 1 && spent >= pct * cap;
|
|
1035
|
+
await ctx.db.patch(s._id, {
|
|
1036
|
+
globalTrippedDaily: dailyCap !== undefined && spentToday >= dailyCap,
|
|
1037
|
+
globalTrippedLifetime: lifetimeCap !== undefined && spentTotal >= lifetimeCap,
|
|
1038
|
+
globalNearLimit: near(spentToday, dailyCap) || near(spentTotal, lifetimeCap),
|
|
1039
|
+
});
|
|
989
1040
|
return null;
|
|
990
1041
|
},
|
|
991
1042
|
});
|
|
@@ -1302,15 +1353,25 @@ export const adjustBucket = mutation({
|
|
|
1302
1353
|
const dSame = b.dayStamp === today;
|
|
1303
1354
|
const mSame = b.monthStamp === month;
|
|
1304
1355
|
const dt = tokens ?? 0;
|
|
1356
|
+
// Gross-plus-credits ledger: a positive delta is a real extra charge (adds
|
|
1357
|
+
// to GROSS spend); a negative delta is a credit/refund that accrues to a
|
|
1358
|
+
// separate credits balance and NEVER reduces gross spend — so gross spend
|
|
1359
|
+
// and durable history stay consistent, and net = gross - credits. Credits
|
|
1360
|
+
// grant headroom because admission subtracts them from the cap check.
|
|
1361
|
+
const debit = deltaNanos > 0 ? deltaNanos : 0;
|
|
1362
|
+
const credit = deltaNanos < 0 ? -deltaNanos : 0;
|
|
1305
1363
|
await ctx.db.patch(b._id, {
|
|
1306
|
-
totalSpendNanos:
|
|
1364
|
+
totalSpendNanos: b.totalSpendNanos + debit,
|
|
1307
1365
|
totalTokens: Math.max(0, b.totalTokens + dt),
|
|
1308
1366
|
dayStamp: today,
|
|
1309
1367
|
monthStamp: month,
|
|
1310
|
-
spendTodayNanos:
|
|
1368
|
+
spendTodayNanos: (dSame ? b.spendTodayNanos : 0) + debit,
|
|
1311
1369
|
tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
|
|
1312
|
-
spendThisMonthNanos:
|
|
1370
|
+
spendThisMonthNanos: (mSame ? b.spendThisMonthNanos ?? 0 : 0) + debit,
|
|
1313
1371
|
tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
|
|
1372
|
+
creditsNanos: (b.creditsNanos ?? 0) + credit,
|
|
1373
|
+
creditsTodayNanos: (dSame ? b.creditsTodayNanos ?? 0 : 0) + credit,
|
|
1374
|
+
creditsThisMonthNanos: (mSame ? b.creditsThisMonthNanos ?? 0 : 0) + credit,
|
|
1314
1375
|
// Advancing the window here must also clear the OLD window's reserved
|
|
1315
1376
|
// holds, or an in-flight request from the previous day/month would be
|
|
1316
1377
|
// treated as reserving against the new window and its later release would
|
|
@@ -1319,8 +1380,10 @@ export const adjustBucket = mutation({
|
|
|
1319
1380
|
...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
|
|
1320
1381
|
});
|
|
1321
1382
|
await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
|
|
1322
|
-
|
|
1323
|
-
|
|
1383
|
+
// Durable history tracks GROSS spend only (debits); credits live in the
|
|
1384
|
+
// adjustments log + bucket balance, so usage rollups never go negative.
|
|
1385
|
+
await addUsage(ctx, dimension, value, "day", today, debit, dt, 0);
|
|
1386
|
+
await addUsage(ctx, dimension, value, "month", month, debit, dt, 0);
|
|
1324
1387
|
return null;
|
|
1325
1388
|
},
|
|
1326
1389
|
});
|
|
@@ -1367,6 +1430,28 @@ export const setAlertDefaults = mutation({
|
|
|
1367
1430
|
},
|
|
1368
1431
|
});
|
|
1369
1432
|
|
|
1433
|
+
// Deployment-wide data/pricing policy. Only the fields you pass change.
|
|
1434
|
+
// - allowUnpricedModels: false rejects models with no configured price under
|
|
1435
|
+
// hard enforcement (default true — charge the conservative fallback).
|
|
1436
|
+
// - storeContent: false stops persisting prompt/response content on request
|
|
1437
|
+
// rows (default true).
|
|
1438
|
+
export const setDeploymentPolicy = mutation({
|
|
1439
|
+
args: {
|
|
1440
|
+
allowUnpricedModels: v.optional(v.boolean()),
|
|
1441
|
+
storeContent: v.optional(v.boolean()),
|
|
1442
|
+
},
|
|
1443
|
+
returns: v.null(),
|
|
1444
|
+
handler: async (ctx, args) => {
|
|
1445
|
+
const patch: Record<string, unknown> = {};
|
|
1446
|
+
if ("allowUnpricedModels" in args) patch.allowUnpricedModels = args.allowUnpricedModels;
|
|
1447
|
+
if ("storeContent" in args) patch.storeContent = args.storeContent;
|
|
1448
|
+
const existing = await getSettings(ctx);
|
|
1449
|
+
if (existing) await ctx.db.patch(existing._id, patch);
|
|
1450
|
+
else await ctx.db.insert("settings", { key: "singleton", ...patch });
|
|
1451
|
+
return null;
|
|
1452
|
+
},
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1370
1455
|
// Delete a bucket and (for the `user` dimension) all of that user's request
|
|
1371
1456
|
// rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
|
|
1372
1457
|
// self-reschedules so it never exceeds the per-transaction document limit.
|
|
@@ -1448,7 +1533,7 @@ export const getGlobalStatus = query({
|
|
|
1448
1533
|
returns: v.object({
|
|
1449
1534
|
dailySpendLimitNanos: v.union(v.number(), v.null()),
|
|
1450
1535
|
lifetimeSpendLimitNanos: v.union(v.number(), v.null()),
|
|
1451
|
-
enforcement: v.union(v.literal("
|
|
1536
|
+
enforcement: v.union(v.literal("approximate"), v.literal("soft")),
|
|
1452
1537
|
spentTodayNanos: v.number(),
|
|
1453
1538
|
spentTotalNanos: v.number(),
|
|
1454
1539
|
// deployment-wide config (surfaced for the admin dashboard)
|
|
@@ -1463,7 +1548,7 @@ export const getGlobalStatus = query({
|
|
|
1463
1548
|
return {
|
|
1464
1549
|
dailySpendLimitNanos: s?.globalDailySpendLimitNanos ?? null,
|
|
1465
1550
|
lifetimeSpendLimitNanos: s?.globalLifetimeSpendLimitNanos ?? null,
|
|
1466
|
-
enforcement: s?.globalEnforcement ?? "
|
|
1551
|
+
enforcement: s?.globalEnforcement ?? "approximate",
|
|
1467
1552
|
spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
|
|
1468
1553
|
spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
1469
1554
|
retentionMs: s?.retentionMs ?? null,
|
|
@@ -1481,7 +1566,7 @@ export const setGlobalLimits = mutation({
|
|
|
1481
1566
|
dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1482
1567
|
lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1483
1568
|
enforcement: v.optional(
|
|
1484
|
-
v.union(v.literal("
|
|
1569
|
+
v.union(v.literal("approximate"), v.literal("soft"), v.null())
|
|
1485
1570
|
),
|
|
1486
1571
|
},
|
|
1487
1572
|
returns: v.null(),
|
package/src/component/schema.ts
CHANGED
|
@@ -59,10 +59,17 @@ export default defineSchema({
|
|
|
59
59
|
lifetimeBumpNanos: v.optional(v.number()),
|
|
60
60
|
bumpDayStamp: v.optional(v.string()),
|
|
61
61
|
bumpMonthStamp: v.optional(v.string()),
|
|
62
|
-
// settled
|
|
62
|
+
// settled GROSS spend (real charges + positive adjustments). Credits never
|
|
63
|
+
// reduce these — they accrue separately below — so gross spend and durable
|
|
64
|
+
// history always agree. Cap checks and "net" use (gross - credits).
|
|
63
65
|
totalSpendNanos: v.number(),
|
|
64
66
|
totalRequests: v.number(),
|
|
65
67
|
totalTokens: v.number(),
|
|
68
|
+
// manual credits (comps/refunds), tracked separately from gross spend and
|
|
69
|
+
// subtracted at admission so a credit grants real headroom under a cap.
|
|
70
|
+
creditsNanos: v.optional(v.number()),
|
|
71
|
+
creditsTodayNanos: v.optional(v.number()),
|
|
72
|
+
creditsThisMonthNanos: v.optional(v.number()),
|
|
66
73
|
// daily window
|
|
67
74
|
dayStamp: v.string(),
|
|
68
75
|
spendTodayNanos: v.number(),
|
|
@@ -211,12 +218,23 @@ export default defineSchema({
|
|
|
211
218
|
// killswitch; per-bucket concurrent admission is atomic via reserve/settle.
|
|
212
219
|
globalDailySpendLimitNanos: v.optional(v.number()),
|
|
213
220
|
globalLifetimeSpendLimitNanos: v.optional(v.number()),
|
|
221
|
+
// "approximate" (default): a best-effort killswitch — it blocks once the
|
|
222
|
+
// sharded total crosses the cap, but with bounded overshoot (no per-request
|
|
223
|
+
// reservation). "soft": warn only. There is deliberately no "hard": a true
|
|
224
|
+
// to-the-dollar ceiling is a per-bucket cap.
|
|
214
225
|
globalEnforcement: v.optional(
|
|
215
|
-
v.union(v.literal("
|
|
226
|
+
v.union(v.literal("approximate"), v.literal("soft"))
|
|
216
227
|
),
|
|
217
228
|
globalDailyBumpNanos: v.optional(v.number()),
|
|
218
229
|
globalLifetimeBumpNanos: v.optional(v.number()),
|
|
219
230
|
globalBumpDayStamp: v.optional(v.string()),
|
|
231
|
+
// H4: the reconciler compares the sharded global total to the cap and sets
|
|
232
|
+
// these, so admission reads ONE settings doc instead of the sharded counter
|
|
233
|
+
// (whose per-admission read contended with every fold). Killswitch lag is
|
|
234
|
+
// bounded by the reconcile interval — fine for a deployment-wide stop.
|
|
235
|
+
globalTrippedDaily: v.optional(v.boolean()),
|
|
236
|
+
globalTrippedLifetime: v.optional(v.boolean()),
|
|
237
|
+
globalNearLimit: v.optional(v.boolean()),
|
|
220
238
|
// request-row retention window in ms (default 1h); 0 disables sweeping.
|
|
221
239
|
retentionMs: v.optional(v.number()),
|
|
222
240
|
// default approaching-limit alert threshold (fraction of a cap) for buckets
|
|
@@ -225,5 +243,13 @@ export default defineSchema({
|
|
|
225
243
|
// per-call price (nanodollars) overrides for provider server tools, keyed by
|
|
226
244
|
// tool name (e.g. { web_search: 12_000_000 }). Merged over the defaults.
|
|
227
245
|
serverToolPrices: v.optional(v.record(v.string(), v.number())),
|
|
246
|
+
// When false, reject requests for a model with no known/override price under
|
|
247
|
+
// hard enforcement (instead of charging the conservative fallback). Default
|
|
248
|
+
// true (charge the fallback, keep the call working).
|
|
249
|
+
allowUnpricedModels: v.optional(v.boolean()),
|
|
250
|
+
// When false, don't persist prompt/response content on request rows (only
|
|
251
|
+
// metadata + cost). Default true. For teams that want zero prompt retention
|
|
252
|
+
// rather than short retention.
|
|
253
|
+
storeContent: v.optional(v.boolean()),
|
|
228
254
|
}).index("key", ["key"]),
|
|
229
255
|
});
|