@convex-dev/ai-budget 0.0.2-alpha.17 → 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/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.17",
10
+ "version": "0.0.2-alpha.19",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -172,13 +172,20 @@ function extractUsage(usage: any): {
172
172
  cachedTokens: number;
173
173
  } {
174
174
  return {
175
- // Cover AI SDK camelCase (v5/v7) AND raw OpenAI-compatible snake_case, so a
176
- // `meter` caller passing a raw provider `usage` object still gets counts.
175
+ // Cover AI SDK camelCase (v5/v7), raw OpenAI-compatible snake_case, AND raw
176
+ // Anthropic (`input_tokens`/`output_tokens`), so a `meter` caller passing any
177
+ // provider's raw `usage` object still gets counts.
177
178
  promptTokens: toTokenCount(
178
- usage?.inputTokens ?? usage?.promptTokens ?? usage?.prompt_tokens
179
+ usage?.inputTokens ??
180
+ usage?.promptTokens ??
181
+ usage?.prompt_tokens ??
182
+ usage?.input_tokens
179
183
  ),
180
184
  completionTokens: toTokenCount(
181
- usage?.outputTokens ?? usage?.completionTokens ?? usage?.completion_tokens
185
+ usage?.outputTokens ??
186
+ usage?.completionTokens ??
187
+ usage?.completion_tokens ??
188
+ usage?.output_tokens
182
189
  ),
183
190
  // cached prompt tokens. The Convex gateway reports these at
184
191
  // `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
@@ -188,7 +195,8 @@ function extractUsage(usage: any): {
188
195
  usage?.cachedInputTokens ??
189
196
  usage?.promptTokensDetails?.cachedTokens ??
190
197
  usage?.prompt_tokens_details?.cached_tokens ??
191
- usage?.cached_tokens
198
+ usage?.cached_tokens ??
199
+ usage?.cache_read_input_tokens
192
200
  ),
193
201
  };
194
202
  }
@@ -214,6 +222,12 @@ function extractGatewayCostNanos(result: any): number | undefined {
214
222
 
215
223
  const NANOS_PER_DOLLAR = 1e9;
216
224
 
225
+ // Cap stored prompt/response content so one row can't approach Convex's 1 MiB
226
+ // document limit (which would fail the call) or bloat the reconciler's scans.
227
+ const MAX_STORED_CONTENT = 32 * 1024;
228
+ const capContent = (s: string) =>
229
+ s.length > MAX_STORED_CONTENT ? s.slice(0, MAX_STORED_CONTENT) + "…[truncated]" : s;
230
+
217
231
  // Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
218
232
  function simplifyPrompt(prompt: any): Message[] {
219
233
  if (!Array.isArray(prompt)) return [];
@@ -223,14 +237,20 @@ function simplifyPrompt(prompt: any): Message[] {
223
237
  content = m.content;
224
238
  } else if (Array.isArray(m.content)) {
225
239
  content = m.content
226
- .map((part: any) =>
227
- part?.type === "text" ? part.text : JSON.stringify(part)
228
- )
240
+ .map((part: any) => {
241
+ if (part?.type === "text") return part.text ?? "";
242
+ // NEVER inline a base64 image/file part — a data URL or Uint8Array here
243
+ // becomes megabytes, pushing the stored row past the 1 MiB doc limit
244
+ // (failing the call) and turning a 200 KB image into a ~50k-token
245
+ // estimate. Store a compact placeholder.
246
+ const bytes = part?.data?.length ?? part?.image?.length ?? part?.data?.byteLength;
247
+ return `[${part?.type ?? "part"}${typeof bytes === "number" ? ` ${bytes}b` : ""}]`;
248
+ })
229
249
  .join("");
230
250
  } else {
231
251
  content = JSON.stringify(m.content);
232
252
  }
233
- return { role: String(m.role), content };
253
+ return { role: String(m.role), content: capContent(content) };
234
254
  });
235
255
  }
236
256
 
@@ -781,7 +801,7 @@ export class AIBudget {
781
801
  }));
