@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 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
- Negative = credit, positive = extra charge; it adjusts the live day/month/lifetime
436
- windows, the history, and an audit log.
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) // { limits, spentTodayNanos, spentTotalNanos, … }
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
@@ -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: "hard" | "soft";
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
- /** A killswitch spend cap across all users/actions (enforced approximately). */
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?: "hard" | "soft";
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(): {
@@ -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
- /** A killswitch spend cap across all users/actions (enforced approximately). */
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: "hard" | "soft";
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?: "hard" | "soft" | null;
125
+ enforcement?: "approximate" | "soft" | null;
122
126
  lifetimeSpendLimitNanos?: number | null;
123
127
  }, null, Name>;
124
128
  setModelPolicy: FunctionReference<"mutation", "internal", {
@@ -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: "hard" | "soft";
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?: "hard" | "soft" | null | undefined;
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;
@@ -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.globalDailySpendLimitNanos !== undefined ||
547
- settings.globalLifetimeSpendLimitNanos !== undefined)) {
548
- const globalEval = evaluateCaps({
549
- label: "global",
550
- name: "deployment",
551
- enforcement: settings.globalEnforcement ?? "hard",
552
- warnAtPct: defaultWarnAtPct,
553
- estCost: est.cost,
554
- estTokens: est.tokens,
555
- spendToday: await globalSpend.count(ctx, globalDayKey(today)),
556
- reservedSpendToday: 0, // sharded holder: no cross-request reservation
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: Math.max(0, b.totalSpendNanos + deltaNanos),
1167
+ totalSpendNanos: b.totalSpendNanos + debit,
1122
1168
  totalTokens: Math.max(0, b.totalTokens + dt),
1123
1169
  dayStamp: today,
1124
1170
  monthStamp: month,
1125
- spendTodayNanos: Math.max(0, (dSame ? b.spendTodayNanos : 0) + deltaNanos),
1171
+ spendTodayNanos: (dSame ? b.spendTodayNanos : 0) + debit,
1126
1172
  tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
1127
- spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
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
- await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
1138
- await addUsage(ctx, dimension, value, "month", month, deltaNanos, dt, 0);
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("hard"), v.literal("soft")),
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 ?? "hard",
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("hard"), v.literal("soft"), v.null())),
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?: "hard" | "soft" | undefined;
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<"hard" | "soft" | undefined, [import("convex/values").VLiteral<"hard", "required">, import("convex/values").VLiteral<"soft", "required">], "optional", never>;
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
- }, "required", "key" | "modelMode" | "models" | "globalDailySpendLimitNanos" | "globalLifetimeSpendLimitNanos" | "globalEnforcement" | "globalDailyBumpNanos" | "globalLifetimeBumpNanos" | "globalBumpDayStamp" | "retentionMs" | "defaultWarnAtPct" | "serverToolPrices" | `serverToolPrices.${string}`>, {
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>;
@@ -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 totals (from finished requests)
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
- globalEnforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
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
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/ai-budget/issues"
9
9
  },
10
- "version": "0.0.2-alpha.18",
10
+ "version": "0.0.2-alpha.19",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -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
- /** A killswitch spend cap across all users/actions (enforced approximately). */
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?: "hard" | "soft";
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: "hard" | "soft";
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?: "hard" | "soft" | null;
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 reduces spend and is logged", async () => {
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
- expect(u.totalSpendNanos).toBe(500_000_000); // 750M - 250M
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
+ });
@@ -316,7 +316,8 @@ function sanitizeExtraTags(
316
316
  function evaluateCaps(o: {
317
317
  label: string;
318
318
  name: string;
319
- enforcement: "hard" | "soft";
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.globalDailySpendLimitNanos !== undefined ||
701
- settings.globalLifetimeSpendLimitNanos !== undefined)
725
+ (settings.globalTrippedDaily || settings.globalTrippedLifetime)
702
726
  ) {
703
- const globalEval = evaluateCaps({
704
- label: "global",
705
- name: "deployment",
706
- enforcement: settings.globalEnforcement ?? "hard",
707
- warnAtPct: defaultWarnAtPct,
708
- estCost: est.cost,
709
- estTokens: est.tokens,
710
- spendToday: await globalSpend.count(ctx, globalDayKey(today)),
711
- reservedSpendToday: 0, // sharded holder: no cross-request reservation
712
- spendThisMonth: 0, // global tracks daily + lifetime only
713
- reservedSpendMonth: 0,
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: Math.max(0, b.totalSpendNanos + deltaNanos),
1364
+ totalSpendNanos: b.totalSpendNanos + debit,
1307
1365
  totalTokens: Math.max(0, b.totalTokens + dt),
1308
1366
  dayStamp: today,
1309
1367
  monthStamp: month,
1310
- spendTodayNanos: Math.max(0, (dSame ? b.spendTodayNanos : 0) + deltaNanos),
1368
+ spendTodayNanos: (dSame ? b.spendTodayNanos : 0) + debit,
1311
1369
  tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
1312
- spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
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
- await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
1323
- await addUsage(ctx, dimension, value, "month", month, deltaNanos, dt, 0);
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("hard"), v.literal("soft")),
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 ?? "hard",
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("hard"), v.literal("soft"), v.null())
1569
+ v.union(v.literal("approximate"), v.literal("soft"), v.null())
1485
1570
  ),
1486
1571
  },
1487
1572
  returns: v.null(),
@@ -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 totals (from finished requests)
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("hard"), v.literal("soft"))
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
  });