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

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.
@@ -0,0 +1,142 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ export const vMessage = v.object({
5
+ role: v.string(), // "system" | "user" | "assistant" | "tool"
6
+ content: v.string(),
7
+ });
8
+
9
+ export default defineSchema({
10
+ users: defineTable({
11
+ userId: v.string(), // app-provided stable key (your user id)
12
+ // limits (all optional — unlimited by default)
13
+ requestsPerMinute: v.optional(v.number()),
14
+ dailySpendLimitNanos: v.optional(v.number()),
15
+ lifetimeSpendLimitNanos: v.optional(v.number()),
16
+ dailyTokenLimit: v.optional(v.number()),
17
+ lifetimeTokenLimit: v.optional(v.number()),
18
+ blocked: v.optional(v.boolean()),
19
+ // "hard" (default): exceeding a budget blocks. "soft": warn but allow.
20
+ 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.
23
+ dailyBumpNanos: v.optional(v.number()),
24
+ lifetimeBumpNanos: v.optional(v.number()),
25
+ bumpDayStamp: v.optional(v.string()),
26
+ // settled totals (from finished requests)
27
+ totalSpendNanos: v.number(),
28
+ totalRequests: v.number(),
29
+ totalTokens: v.number(),
30
+ // daily window
31
+ dayStamp: v.string(), // e.g. "2026-08-27" (UTC)
32
+ spendTodayNanos: v.number(),
33
+ tokensToday: v.optional(v.number()),
34
+ // in-flight reservations (pessimistic holds; released on settle/expiry)
35
+ reservedTodayNanos: v.optional(v.number()),
36
+ reservedTotalNanos: v.optional(v.number()),
37
+ reservedTodayTokens: v.optional(v.number()),
38
+ reservedTotalTokens: v.optional(v.number()),
39
+ pendingCount: v.optional(v.number()),
40
+ }).index("userId", ["userId"]),
41
+
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"]),
66
+
67
+ requests: defineTable({
68
+ userId: v.string(),
69
+ actionName: v.optional(v.string()),
70
+ model: v.string(),
71
+ // pessimistic holds placed at start; reconciled to actual on settle
72
+ estimatedNanos: v.optional(v.number()),
73
+ estimatedTokens: v.optional(v.number()),
74
+ // true when the model had no known/override price and was charged the
75
+ // conservative fallback — a signal to add a real price via setPrice.
76
+ unpricedModel: v.optional(v.boolean()),
77
+ // true when a soft budget was exceeded (allowed with a warning).
78
+ overBudget: v.optional(v.boolean()),
79
+ // false once finished and awaiting fold into totals; true once folded.
80
+ // absent while pending or blocked (so the reconciler ignores those).
81
+ settled: v.optional(v.boolean()),
82
+ messages: v.array(vMessage),
83
+ status: v.union(
84
+ v.literal("pending"),
85
+ v.literal("success"),
86
+ v.literal("error"),
87
+ v.literal("blocked")
88
+ ),
89
+ responseText: v.optional(v.string()),
90
+ error: v.optional(v.string()),
91
+ promptTokens: v.optional(v.number()),
92
+ completionTokens: v.optional(v.number()),
93
+ // subset of promptTokens served from the provider's prompt cache (cheaper).
94
+ cachedTokens: v.optional(v.number()),
95
+ costNanos: v.optional(v.number()),
96
+ latencyMs: v.optional(v.number()),
97
+ rerunOf: v.optional(v.id("requests")),
98
+ })
99
+ .index("userId", ["userId"])
100
+ .index("status", ["status"])
101
+ .index("rerunOf", ["rerunOf"])
102
+ .index("actionName", ["actionName"])
103
+ .index("settled", ["settled"]),
104
+
105
+ // per-model price overrides (nanodollars per million tokens)
106
+ prices: defineTable({
107
+ model: v.string(),
108
+ inputNanosPerMTok: v.number(),
109
+ outputNanosPerMTok: v.number(),
110
+ }).index("model", ["model"]),
111
+
112
+ // singleton component config (key === "singleton")
113
+ settings: defineTable({
114
+ key: v.string(),
115
+ // "open": any model allowed. "allowlist": only listed models.
116
+ // "denylist": any model except the listed ones.
117
+ modelMode: v.optional(
118
+ v.union(
119
+ v.literal("open"),
120
+ v.literal("allowlist"),
121
+ v.literal("denylist")
122
+ )
123
+ ),
124
+ 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.
131
+ globalDailySpendLimitNanos: v.optional(v.number()),
132
+ globalLifetimeSpendLimitNanos: v.optional(v.number()),
133
+ globalEnforcement: v.optional(
134
+ v.union(v.literal("hard"), v.literal("soft"))
135
+ ),
136
+ globalDailyBumpNanos: v.optional(v.number()),
137
+ globalLifetimeBumpNanos: v.optional(v.number()),
138
+ globalBumpDayStamp: v.optional(v.string()),
139
+ // request-row retention window in ms (default 1h); 0 disables sweeping.
140
+ retentionMs: v.optional(v.number()),
141
+ }).index("key", ["key"]),
142
+ });