782
802
  const tapped = result.stream.pipeThrough(
783
803
  new TransformStream({
784
- async transform(chunk: any, controller) {
804
+ async transform(chunk: any, controller: any) {
785
805
  if (chunk?.type === "text-delta") {
786
806
  text += chunk.delta ?? chunk.textDelta ?? "";
787
807
  }
@@ -797,7 +817,22 @@ export class AIBudget {
797
817
  async flush() {
798
818
  await settle();
799
819
  },
800
- })
820
+ // A cancelled/aborted stream (client disconnect, AbortSignal, or
821
+ // breaking out of `for await`) does NOT run `flush`. Without this
822
+ // the request would sit pending until the 30-min reservation
823
+ // expiry and be recorded as $0 though the provider charged for
824
+ // what streamed. Settle here with the text so far, estimating the
825
+ // completion tokens when the provider gave no usage.
826
+ // `cancel` is a newer Streams-spec transformer hook not yet in
827
+ // the TS DOM lib types; cast the literal so it compiles (runtimes
828
+ // without it simply won't call it, and the reconciler backstops).
829
+ async cancel(reason: any) {
830
+ if (usage === undefined && text) {
831
+ usage = { outputTokens: Math.ceil(text.length / 4) };
832
+ }
833
+ await settle(`cancelled: ${String(reason)}`);
834
+ },
835
+ } as any)
801
836
  );
802
837
  return { ...result, stream: tapped };
803
838
  } catch (e) {
@@ -956,13 +991,17 @@ export class AIBudget {
956
991
  return {
957
992
  /** Limits + spend today/total. */
958
993
  status: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.getGlobalStatus, {}),
959
- /** 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
+ */
960
999
  setLimits: (
961
1000
  ctx: RunMutationCtx,
962
1001
  args: {
963
- dailySpendLimitNanos?: number;
964
- lifetimeSpendLimitNanos?: number;
965
- enforcement?: "hard" | "soft";
1002
+ dailySpendLimitNanos?: number | null;
1003
+ lifetimeSpendLimitNanos?: number | null;
1004
+ enforcement?: "approximate" | "soft" | null;
966
1005
  }
967
1006
  ) => ctx.runMutation(c.lib.setGlobalLimits, args),
968
1007
  bump: (
@@ -975,6 +1014,17 @@ export class AIBudget {
975
1014
  /** Request-row retention window in ms (default 1h; 0 disables). */
976
1015
  setRetention: (ctx: RunMutationCtx, args: { retentionMs: number }) =>
977
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),
978
1028
  };
979
1029
  }
980
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,13 +181,20 @@ 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
- dailySpendLimitNanos?: number;
189
- enforcement?: "hard" | "soft";
190
- lifetimeSpendLimitNanos?: number;
195
+ dailySpendLimitNanos?: number | null;
196
+ enforcement?: "approximate" | "soft" | null;
197
+ lifetimeSpendLimitNanos?: number | null;
191
198
  },
192
199
  null,
193
200
  Name
@@ -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", () => {
@@ -554,8 +569,9 @@ describe("F-04 fail-closed pricing", () => {
554
569
  expect(r.allowed).toBe(true);
555
570
  await settle(t, r.requestId, 1_000_000, 1_000_000);
556
571
  const u = await userOf(t, "u");
557
- // conservative = max over table = {$3 in, $15 out}/Mtok => $18 = 18e9 nano.
558
- expect(u.totalSpendNanos).toBe(18_000_000_000);
572
+ // Conservative fallback is a frontier ceiling of {$20 in, $100 out}/Mtok, so
573
+ // 1M in + 1M out => $120 = 120e9 nano (over-count is the safe direction).
574
+ expect(u.totalSpendNanos).toBe(120_000_000_000);
559
575
  const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
560
576
  expect(req.unpricedModel).toBe(true);
561
577
  });
@@ -598,7 +614,7 @@ describe("accounting lifecycle regressions", () => {
598
614
  await setUserLimits(t, "u", { dailySpendLimitNanos: 1000 });
599
615
  const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
600
616
  vi.advanceTimersByTime(2 * 60 * 60_000);
601
- await t.mutation(internal.lib.reconcile, {});
617
+ await t.mutation(internal.lib.expirePhase, {});
602
618
  expect((await userOf(t, "u")).reservedTotalNanos).toBe(0);
603
619
  await start(t, { userId: "u", estimatedCostNanos: 100 });
604
620
  await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 75 });
@@ -625,7 +641,7 @@ describe("accounting lifecycle regressions", () => {
625
641
  });
626
642
  const short = await start(t, { userId: "short" });
627
643
  vi.advanceTimersByTime(31 * 60_000);
628
- const result = await t.mutation(internal.lib.reconcile, {});
644
+ const result = await t.mutation(internal.lib.expirePhase, {});
629
645
  expect(result.expired).toBe(1);
630
646
  expect((await t.run(ctx => ctx.db.get(short.requestId))).reservationExpired).toBe(true);
631
647
  } finally { vi.useRealTimers(); }
