@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.
- package/README.md +335 -183
- package/dist/client/dashboard.d.ts +1 -0
- package/dist/client/dashboard.js +223 -0
- package/dist/client/index.d.ts +494 -35
- package/dist/client/index.js +276 -33
- package/dist/component/_generated/component.d.ts +56 -24
- package/dist/component/lib.d.ts +132 -40
- package/dist/component/lib.js +590 -305
- package/dist/component/schema.d.ts +103 -53
- package/dist/component/schema.js +81 -38
- package/package.json +5 -5
- package/src/client/dashboard.ts +223 -0
- package/src/client/index.ts +407 -73
- package/src/component/_generated/component.ts +79 -29
- package/src/component/lib.test.ts +208 -15
- package/src/component/lib.ts +688 -333
- package/src/component/schema.ts +84 -38
package/dist/client/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { httpActionGeneric, } from "convex/server";
|
|
1
2
|
import { ConvexError } from "convex/values";
|
|
2
3
|
import { generateText, wrapLanguageModel } from "ai";
|
|
3
4
|
import { convexGateway } from "@convex-dev/ai-sdk-provider";
|
|
5
|
+
import { DASHBOARD_HTML } from "./dashboard";
|
|
4
6
|
// The calling Convex action's name (e.g. "ai:sendMessage"), unless overridden.
|
|
5
7
|
async function resolveActionName(ctx, explicit) {
|
|
6
8
|
if (explicit !== undefined)
|
|
@@ -39,14 +41,35 @@ function extractUsage(usage) {
|
|
|
39
41
|
return {
|
|
40
42
|
promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens),
|
|
41
43
|
completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens),
|
|
42
|
-
// cached prompt tokens
|
|
43
|
-
// `
|
|
44
|
-
|
|
44
|
+
// cached prompt tokens. The Convex gateway reports these at
|
|
45
|
+
// `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
|
|
46
|
+
// (`cachedInputTokens`) and raw OpenAI-compatible shapes.
|
|
47
|
+
cachedTokens: toTokenCount(usage?.inputTokenDetails?.cacheReadTokens ??
|
|
48
|
+
usage?.cachedInputTokens ??
|
|
45
49
|
usage?.promptTokensDetails?.cachedTokens ??
|
|
46
50
|
usage?.prompt_tokens_details?.cached_tokens ??
|
|
47
51
|
usage?.cached_tokens),
|
|
48
52
|
};
|
|
49
53
|
}
|
|
54
|
+
// The AI Gateway reports the authoritative dollar cost of each request.
|
|
55
|
+
// @convex-dev/ai-sdk-provider surfaces it at
|
|
56
|
+
// `providerMetadata.convexGateway.cost` (USD); convert to nanodollars and pass
|
|
57
|
+
// it through as authoritative (finishRequest prefers it over the token-based
|
|
58
|
+
// estimate). No-op on older provider versions that don't surface it.
|
|
59
|
+
function extractGatewayCostNanos(result) {
|
|
60
|
+
const meta = result?.providerMetadata?.convexGateway;
|
|
61
|
+
const costUsd = meta?.cost;
|
|
62
|
+
if (typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd >= 0) {
|
|
63
|
+
return Math.round(costUsd * NANOS_PER_DOLLAR);
|
|
64
|
+
}
|
|
65
|
+
// Back-compat: honor an explicitly nano-denominated field if ever present.
|
|
66
|
+
const costNanos = meta?.costNanos ?? result?.usage?.costNanos;
|
|
67
|
+
if (typeof costNanos === "number" && Number.isFinite(costNanos) && costNanos >= 0) {
|
|
68
|
+
return Math.round(costNanos);
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const NANOS_PER_DOLLAR = 1e9;
|
|
50
73
|
// Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
|
|
51
74
|
function simplifyPrompt(prompt) {
|
|
52
75
|
if (!Array.isArray(prompt))
|
|
@@ -79,23 +102,56 @@ function extractText(result) {
|
|
|
79
102
|
return "";
|
|
80
103
|
}
|
|
81
104
|
// ---------- client ----------
|
|
105
|
+
// Length-independent-branch string compare, so the dashboard token check
|
|
106
|
+
// doesn't leak the token via response timing. (Length itself is not secret.)
|
|
107
|
+
function timingSafeEqual(a, b) {
|
|
108
|
+
if (a.length !== b.length)
|
|
109
|
+
return false;
|
|
110
|
+
let r = 0;
|
|
111
|
+
for (let i = 0; i < a.length; i++)
|
|
112
|
+
r |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
113
|
+
return r === 0;
|
|
114
|
+
}
|
|
82
115
|
export class AIBudget {
|
|
83
116
|
component;
|
|
84
117
|
defaultModel;
|
|
85
118
|
onSoftLimit;
|
|
119
|
+
onThreshold;
|
|
120
|
+
onLimitReached;
|
|
86
121
|
constructor(component, options) {
|
|
87
122
|
this.component = component;
|
|
88
123
|
this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
|
|
89
124
|
this.onSoftLimit = options?.onSoftLimit;
|
|
125
|
+
this.onThreshold = options?.onThreshold;
|
|
126
|
+
this.onLimitReached = options?.onLimitReached;
|
|
127
|
+
}
|
|
128
|
+
// Fire the soft-limit + threshold callbacks from a startRequest result.
|
|
129
|
+
async fireBudgetEvents(base, warnings, notices) {
|
|
130
|
+
if (warnings.length > 0 && this.onSoftLimit) {
|
|
131
|
+
try {
|
|
132
|
+
await this.onSoftLimit({ ...base, messages: warnings, warnings });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* never let a callback error break a request */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (notices.length > 0 && this.onThreshold) {
|
|
139
|
+
try {
|
|
140
|
+
await this.onThreshold({ ...base, messages: notices });
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* swallow */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
90
146
|
}
|
|
91
|
-
async
|
|
92
|
-
if (
|
|
147
|
+
async fireLimitReached(info) {
|
|
148
|
+
if (!this.onLimitReached)
|
|
93
149
|
return;
|
|
94
150
|
try {
|
|
95
|
-
await this.
|
|
151
|
+
await this.onLimitReached(info);
|
|
96
152
|
}
|
|
97
153
|
catch {
|
|
98
|
-
|
|
154
|
+
/* swallow */
|
|
99
155
|
}
|
|
100
156
|
}
|
|
101
157
|
/**
|
|
@@ -110,11 +166,20 @@ export class AIBudget {
|
|
|
110
166
|
const started = await ctx.runMutation(this.component.lib.startRequest, {
|
|
111
167
|
userId,
|
|
112
168
|
actionName,
|
|
169
|
+
tags: args.tags,
|
|
113
170
|
model,
|
|
114
171
|
messages,
|
|
115
172
|
rerunOf: args.rerunOf,
|
|
116
173
|
});
|
|
117
174
|
if (!started.allowed) {
|
|
175
|
+
await this.fireLimitReached({
|
|
176
|
+
userId,
|
|
177
|
+
action: actionName,
|
|
178
|
+
tags: args.tags,
|
|
179
|
+
messages: [started.reason],
|
|
180
|
+
code: started.code,
|
|
181
|
+
reason: started.reason,
|
|
182
|
+
});
|
|
118
183
|
throw new ConvexError({
|
|
119
184
|
kind: "AIBudgetLimit",
|
|
120
185
|
code: started.code,
|
|
@@ -123,7 +188,8 @@ export class AIBudget {
|
|
|
123
188
|
}
|
|
124
189
|
const requestId = started.requestId;
|
|
125
190
|
const warnings = started.warnings;
|
|
126
|
-
|
|
191
|
+
const notices = started.notices;
|
|
192
|
+
await this.fireBudgetEvents({ userId, action: actionName, tags: args.tags, requestId }, warnings, notices);
|
|
127
193
|
const start = Date.now();
|
|
128
194
|
try {
|
|
129
195
|
// The full chain (incl. system) is stored on the request for audit/replay,
|
|
@@ -143,9 +209,10 @@ export class AIBudget {
|
|
|
143
209
|
requestId,
|
|
144
210
|
responseText: result.text,
|
|
145
211
|
...usage,
|
|
212
|
+
costNanos: extractGatewayCostNanos(result),
|
|
146
213
|
latencyMs: Date.now() - start,
|
|
147
214
|
});
|
|
148
|
-
return { text: result.text, requestId, costNanos, warnings, ...usage };
|
|
215
|
+
return { text: result.text, requestId, costNanos, warnings, notices, ...usage };
|
|
149
216
|
}
|
|
150
217
|
catch (e) {
|
|
151
218
|
await ctx.runMutation(this.component.lib.finishRequest, {
|
|
@@ -165,29 +232,33 @@ export class AIBudget {
|
|
|
165
232
|
languageModel(ctx, opts = {}) {
|
|
166
233
|
const modelId = opts.model ?? this.defaultModel;
|
|
167
234
|
const component = this.component;
|
|
168
|
-
const
|
|
235
|
+
const fireBudgetEvents = this.fireBudgetEvents.bind(this);
|
|
236
|
+
const fireLimitReached = this.fireLimitReached.bind(this);
|
|
169
237
|
const begin = async (params) => {
|
|
170
238
|
const userId = await resolveUserId(ctx, opts.userId);
|
|
171
239
|
const actionName = await resolveActionName(ctx, opts.action);
|
|
240
|
+
const base = { userId, action: actionName, tags: opts.tags };
|
|
172
241
|
const started = await ctx.runMutation(component.lib.startRequest, {
|
|
173
242
|
userId,
|
|
174
243
|
actionName,
|
|
244
|
+
tags: opts.tags,
|
|
175
245
|
model: modelId,
|
|
176
246
|
messages: simplifyPrompt(params.prompt),
|
|
177
247
|
});
|
|
178
248
|
if (!started.allowed) {
|
|
249
|
+
await fireLimitReached({
|
|
250
|
+
...base,
|
|
251
|
+
messages: [started.reason],
|
|
252
|
+
code: started.code,
|
|
253
|
+
reason: started.reason,
|
|
254
|
+
});
|
|
179
255
|
throw new ConvexError({
|
|
180
256
|
kind: "AIBudgetLimit",
|
|
181
257
|
code: started.code,
|
|
182
258
|
reason: started.reason,
|
|
183
259
|
});
|
|
184
260
|
}
|
|
185
|
-
await
|
|
186
|
-
userId,
|
|
187
|
-
action: actionName,
|
|
188
|
-
requestId: started.requestId,
|
|
189
|
-
warnings: started.warnings,
|
|
190
|
-
});
|
|
261
|
+
await fireBudgetEvents({ ...base, requestId: started.requestId }, started.warnings, started.notices);
|
|
191
262
|
return started.requestId;
|
|
192
263
|
};
|
|
193
264
|
const finish = async (requestId, fields) => ctx.runMutation(component.lib.finishRequest, { requestId, ...fields });
|
|
@@ -202,6 +273,7 @@ export class AIBudget {
|
|
|
202
273
|
await finish(requestId, {
|
|
203
274
|
responseText: extractText(result),
|
|
204
275
|
...extractUsage(result.usage),
|
|
276
|
+
costNanos: extractGatewayCostNanos(result),
|
|
205
277
|
latencyMs: Date.now() - start,
|
|
206
278
|
});
|
|
207
279
|
return result;
|
|
@@ -219,6 +291,7 @@ export class AIBudget {
|
|
|
219
291
|
const start = Date.now();
|
|
220
292
|
let text = "";
|
|
221
293
|
let usage = undefined;
|
|
294
|
+
let providerMetadata = undefined;
|
|
222
295
|
try {
|
|
223
296
|
const result = await doStream();
|
|
224
297
|
// finishRequest is idempotent (terminal-guarded server-side), so
|
|
@@ -235,6 +308,7 @@ export class AIBudget {
|
|
|
235
308
|
responseText: text,
|
|
236
309
|
error,
|
|
237
310
|
...extractUsage(usage),
|
|
311
|
+
costNanos: extractGatewayCostNanos({ providerMetadata }),
|
|
238
312
|
latencyMs: Date.now() - start,
|
|
239
313
|
});
|
|
240
314
|
};
|
|
@@ -243,8 +317,10 @@ export class AIBudget {
|
|
|
243
317
|
if (chunk?.type === "text-delta") {
|
|
244
318
|
text += chunk.delta ?? chunk.textDelta ?? "";
|
|
245
319
|
}
|
|
246
|
-
if (chunk?.type === "finish")
|
|
320
|
+
if (chunk?.type === "finish") {
|
|
247
321
|
usage = chunk.usage;
|
|
322
|
+
providerMetadata = chunk.providerMetadata ?? providerMetadata;
|
|
323
|
+
}
|
|
248
324
|
if (chunk?.type === "error")
|
|
249
325
|
void settle(String(chunk.error));
|
|
250
326
|
controller.enqueue(chunk);
|
|
@@ -278,6 +354,7 @@ export class AIBudget {
|
|
|
278
354
|
messages: args.messages ?? original.messages,
|
|
279
355
|
rerunOf: args.requestId,
|
|
280
356
|
action: original.actionName,
|
|
357
|
+
tags: original.tags,
|
|
281
358
|
});
|
|
282
359
|
}
|
|
283
360
|
// ---------- namespaced admin API ----------
|
|
@@ -285,35 +362,88 @@ export class AIBudget {
|
|
|
285
362
|
get requests() {
|
|
286
363
|
const c = this.component;
|
|
287
364
|
return {
|
|
365
|
+
/** Filter by userId, or by any {dimension, value} (incl. custom tags). */
|
|
288
366
|
list: (ctx, args = {}) => ctx.runQuery(c.lib.listRequests, args),
|
|
367
|
+
/** One request, including its stored prompt and response. */
|
|
368
|
+
get: (ctx, args) => ctx.runQuery(c.lib.getRequest, { requestId: args.requestId }),
|
|
289
369
|
/** Ancestors up to the original, plus direct re-runs. */
|
|
290
370
|
lineage: (ctx, args) => ctx.runQuery(c.lib.lineage, { requestId: args.requestId }),
|
|
291
371
|
/** Replay a stored request, optionally with edited messages/model. */
|
|
292
372
|
rerun: (ctx, args) => this.rerunImpl(ctx, args),
|
|
293
373
|
};
|
|
294
374
|
}
|
|
295
|
-
/**
|
|
296
|
-
|
|
375
|
+
/**
|
|
376
|
+
* Budgets and controls for an arbitrary attribution dimension — the
|
|
377
|
+
* generalization of `users`/`actions`. Give it any dimension name (team,
|
|
378
|
+
* project, tenant, customer, env, feature, …) and set caps per value:
|
|
379
|
+
*
|
|
380
|
+
* ai.tag("customer").setLimits(ctx, { value: "acme", monthlySpendLimitNanos });
|
|
381
|
+
* ai.tag("customer").history(ctx, { value: "acme", period: "day" });
|
|
382
|
+
*
|
|
383
|
+
* Attribute a call to it by passing `tags` to `chat`/`languageModel`.
|
|
384
|
+
*/
|
|
385
|
+
tag(dimension) {
|
|
386
|
+
return this.dimensionApi(dimension, (a) => a.value);
|
|
387
|
+
}
|
|
388
|
+
// Shared implementation behind tag()/users/actions. `key` maps the namespace's
|
|
389
|
+
// id field (value/userId/name) to the bucket value.
|
|
390
|
+
dimensionApi(dimension, key) {
|
|
297
391
|
const c = this.component;
|
|
298
392
|
return {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
/** One
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
393
|
+
/** All buckets in this dimension. */
|
|
394
|
+
list: (ctx) => ctx.runQuery(c.lib.listBuckets, { dimension }),
|
|
395
|
+
/** One bucket's limits + spend (null if it has none yet). */
|
|
396
|
+
get: (ctx, args) => ctx.runQuery(c.lib.getBucket, { dimension, value: key(args) }),
|
|
397
|
+
setLimits: (ctx, args) => {
|
|
398
|
+
const { value, userId, name, ...limits } = args;
|
|
399
|
+
return ctx.runMutation(c.lib.setBucketLimits, {
|
|
400
|
+
dimension,
|
|
401
|
+
value: key(args),
|
|
402
|
+
...limits,
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
/** One-time "approve another $X" bump (daily/monthly reset with the window). */
|
|
406
|
+
bump: (ctx, args) => ctx.runMutation(c.lib.bumpBucket, {
|
|
407
|
+
dimension,
|
|
408
|
+
value: key(args),
|
|
409
|
+
dailyNanos: args.dailyNanos,
|
|
410
|
+
monthlyNanos: args.monthlyNanos,
|
|
411
|
+
lifetimeNanos: args.lifetimeNanos,
|
|
412
|
+
}),
|
|
413
|
+
/** Manually credit (negative) or debit (positive) this bucket. */
|
|
414
|
+
adjust: (ctx, args) => ctx.runMutation(c.lib.adjustBucket, {
|
|
415
|
+
dimension,
|
|
416
|
+
value: key(args),
|
|
417
|
+
deltaNanos: args.deltaNanos,
|
|
418
|
+
tokens: args.tokens,
|
|
419
|
+
reason: args.reason,
|
|
420
|
+
}),
|
|
421
|
+
/** Durable spend history for this bucket (per day or per month). */
|
|
422
|
+
history: (ctx, args) => ctx.runQuery(c.lib.usageHistory, {
|
|
423
|
+
dimension,
|
|
424
|
+
value: key(args),
|
|
425
|
+
period: args.period ?? "day",
|
|
426
|
+
limit: args.limit,
|
|
427
|
+
}),
|
|
428
|
+
/** Manual-adjustment audit log for this bucket. */
|
|
429
|
+
adjustments: (ctx, args) => ctx.runQuery(c.lib.listAdjustments, {
|
|
430
|
+
dimension,
|
|
431
|
+
value: key(args),
|
|
432
|
+
limit: args.limit,
|
|
433
|
+
}),
|
|
434
|
+
/** Delete the bucket (for "user", also its request rows). */
|
|
435
|
+
delete: (ctx, args) => ctx.runMutation(c.lib.deleteBucket, { dimension, value: key(args) }),
|
|
305
436
|
};
|
|
306
437
|
}
|
|
307
|
-
/** Per-
|
|
438
|
+
/** Per-user budgets and controls — sugar over the "user" dimension. */
|
|
439
|
+
get users() {
|
|
440
|
+
return this.dimensionApi("user", (a) => a.userId);
|
|
441
|
+
}
|
|
442
|
+
/** Per-action (per-feature) budgets — sugar over the "action" dimension. */
|
|
308
443
|
get actions() {
|
|
309
|
-
|
|
310
|
-
return {
|
|
311
|
-
list: (ctx) => ctx.runQuery(c.lib.listActions, {}),
|
|
312
|
-
setLimits: (ctx, args) => ctx.runMutation(c.lib.setActionLimits, args),
|
|
313
|
-
bump: (ctx, args) => ctx.runMutation(c.lib.bumpAction, args),
|
|
314
|
-
};
|
|
444
|
+
return this.dimensionApi("action", (a) => a.name);
|
|
315
445
|
}
|
|
316
|
-
/** The deployment-wide budget and retention config. */
|
|
446
|
+
/** The deployment-wide budget, alerts, and retention config. */
|
|
317
447
|
get global() {
|
|
318
448
|
const c = this.component;
|
|
319
449
|
return {
|
|
@@ -322,6 +452,8 @@ export class AIBudget {
|
|
|
322
452
|
/** A killswitch spend cap across all users/actions (enforced approximately). */
|
|
323
453
|
setLimits: (ctx, args) => ctx.runMutation(c.lib.setGlobalLimits, args),
|
|
324
454
|
bump: (ctx, args) => ctx.runMutation(c.lib.bumpGlobal, args),
|
|
455
|
+
/** Default approaching-limit alert threshold (fraction of a cap, e.g. 0.8). */
|
|
456
|
+
setAlertDefaults: (ctx, args) => ctx.runMutation(c.lib.setAlertDefaults, args),
|
|
325
457
|
/** Request-row retention window in ms (default 1h; 0 disables). */
|
|
326
458
|
setRetention: (ctx, args) => ctx.runMutation(c.lib.setRetention, args),
|
|
327
459
|
};
|
|
@@ -343,6 +475,117 @@ export class AIBudget {
|
|
|
343
475
|
set: (ctx, args) => ctx.runMutation(c.lib.setPrice, args),
|
|
344
476
|
};
|
|
345
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* Mount the built-in admin dashboard on your app's HTTP router with one call.
|
|
480
|
+
* Serves a self-contained HTML dashboard (buckets, requests, usage history,
|
|
481
|
+
* settings) plus a small JSON API, all backed by the component — no extra
|
|
482
|
+
* queries to write.
|
|
483
|
+
*
|
|
484
|
+
* // convex/http.ts
|
|
485
|
+
* import { httpRouter } from "convex/server";
|
|
486
|
+
* const http = httpRouter();
|
|
487
|
+
* ai.registerRoutes(http, { authorize: async (ctx) =>
|
|
488
|
+
* (await ctx.auth.getUserIdentity())?.role === "admin" });
|
|
489
|
+
* export default http;
|
|
490
|
+
*
|
|
491
|
+
* It then lives at `https://<deployment>.convex.site/aibudget`.
|
|
492
|
+
*
|
|
493
|
+
* SECURITY: the endpoint is public on the internet. You MUST gate it — either
|
|
494
|
+
* pass `authorize` (recommended: check the caller is a deployment admin) or
|
|
495
|
+
* set the `AI_BUDGET_DASHBOARD_TOKEN` env var (a bearer token / `?token=`).
|
|
496
|
+
* With neither, every route returns 401.
|
|
497
|
+
*/
|
|
498
|
+
registerRoutes(http, opts = {}) {
|
|
499
|
+
const prefix = (opts.path ?? "/aibudget").replace(/\/+$/, "");
|
|
500
|
+
const c = this.component.lib;
|
|
501
|
+
const authorize = opts.authorize;
|
|
502
|
+
const guard = async (ctx, request) => {
|
|
503
|
+
if (authorize)
|
|
504
|
+
return { ok: await authorize(ctx, request), token: "" };
|
|
505
|
+
const token = globalThis.process?.env?.AI_BUDGET_DASHBOARD_TOKEN;
|
|
506
|
+
if (!token)
|
|
507
|
+
return { ok: false, token: "" };
|
|
508
|
+
const url = new URL(request.url);
|
|
509
|
+
const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
510
|
+
// `?token=` is accepted only for the initial page navigation (a browser
|
|
511
|
+
// GET can't set headers); the page strips it from the URL on load and the
|
|
512
|
+
// JSON API is called with the bearer header. Compared in constant time.
|
|
513
|
+
const provided = bearer || url.searchParams.get("token") || "";
|
|
514
|
+
return { ok: timingSafeEqual(provided, token), token };
|
|
515
|
+
};
|
|
516
|
+
const json = (data, status = 200) => new Response(JSON.stringify(data ?? null), {
|
|
517
|
+
status,
|
|
518
|
+
headers: { "content-type": "application/json" },
|
|
519
|
+
});
|
|
520
|
+
const handle = async (ctx, request) => {
|
|
521
|
+
const url = new URL(request.url);
|
|
522
|
+
const sub = url.pathname.slice(prefix.length) || "/";
|
|
523
|
+
const { ok, token } = await guard(ctx, request);
|
|
524
|
+
if (!ok) {
|
|
525
|
+
return new Response("Unauthorized. Pass `authorize` to registerRoutes or set AI_BUDGET_DASHBOARD_TOKEN.", { status: 401 });
|
|
526
|
+
}
|
|
527
|
+
if (sub.startsWith("/api/")) {
|
|
528
|
+
const route = request.method + " " + sub.slice(4); // strip "/api"
|
|
529
|
+
const p = {};
|
|
530
|
+
url.searchParams.forEach((v, k) => {
|
|
531
|
+
p[k] = v;
|
|
532
|
+
});
|
|
533
|
+
const body = request.method === "POST"
|
|
534
|
+
? await request.json().catch(() => ({}))
|
|
535
|
+
: {};
|
|
536
|
+
switch (route) {
|
|
537
|
+
case "GET /buckets":
|
|
538
|
+
return json(await ctx.runQuery(c.listBuckets, { dimension: p.dimension || undefined }));
|
|
539
|
+
case "GET /requests":
|
|
540
|
+
return json(await ctx.runQuery(c.listRequests, {
|
|
541
|
+
userId: p.userId || undefined,
|
|
542
|
+
dimension: p.dimension || undefined,
|
|
543
|
+
value: p.value || undefined,
|
|
544
|
+
limit: 100,
|
|
545
|
+
}));
|
|
546
|
+
case "GET /usage":
|
|
547
|
+
return json(await ctx.runQuery(c.usageHistory, {
|
|
548
|
+
dimension: p.dimension,
|
|
549
|
+
value: p.value,
|
|
550
|
+
period: p.period === "month" ? "month" : "day",
|
|
551
|
+
}));
|
|
552
|
+
case "GET /global":
|
|
553
|
+
return json(await ctx.runQuery(c.getGlobalStatus, {}));
|
|
554
|
+
case "GET /prices":
|
|
555
|
+
return json(await ctx.runQuery(c.listPrices, {}));
|
|
556
|
+
case "POST /setLimits":
|
|
557
|
+
return json(await ctx.runMutation(c.setBucketLimits, body));
|
|
558
|
+
case "POST /bump":
|
|
559
|
+
return json(await ctx.runMutation(c.bumpBucket, body));
|
|
560
|
+
case "POST /adjust":
|
|
561
|
+
return json(await ctx.runMutation(c.adjustBucket, body));
|
|
562
|
+
case "POST /delete":
|
|
563
|
+
return json(await ctx.runMutation(c.deleteBucket, body));
|
|
564
|
+
case "POST /global/setLimits":
|
|
565
|
+
return json(await ctx.runMutation(c.setGlobalLimits, body));
|
|
566
|
+
case "POST /global/setAlertDefaults":
|
|
567
|
+
return json(await ctx.runMutation(c.setAlertDefaults, body));
|
|
568
|
+
case "POST /global/setRetention":
|
|
569
|
+
return json(await ctx.runMutation(c.setRetention, body));
|
|
570
|
+
case "POST /setPrice":
|
|
571
|
+
return json(await ctx.runMutation(c.setPrice, body));
|
|
572
|
+
default:
|
|
573
|
+
return json({ error: "not found" }, 404);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// Inject as JSON literals (function replacers so `$` in the value isn't
|
|
577
|
+
// treated as a replacement pattern). This keeps a token/prefix containing
|
|
578
|
+
// quotes, backslashes, or `</script>` from breaking out of the JS string.
|
|
579
|
+
const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () => JSON.stringify(`${prefix}/api`)).replace(/__TOKEN__/g, () => JSON.stringify(token));
|
|
580
|
+
return new Response(html, {
|
|
581
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
582
|
+
});
|
|
583
|
+
};
|
|
584
|
+
const handler = httpActionGeneric(handle);
|
|
585
|
+
http.route({ path: prefix, method: "GET", handler });
|
|
586
|
+
http.route({ pathPrefix: `${prefix}/`, method: "GET", handler });
|
|
587
|
+
http.route({ pathPrefix: `${prefix}/`, method: "POST", handler });
|
|
588
|
+
}
|
|
346
589
|
}
|
|
347
590
|
/** @deprecated Renamed to `AIBudget`. */
|
|
348
591
|
export const WorryFreeAI = AIBudget;
|
|
@@ -20,22 +20,28 @@ import type { FunctionReference } from "convex/server";
|
|
|
20
20
|
*/
|
|
21
21
|
export type ComponentApi<Name extends string | undefined = string | undefined> = {
|
|
22
22
|
lib: {
|
|
23
|
-
|
|
23
|
+
adjustBucket: FunctionReference<"mutation", "internal", {
|
|
24
|
+
deltaNanos: number;
|
|
25
|
+
dimension: string;
|
|
26
|
+
reason?: string;
|
|
27
|
+
tokens?: number;
|
|
28
|
+
value: string;
|
|
29
|
+
}, null, Name>;
|
|
30
|
+
bumpBucket: FunctionReference<"mutation", "internal", {
|
|
24
31
|
dailyNanos?: number;
|
|
32
|
+
dimension: string;
|
|
25
33
|
lifetimeNanos?: number;
|
|
26
|
-
|
|
34
|
+
monthlyNanos?: number;
|
|
35
|
+
value: string;
|
|
27
36
|
}, null, Name>;
|
|
28
37
|
bumpGlobal: FunctionReference<"mutation", "internal", {
|
|
29
38
|
dailyNanos?: number;
|
|
30
39
|
lifetimeNanos?: number;
|
|
40
|
+
monthlyNanos?: number;
|
|
31
41
|
}, null, Name>;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
userId: string;
|
|
36
|
-
}, null, Name>;
|
|
37
|
-
deleteUser: FunctionReference<"mutation", "internal", {
|
|
38
|
-
userId: string;
|
|
42
|
+
deleteBucket: FunctionReference<"mutation", "internal", {
|
|
43
|
+
dimension: string;
|
|
44
|
+
value: string;
|
|
39
45
|
}, {
|
|
40
46
|
deletedThisBatch: number;
|
|
41
47
|
done: boolean;
|
|
@@ -43,6 +49,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
43
49
|
finishRequest: FunctionReference<"mutation", "internal", {
|
|
44
50
|
cachedTokens?: number;
|
|
45
51
|
completionTokens?: number;
|
|
52
|
+
costNanos?: number;
|
|
46
53
|
error?: string;
|
|
47
54
|
latencyMs?: number;
|
|
48
55
|
promptTokens?: number;
|
|
@@ -51,10 +58,16 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
51
58
|
}, {
|
|
52
59
|
costNanos: number;
|
|
53
60
|
}, Name>;
|
|
61
|
+
getBucket: FunctionReference<"query", "internal", {
|
|
62
|
+
dimension: string;
|
|
63
|
+
value: string;
|
|
64
|
+
}, any, Name>;
|
|
54
65
|
getGlobalStatus: FunctionReference<"query", "internal", {}, {
|
|
55
66
|
dailySpendLimitNanos: number | null;
|
|
67
|
+
defaultWarnAtPct: number | null;
|
|
56
68
|
enforcement: "hard" | "soft";
|
|
57
69
|
lifetimeSpendLimitNanos: number | null;
|
|
70
|
+
retentionMs: number | null;
|
|
58
71
|
spentTodayNanos: number;
|
|
59
72
|
spentTotalNanos: number;
|
|
60
73
|
}, Name>;
|
|
@@ -68,42 +81,50 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
68
81
|
lineage: FunctionReference<"query", "internal", {
|
|
69
82
|
requestId: string;
|
|
70
83
|
}, any, Name>;
|
|
71
|
-
|
|
84
|
+
listAdjustments: FunctionReference<"query", "internal", {
|
|
85
|
+
dimension: string;
|
|
86
|
+
limit?: number;
|
|
87
|
+
value: string;
|
|
88
|
+
}, any, Name>;
|
|
89
|
+
listBuckets: FunctionReference<"query", "internal", {
|
|
90
|
+
dimension?: string;
|
|
91
|
+
}, any, Name>;
|
|
72
92
|
listPrices: FunctionReference<"query", "internal", {}, any, Name>;
|
|
73
93
|
listRequests: FunctionReference<"query", "internal", {
|
|
94
|
+
dimension?: string;
|
|
74
95
|
limit?: number;
|
|
75
96
|
userId?: string;
|
|
97
|
+
value?: string;
|
|
76
98
|
}, any, Name>;
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
setAlertDefaults: FunctionReference<"mutation", "internal", {
|
|
100
|
+
warnAtPct?: number;
|
|
101
|
+
}, null, Name>;
|
|
102
|
+
setBucketLimits: FunctionReference<"mutation", "internal", {
|
|
103
|
+
blocked?: boolean;
|
|
79
104
|
dailySpendLimitNanos?: number;
|
|
80
105
|
dailyTokenLimit?: number;
|
|
81
|
-
|
|
106
|
+
dimension: string;
|
|
82
107
|
enforcement?: "hard" | "soft";
|
|
83
108
|
lifetimeSpendLimitNanos?: number;
|
|
84
109
|
lifetimeTokenLimit?: number;
|
|
85
|
-
|
|
110
|
+
maxConcurrent?: number;
|
|
111
|
+
monthlySpendLimitNanos?: number;
|
|
112
|
+
monthlyTokenLimit?: number;
|
|
113
|
+
requestsPerMinute?: number;
|
|
114
|
+
value: string;
|
|
115
|
+
warnAtPct?: number;
|
|
86
116
|
}, null, Name>;
|
|
87
117
|
setGlobalLimits: FunctionReference<"mutation", "internal", {
|
|
88
118
|
dailySpendLimitNanos?: number;
|
|
89
119
|
enforcement?: "hard" | "soft";
|
|
90
120
|
lifetimeSpendLimitNanos?: number;
|
|
91
121
|
}, null, Name>;
|
|
92
|
-
setLimits: FunctionReference<"mutation", "internal", {
|
|
93
|
-
blocked?: boolean;
|
|
94
|
-
dailySpendLimitNanos?: number;
|
|
95
|
-
dailyTokenLimit?: number;
|
|
96
|
-
enforcement?: "hard" | "soft";
|
|
97
|
-
lifetimeSpendLimitNanos?: number;
|
|
98
|
-
lifetimeTokenLimit?: number;
|
|
99
|
-
requestsPerMinute?: number;
|
|
100
|
-
userId: string;
|
|
101
|
-
}, null, Name>;
|
|
102
122
|
setModelPolicy: FunctionReference<"mutation", "internal", {
|
|
103
123
|
mode: "open" | "allowlist" | "denylist";
|
|
104
124
|
models: Array<string>;
|
|
105
125
|
}, null, Name>;
|
|
106
126
|
setPrice: FunctionReference<"mutation", "internal", {
|
|
127
|
+
cachedNanosPerMTok?: number;
|
|
107
128
|
inputNanosPerMTok: number;
|
|
108
129
|
model: string;
|
|
109
130
|
outputNanosPerMTok: number;
|
|
@@ -119,9 +140,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
119
140
|
}>;
|
|
120
141
|
model: string;
|
|
121
142
|
rerunOf?: string;
|
|
143
|
+
tags?: Array<{
|
|
144
|
+
dimension: string;
|
|
145
|
+
value: string;
|
|
146
|
+
}>;
|
|
122
147
|
userId: string;
|
|
123
148
|
}, {
|
|
124
149
|
allowed: true;
|
|
150
|
+
notices: Array<string>;
|
|
125
151
|
requestId: string;
|
|
126
152
|
warnings: Array<string>;
|
|
127
153
|
} | {
|
|
@@ -129,5 +155,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
129
155
|
code: string;
|
|
130
156
|
reason: string;
|
|
131
157
|
}, Name>;
|
|
158
|
+
usageHistory: FunctionReference<"query", "internal", {
|
|
159
|
+
dimension: string;
|
|
160
|
+
limit?: number;
|
|
161
|
+
period: "day" | "month";
|
|
162
|
+
value: string;
|
|
163
|
+
}, any, Name>;
|
|
132
164
|
};
|
|
133
165
|
};
|