@convex-dev/ai-budget 0.0.2-alpha.0 → 0.0.2-alpha.10

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.
@@ -6,67 +6,107 @@ export const vMessage = v.object({
6
6
  content: v.string(),
7
7
  });
8
8
 
9
+ // One attribution tag: a (dimension, value) pair, e.g. {dimension:"user",
10
+ // value:"alice"} or {dimension:"customer", value:"acme"}. `user` and `action`
11
+ // are built-in dimensions; apps can add any others (team, project, env, …).
12
+ export const vTag = v.object({ dimension: v.string(), value: v.string() });
13
+
9
14
  export default defineSchema({
10
- users: defineTable({
11
- userId: v.string(), // app-provided stable key (your user id)
15
+ // A budget holder, keyed by (dimension, value). Unifies what used to be the
16
+ // `users` and `actions` tables those are just the "user" and "action"
17
+ // dimensions now. Any tag a request carries can have its own budget here.
18
+ buckets: defineTable({
19
+ dimension: v.string(),
20
+ value: v.string(),
12
21
  // limits (all optional — unlimited by default)
13
- requestsPerMinute: v.optional(v.number()),
22
+ requestsPerMinute: v.optional(v.number()), // rolling limit for this bucket
23
+ maxConcurrent: v.optional(v.number()), // max in-flight (pending) requests
14
24
  dailySpendLimitNanos: v.optional(v.number()),
25
+ monthlySpendLimitNanos: v.optional(v.number()),
15
26
  lifetimeSpendLimitNanos: v.optional(v.number()),
16
27
  dailyTokenLimit: v.optional(v.number()),
28
+ monthlyTokenLimit: v.optional(v.number()),
17
29
  lifetimeTokenLimit: v.optional(v.number()),
18
- blocked: v.optional(v.boolean()),
30
+ blocked: v.optional(v.boolean()), // hard block (was `blocked`/`disabled`)
31
+ // Fire an approaching-limit alert once usage crosses this fraction of a cap
32
+ // (e.g. 0.8 = warn at 80%). Falls back to the deployment default.
33
+ warnAtPct: v.optional(v.number()),
19
34
  // "hard" (default): exceeding a budget blocks. "soft": warn but allow.
20
35
  enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
21
- // one-time bumps ("approve another $X") added on top of the cap. Daily bump
22
- // is scoped to bumpDayStamp (resets with the day); lifetime bump is permanent.
36
+ // one-time bumps ("approve another $X"). Daily/monthly bumps are scoped to
37
+ // their stamp (reset with the window); lifetime bump is permanent.
23
38
  dailyBumpNanos: v.optional(v.number()),
39
+ monthlyBumpNanos: v.optional(v.number()),
24
40
  lifetimeBumpNanos: v.optional(v.number()),
25
41
  bumpDayStamp: v.optional(v.string()),
42
+ bumpMonthStamp: v.optional(v.string()),
26
43
  // settled totals (from finished requests)
27
44
  totalSpendNanos: v.number(),
28
45
  totalRequests: v.number(),
29
46
  totalTokens: v.number(),
30
47
  // daily window
31
- dayStamp: v.string(), // e.g. "2026-08-27" (UTC)
48
+ dayStamp: v.string(),
32
49
  spendTodayNanos: v.number(),
33
50
  tokensToday: v.optional(v.number()),
51
+ // monthly window (UTC calendar month, e.g. "2026-09")
52
+ monthStamp: v.optional(v.string()),
53
+ spendThisMonthNanos: v.optional(v.number()),
54
+ tokensThisMonth: v.optional(v.number()),
34
55
  // in-flight reservations (pessimistic holds; released on settle/expiry)
35
56
  reservedTodayNanos: v.optional(v.number()),
57
+ reservedMonthNanos: v.optional(v.number()),
36
58
  reservedTotalNanos: v.optional(v.number()),
37
59
  reservedTodayTokens: v.optional(v.number()),
60
+ reservedMonthTokens: v.optional(v.number()),
38
61
  reservedTotalTokens: v.optional(v.number()),
39
62
  pendingCount: v.optional(v.number()),
40
- }).index("userId", ["userId"]),
63
+ })
64
+ .index("dim_value", ["dimension", "value"])
65
+ .index("dimension", ["dimension"]),
41
66
 
42
- // per-action-name budgets and running totals (e.g. "chat", "summarize")
43
- actions: defineTable({
44
- name: v.string(),
45
- dailySpendLimitNanos: v.optional(v.number()),
46
- lifetimeSpendLimitNanos: v.optional(v.number()),
47
- dailyTokenLimit: v.optional(v.number()),
48
- lifetimeTokenLimit: v.optional(v.number()),
49
- disabled: v.optional(v.boolean()),
50
- enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
51
- dailyBumpNanos: v.optional(v.number()),
52
- lifetimeBumpNanos: v.optional(v.number()),
53
- bumpDayStamp: v.optional(v.string()),
54
- totalSpendNanos: v.number(),
55
- totalRequests: v.number(),
56
- totalTokens: v.number(),
57
- dayStamp: v.string(),
58
- spendTodayNanos: v.number(),
59
- tokensToday: v.optional(v.number()),
60
- reservedTodayNanos: v.optional(v.number()),
61
- reservedTotalNanos: v.optional(v.number()),
62
- reservedTodayTokens: v.optional(v.number()),
63
- reservedTotalTokens: v.optional(v.number()),
64
- pendingCount: v.optional(v.number()),
65
- }).index("name", ["name"]),
67
+ // Durable per-(bucket, period) spend history. Written from settled requests
68
+ // and manual adjustments; NEVER swept by request retention, so spend charts
69
+ // and "what did we spend last month" survive long after the raw request rows
70
+ // are purged. period is "day" ("2026-09-04") or "month" ("2026-09").
71
+ usage: defineTable({
72
+ dimension: v.string(),
73
+ value: v.string(),
74
+ period: v.union(v.literal("day"), v.literal("month")),
75
+ stamp: v.string(),
76
+ spendNanos: v.number(),
77
+ tokens: v.number(),
78
+ requests: v.number(),
79
+ })
80
+ .index("bucket_period_stamp", ["dimension", "value", "period", "stamp"])
81
+ .index("period_stamp", ["period", "stamp"]),
82
+
83
+ // Reverse index for filtering the request log by an arbitrary tag dimension
84
+ // (user/action are already indexed on `requests`). One row per extra tag per
85
+ // request; cleaned up with the request on retention/deletion.
86
+ requestTags: defineTable({
87
+ dimension: v.string(),
88
+ value: v.string(),
89
+ requestId: v.id("requests"),
90
+ })
91
+ .index("dim_value", ["dimension", "value"])
92
+ .index("requestId", ["requestId"]),
93
+
94
+ // Manual credits/debits applied to a bucket (comp a user, correct an
95
+ // overcharge). Negative delta = credit/refund, positive = extra charge.
96
+ adjustments: defineTable({
97
+ dimension: v.string(),
98
+ value: v.string(),
99
+ deltaNanos: v.number(),
100
+ tokens: v.optional(v.number()),
101
+ reason: v.optional(v.string()),
102
+ }).index("dim_value", ["dimension", "value"]),
66
103
 
67
104
  requests: defineTable({
105
+ // `user` and `action` stay first-class + indexed (the hot-path filters and
106
+ // rate limiting); the full attribution incl. extra tags lives in `tags`.
68
107
  userId: v.string(),
69
108
  actionName: v.optional(v.string()),
109
+ tags: v.optional(v.array(vTag)),
70
110
  model: v.string(),
71
111
  // pessimistic holds placed at start; reconciled to actual on settle
72
112
  estimatedNanos: v.optional(v.number()),
@@ -107,6 +147,9 @@ export default defineSchema({
107
147
  model: v.string(),
108
148
  inputNanosPerMTok: v.number(),
109
149
  outputNanosPerMTok: v.number(),
150
+ // price for cached (prompt-cache-read) input tokens. Providers bill these
151
+ // at a fraction of the input rate; if unset, a default discount is applied.
152
+ cachedNanosPerMTok: v.optional(v.number()),
110
153
  }).index("model", ["model"]),
111
154
 
112
155
  // singleton component config (key === "singleton")
@@ -122,12 +165,12 @@ export default defineSchema({
122
165
  )
123
166
  ),
124
167
  models: v.optional(v.array(v.string())),
125
- // Deployment-wide ("global") spend cap across ALL users and actions. The
126
- // running totals live in a sharded counter (high write throughput); only
127
- // the limit config lives here. The cap is enforced approximately — the
128
- // sharded total is read without a reservation, so under heavy concurrency
129
- // it can overshoot by a bounded amount. Right for a global killswitch;
130
- // per-user/per-action caps stay exact via reserve/settle.
168
+ // Deployment-wide ("global") spend cap across ALL requests. Running totals
169
+ // live in a sharded counter (high write throughput) since every request
170
+ // touches it; only the limit config lives here. Enforced approximately —
171
+ // the sharded total is read without a reservation, so under heavy
172
+ // concurrency it can overshoot by a bounded amount. Right for a global
173
+ // killswitch; per-bucket concurrent admission is atomic via reserve/settle.
131
174
  globalDailySpendLimitNanos: v.optional(v.number()),
132
175
  globalLifetimeSpendLimitNanos: v.optional(v.number()),
133
176
  globalEnforcement: v.optional(
@@ -138,5 +181,8 @@ export default defineSchema({
138
181
  globalBumpDayStamp: v.optional(v.string()),
139
182
  // request-row retention window in ms (default 1h); 0 disables sweeping.
140
183
  retentionMs: v.optional(v.number()),
184
+ // default approaching-limit alert threshold (fraction of a cap) for buckets
185
+ // that don't set their own warnAtPct. 0/unset disables threshold alerts.
186
+ defaultWarnAtPct: v.optional(v.number()),
141
187
  }).index("key", ["key"]),
142
188
  });