@@ -638,6 +654,11 @@ describe("accounting lifecycle regressions", () => {
638
654
  await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
639
655
  expect((await t.query(api.lib.getGlobalStatus, {})).spentTotalNanos).toBe(100);
640
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, {});
641
662
  expect((await start(t, { userId: "u", estimatedCostNanos: 1 })).allowed).toBe(false);
642
663
  });
643
664
 
@@ -664,9 +685,11 @@ test("legacy pending rows acquire deadlines without starving newer expired work"
664
685
  });
665
686
  const job = await start(t, { userId: "new" });
666
687
  vi.advanceTimersByTime(31 * 60_000);
667
- expect((await t.mutation(internal.lib.reconcile, {})).expired).toBe(1);
688
+ expect((await t.mutation(internal.lib.expirePhase, {})).expired).toBe(1);
668
689
  expect((await t.run(ctx => ctx.db.get(job.requestId))).reservationExpired).toBe(true);
669
- await t.mutation(internal.lib.reconcile, {});
690
+ // The phase self-reschedules to backfill the remaining legacy rows in
691
+ // batches; drain those scheduled continuations.
692
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
670
693
  expect(await t.run(ctx => ctx.db.query("requests").withIndex("status_expires", q =>
671
694
  q.eq("status", "pending").eq("expiresAt", undefined)).take(1))).toHaveLength(0);
672
695
  } finally { vi.useRealTimers(); }
@@ -685,7 +708,7 @@ test("retention progresses past unresolved jobs", async () => {
685
708
  await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 0 });
686
709
  await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
687
710
  vi.advanceTimersByTime(2 * 60 * 60_000);
688
- expect((await t.mutation(internal.lib.reconcile, {})).purged).toBe(1);
711
+ expect((await t.mutation(internal.lib.retentionPhase, {})).purged).toBe(1);
689
712
  expect(await t.run(ctx => ctx.db.get(job.requestId))).toBeNull();
690
713
  } finally { vi.useRealTimers(); }
691
714
  });
@@ -791,3 +814,80 @@ describe("v1 hardening", () => {
791
814
  ).rejects.toThrow(/dailySpendLimitNanos/);
792
815
  });
793
816
  });
817
+
818
+ describe("v1 hardening (round 2)", () => {
819
+ test("a non-finite estimatedCostNanos is rejected before it can poison a bucket", async () => {
820
+ const t = initTest();
821
+ for (const estimatedCostNanos of [Infinity, NaN, -1]) {
822
+ await expect(
823
+ start(t, { userId: "u", estimatedCostNanos })
824
+ ).rejects.toThrow(/estimatedCostNanos/);
825
+ }
826
+ });
827
+
828
+ test("a settle with no cost signal falls back to the reserved estimate, not $0", async () => {
829
+ const t = initTest();
830
+ // $2 reserved up front (e.g. a video job).
831
+ const r = await start(t, { userId: "u", estimatedCostNanos: 2_000_000_000 });
832
+ // Settle with an unpriced server tool and no tokens/authoritative cost.
833
+ const out = await t.mutation(api.lib.finishRequest, {
834
+ requestId: r.requestId,
835
+ serverToolUses: { video_seconds: 8 }, // no configured price
836
+ });
837
+ expect(out.costNanos).toBe(2_000_000_000); // NOT 0
838
+ vi.useFakeTimers();
839
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
840
+ vi.useRealTimers();
841
+ const u = await userOf(t, "u");
842
+ expect(u.totalSpendNanos).toBe(2_000_000_000);
843
+ });
844
+ });
845
+
846
+ describe("v1 hardening (round 3): reconcile phases", () => {
847
+ test("expired tombstones are deleted after the late-settle horizon", async () => {
848
+ vi.useFakeTimers();
849
+ try {
850
+ const t = initTest();
851
+ const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
852
+ vi.advanceTimersByTime(31 * 60_000);
853
+ // Expire it into a billing tombstone (reservationExpired + settled).
854
+ await t.mutation(internal.lib.expirePhase, {});
855
+ // Within the 7-day late-settle horizon: retention keeps the tombstone.
856
+ await t.mutation(internal.lib.retentionPhase, {});
857
+ expect(await t.run((ctx) => ctx.db.get(job.requestId))).not.toBeNull();
858
+ // Past the horizon: the tombstone is deleted so they can't accumulate.
859
+ vi.advanceTimersByTime(8 * 24 * 60 * 60_000);
860
+ await t.mutation(internal.lib.retentionPhase, {});
861
+ expect(await t.run((ctx) => ctx.db.get(job.requestId))).toBeNull();
862
+ } finally {
863
+ vi.useRealTimers();
864
+ }
865
+ });
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
+ });