@convex-dev/ai-budget 0.0.2-alpha.15 → 0.0.2-alpha.17
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 +49 -4
- package/dist/client/dashboard.js +12 -2
- package/dist/client/index.d.ts +53 -0
- package/dist/client/index.js +157 -59
- package/dist/component/lib.d.ts +3 -3
- package/dist/component/lib.js +101 -28
- package/package.json +1 -1
- package/src/client/dashboard.ts +12 -2
- package/src/client/index.ts +204 -54
- package/src/component/lib.test.ts +95 -11
- package/src/component/lib.ts +104 -30
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ full audit log you can replay later.
|
|
|
23
23
|
- [Setup](#setup)
|
|
24
24
|
- [Quickstart](#quickstart)
|
|
25
25
|
- [Concepts](#concepts) — dimensions, nanodollars, reserve→settle
|
|
26
|
-
- [Generating text](#generating-text) — `chat`, `languageModel`, replay
|
|
26
|
+
- [Generating text](#generating-text) — `chat`, `languageModel`, `meter`, `decisions`, replay
|
|
27
27
|
- [Budgets & limits](#budgets--limits) — set caps, bumps, credits, alerts
|
|
28
28
|
- [Monitoring](#monitoring) — totals, spend history, the request log
|
|
29
29
|
- [Deployment-wide controls](#deployment-wide-controls) — global cap, model policy, pricing, retention
|
|
@@ -251,6 +251,35 @@ await ai.meter(ctx,
|
|
|
251
251
|
});
|
|
252
252
|
```
|
|
253
253
|
|
|
254
|
+
### `ai.decisions` — structured decisions (Jev)
|
|
255
|
+
|
|
256
|
+
Budget the gateway's Decisions endpoint ([Jev](https://docs.typesafe.ai)) — typed
|
|
257
|
+
`choice` / `score` / `boolean` questions evaluated against a `state` — with the
|
|
258
|
+
same limits, audit log, cost tracking, and tags as `ai.chat`:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
const { answers, costNanos } = await ai.decisions(ctx, {
|
|
262
|
+
state: { ticket: "Customer cannot sign in" },
|
|
263
|
+
questions: {
|
|
264
|
+
priority: {
|
|
265
|
+
type: "choice",
|
|
266
|
+
instructions: "Choose the response priority",
|
|
267
|
+
criteria: { urgent: "Respond now", normal: "Respond today" },
|
|
268
|
+
},
|
|
269
|
+
needsReview: { type: "boolean", instructions: "Does a human need to review this?" },
|
|
270
|
+
},
|
|
271
|
+
tags: [{ dimension: "team", value: "support" }],
|
|
272
|
+
});
|
|
273
|
+
answers.priority.choice; // "urgent" | "normal"
|
|
274
|
+
answers.needsReview.probability;
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Sugar over `ai.meter`, so cost is the gateway's authoritative amount. Model
|
|
278
|
+
defaults to `defaultEvalModel` (`"typesafe/jev-1.13"`). Requires
|
|
279
|
+
`@convex-dev/ai-sdk-provider` ≥ 0.2.1 and an `ai` version with
|
|
280
|
+
`experimental_evaluate` — both imported lazily, so callers who don't use
|
|
281
|
+
`decisions` are unaffected.
|
|
282
|
+
|
|
254
283
|
### `ai.begin` / `ai.settle` — long async jobs (video)
|
|
255
284
|
|
|
256
285
|
A video job is submit → wait minutes → poll/webhook → done, spanning multiple
|
|
@@ -361,6 +390,13 @@ reserve-then-settle admission check runs per bucket. Uncapped buckets never seri
|
|
|
361
390
|
adding tags you don't cap is free at admission; their totals still accrue for
|
|
362
391
|
reporting.
|
|
363
392
|
|
|
393
|
+
> **Choosing what to cap.** A cap makes its bucket's admissions atomic by
|
|
394
|
+
> reserving on that bucket's single row, so capping a dimension that *all* traffic
|
|
395
|
+
> shares (one `env`/`action` every request carries) serializes those admissions on
|
|
396
|
+
> one document under load. Prefer capping **naturally-sharded** dimensions — per
|
|
397
|
+
> `user`, per `customer` — and use the [global cap](#global-cap) for a
|
|
398
|
+
> deployment-wide ceiling. Uncapped high-traffic tags are always free.
|
|
399
|
+
|
|
364
400
|
### Tags — budgeting by any dimension
|
|
365
401
|
|
|
366
402
|
```ts
|
|
@@ -459,9 +495,12 @@ ai.global.status(ctx) // { limits, spentTodayNanos, spentTotalNanos, … }
|
|
|
459
495
|
ai.global.bump(ctx, { dailyNanos?, lifetimeNanos? })
|
|
460
496
|
```
|
|
461
497
|
|
|
462
|
-
A killswitch across everything. Backed by a sharded counter for
|
|
463
|
-
it's enforced **approximately**
|
|
464
|
-
|
|
498
|
+
A best-effort killswitch across everything. Backed by a sharded counter for
|
|
499
|
+
throughput, so it's enforced **approximately** — under burst it can overshoot the
|
|
500
|
+
cap by a bounded amount, and it excludes in-flight (not-yet-settled) spend. It is
|
|
501
|
+
**not** a to-the-dollar ceiling: for an exact limit use a per-bucket cap (those
|
|
502
|
+
reserve atomically); reach for the global cap when you want a deployment-wide
|
|
503
|
+
"stop everything" switch.
|
|
465
504
|
|
|
466
505
|
### Model policy
|
|
467
506
|
|
|
@@ -523,6 +562,12 @@ ai.global.setRetention(ctx, { retentionMs }) // default 1h; 0 disables
|
|
|
523
562
|
Full request rows (prompts + responses) are swept after the window to bound the
|
|
524
563
|
audit table. **Spend history survives** — it lives in separate durable rollups.
|
|
525
564
|
|
|
565
|
+
> **Prompt/response content is stored** on the request row until the window sweeps
|
|
566
|
+
> it (default 1h), so it's visible in the request log and to admin reads until
|
|
567
|
+
> then. If your prompts carry PII you don't want retained, shorten `retentionMs`
|
|
568
|
+
> (or set it low enough that content lives only as long as you need replay). Spend
|
|
569
|
+
> rollups never contain content, so trimming retention doesn't cost you history.
|
|
570
|
+
|
|
526
571
|
---
|
|
527
572
|
|
|
528
573
|
## Admin dashboard
|
package/dist/client/dashboard.js
CHANGED
|
@@ -97,8 +97,18 @@ async function renderBuckets() {
|
|
|
97
97
|
const dims = ["user", "action"];
|
|
98
98
|
const state = { dimension: window.__dim ?? "" };
|
|
99
99
|
const rows = await get("/buckets", state.dimension ? { dimension: state.dimension } : {});
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
// Within ONE dimension each request bills exactly one bucket, so summing is a
|
|
101
|
+
// true total. Across dimensions a request bills several buckets (user + action
|
|
102
|
+
// + tags), so summing multi-counts — use the deployment-wide spend instead.
|
|
103
|
+
let totalText;
|
|
104
|
+
if (state.dimension) {
|
|
105
|
+
const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
|
|
106
|
+
totalText = rows.length + " buckets · " + usd(grand) + " total (" + state.dimension + ")";
|
|
107
|
+
} else {
|
|
108
|
+
const g = await get("/global", {});
|
|
109
|
+
totalText = rows.length + " buckets · " + usd(g.spentTotalNanos ?? 0) + " spent (deployment)";
|
|
110
|
+
}
|
|
111
|
+
document.getElementById("total").textContent = totalText;
|
|
102
112
|
const dimSet = [...new Set(rows.map((b) => b.dimension).concat(dims))];
|
|
103
113
|
const sel = el("select", { value: state.dimension, style: "width:140px",
|
|
104
114
|
onchange: (e) => { window.__dim = e.target.value; render(); } },
|
package/dist/client/index.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type SoftLimitInfo = BudgetEventInfo & {
|
|
|
39
39
|
};
|
|
40
40
|
export type AIBudgetOptions = {
|
|
41
41
|
defaultModel?: string;
|
|
42
|
+
/** Default model for `decisions()` (the Decisions/"Jev" endpoint). */
|
|
43
|
+
defaultEvalModel?: string;
|
|
42
44
|
/**
|
|
43
45
|
* A *soft* limit was exceeded (request still allowed). Lets you surface budget
|
|
44
46
|
* warnings even on the languageModel/Agent path where they can't be returned.
|
|
@@ -82,6 +84,16 @@ export type ChatResult = {
|
|
|
82
84
|
/** Approaching-cap notices (empty unless a warnAtPct threshold was crossed). */
|
|
83
85
|
notices: string[];
|
|
84
86
|
};
|
|
87
|
+
/** The tracked result of a `decisions()` call: budgeting metadata plus the
|
|
88
|
+
* structured answers from the Decisions ("Jev") endpoint. */
|
|
89
|
+
export type DecisionResult = Omit<ChatResult, "text"> & {
|
|
90
|
+
/** Structured answers keyed by your question names (shape depends on each
|
|
91
|
+
* question type: `choice`, `score`, or `boolean`). */
|
|
92
|
+
answers: Record<string, any>;
|
|
93
|
+
/** The raw gateway response, including provider-specific fields (e.g.
|
|
94
|
+
* `confidence`) under `response.body`. */
|
|
95
|
+
response?: any;
|
|
96
|
+
};
|
|
85
97
|
/** Limits/controls settable on any budget bucket (user, action, or tag). */
|
|
86
98
|
export type BucketLimits = {
|
|
87
99
|
/** Token-bucket refill per minute and burst capacity; 0 blocks all requests. */
|
|
@@ -107,6 +119,7 @@ export type BumpArgs = {
|
|
|
107
119
|
export declare class AIBudget {
|
|
108
120
|
component: AIBudgetApi;
|
|
109
121
|
defaultModel: string;
|
|
122
|
+
defaultEvalModel: string;
|
|
110
123
|
private onSoftLimit?;
|
|
111
124
|
private onThreshold?;
|
|
112
125
|
private onLimitReached?;
|
|
@@ -208,6 +221,46 @@ export declare class AIBudget {
|
|
|
208
221
|
/** Extra attribution dimensions to bill/limit (team, customer, env, …). */
|
|
209
222
|
tags?: Tag[];
|
|
210
223
|
}): Promise<ChatResult>;
|
|
224
|
+
/**
|
|
225
|
+
* Budget a structured decision through the AI Gateway's Decisions ("Jev")
|
|
226
|
+
* endpoint — sugar over `meter`. Evaluates typed `questions` (choice / score /
|
|
227
|
+
* boolean) about the `state` you provide, with the same reserve→settle
|
|
228
|
+
* limits, audit log, cost tracking, and per-tag attribution as `chat`. Call
|
|
229
|
+
* from an action. `userId` defaults to the authenticated caller.
|
|
230
|
+
*
|
|
231
|
+
* Requires `@convex-dev/ai-sdk-provider` >= 0.2.1 and an `ai` version that
|
|
232
|
+
* exposes `experimental_evaluate` (AI SDK 7's evaluation interface); both are
|
|
233
|
+
* imported lazily, so consumers who never call `decisions()` are unaffected.
|
|
234
|
+
*
|
|
235
|
+
* const { answers } = await ai.decisions(ctx, {
|
|
236
|
+
* state: { ticket: "Customer cannot sign in" },
|
|
237
|
+
* questions: {
|
|
238
|
+
* priority: { type: "choice", instructions: "...", criteria: { urgent: "...", normal: "..." } },
|
|
239
|
+
* needsReview: { type: "boolean", instructions: "..." },
|
|
240
|
+
* },
|
|
241
|
+
* });
|
|
242
|
+
* answers.priority.choice; // "urgent" | "normal"
|
|
243
|
+
*/
|
|
244
|
+
decisions(ctx: RunMutationCtx, args: {
|
|
245
|
+
/** The evaluation model. Defaults to `defaultEvalModel` ("typesafe/jev-1.13"). */
|
|
246
|
+
model?: string;
|
|
247
|
+
/** Context the questions are evaluated against (a string or an object). */
|
|
248
|
+
state: unknown;
|
|
249
|
+
/** Typed questions (choice / score / boolean) keyed by name. */
|
|
250
|
+
questions: Record<string, unknown>;
|
|
251
|
+
/** Whom to bill. Defaults to the authenticated user (ctx.auth). */
|
|
252
|
+
userId?: string;
|
|
253
|
+
/** Attribute spend to this action name. Defaults to the calling action. */
|
|
254
|
+
action?: string;
|
|
255
|
+
/** Extra attribution dimensions to bill/limit (team, customer, env, …). */
|
|
256
|
+
tags?: Tag[];
|
|
257
|
+
/** Reserve this exact amount (nanodollars) up front — the decision cost
|
|
258
|
+
* isn't known before the call, so a hard cap is only exact with this. */
|
|
259
|
+
estimatedCostNanos?: number;
|
|
260
|
+
rerunOf?: string;
|
|
261
|
+
/** Cancel the underlying request. */
|
|
262
|
+
abortSignal?: AbortSignal;
|
|
263
|
+
}): Promise<DecisionResult>;
|
|
211
264
|
/**
|
|
212
265
|
* An AI SDK LanguageModel that enforces limits and records usage/cost for
|
|
213
266
|
* `userId` on every call. Drop it into `generateText`, `streamText`, or the
|
package/dist/client/index.js
CHANGED
|
@@ -39,8 +39,10 @@ function toTokenCount(x) {
|
|
|
39
39
|
}
|
|
40
40
|
function extractUsage(usage) {
|
|
41
41
|
return {
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Cover AI SDK camelCase (v5/v7) AND raw OpenAI-compatible snake_case, so a
|
|
43
|
+
// `meter` caller passing a raw provider `usage` object still gets counts.
|
|
44
|
+
promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens ?? usage?.prompt_tokens),
|
|
45
|
+
completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens ?? usage?.completion_tokens),
|
|
44
46
|
// cached prompt tokens. The Convex gateway reports these at
|
|
45
47
|
// `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
|
|
46
48
|
// (`cachedInputTokens`) and raw OpenAI-compatible shapes.
|
|
@@ -115,12 +117,14 @@ function timingSafeEqual(a, b) {
|
|
|
115
117
|
export class AIBudget {
|
|
116
118
|
component;
|
|
117
119
|
defaultModel;
|
|
120
|
+
defaultEvalModel;
|
|
118
121
|
onSoftLimit;
|
|
119
122
|
onThreshold;
|
|
120
123
|
onLimitReached;
|
|
121
124
|
constructor(component, options) {
|
|
122
125
|
this.component = component;
|
|
123
126
|
this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
|
|
127
|
+
this.defaultEvalModel = options?.defaultEvalModel ?? "typesafe/jev-1.13";
|
|
124
128
|
this.onSoftLimit = options?.onSoftLimit;
|
|
125
129
|
this.onThreshold = options?.onThreshold;
|
|
126
130
|
this.onLimitReached = options?.onLimitReached;
|
|
@@ -238,35 +242,42 @@ export class AIBudget {
|
|
|
238
242
|
}
|
|
239
243
|
const { requestId, warnings, notices } = started;
|
|
240
244
|
const start = Date.now();
|
|
245
|
+
// Run the provider call. ONLY a failure of the call itself settles as an
|
|
246
|
+
// error (no charge expected).
|
|
247
|
+
let out;
|
|
241
248
|
try {
|
|
242
|
-
|
|
243
|
-
const { costNanos } = await this.settle(ctx, {
|
|
244
|
-
requestId,
|
|
245
|
-
responseText: out.text,
|
|
246
|
-
usage: out.usage,
|
|
247
|
-
promptTokens: out.promptTokens,
|
|
248
|
-
completionTokens: out.completionTokens,
|
|
249
|
-
cachedTokens: out.cachedTokens,
|
|
250
|
-
serverToolUses: out.serverToolUses,
|
|
251
|
-
costNanos: out.costNanos,
|
|
252
|
-
latencyMs: Date.now() - start,
|
|
253
|
-
});
|
|
254
|
-
// Re-derive the recorded usage for the return value.
|
|
255
|
-
const usage = out.promptTokens !== undefined ||
|
|
256
|
-
out.completionTokens !== undefined ||
|
|
257
|
-
out.cachedTokens !== undefined
|
|
258
|
-
? {
|
|
259
|
-
promptTokens: out.promptTokens ?? 0,
|
|
260
|
-
completionTokens: out.completionTokens ?? 0,
|
|
261
|
-
cachedTokens: out.cachedTokens ?? 0,
|
|
262
|
-
}
|
|
263
|
-
: extractUsage(out.usage);
|
|
264
|
-
return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
|
|
249
|
+
out = await run();
|
|
265
250
|
}
|
|
266
251
|
catch (e) {
|
|
267
252
|
await this.settle(ctx, { requestId, error: String(e), latencyMs: Date.now() - start });
|
|
268
253
|
throw e;
|
|
269
254
|
}
|
|
255
|
+
// The call SUCCEEDED (the provider may have charged). Settle the real usage.
|
|
256
|
+
// If settlement itself fails here, do NOT fall into an error-settle that
|
|
257
|
+
// records zero — that would erase a real charge. Rethrow and leave the
|
|
258
|
+
// reservation for the reconciler; billing stays "unknown", never a false zero.
|
|
259
|
+
const { costNanos } = await this.settle(ctx, {
|
|
260
|
+
requestId,
|
|
261
|
+
responseText: out.text,
|
|
262
|
+
usage: out.usage,
|
|
263
|
+
promptTokens: out.promptTokens,
|
|
264
|
+
completionTokens: out.completionTokens,
|
|
265
|
+
cachedTokens: out.cachedTokens,
|
|
266
|
+
serverToolUses: out.serverToolUses,
|
|
267
|
+
costNanos: out.costNanos,
|
|
268
|
+
latencyMs: Date.now() - start,
|
|
269
|
+
});
|
|
270
|
+
// Re-derive the recorded usage for the return value.
|
|
271
|
+
const usage = out.promptTokens !== undefined ||
|
|
272
|
+
out.completionTokens !== undefined ||
|
|
273
|
+
out.cachedTokens !== undefined
|
|
274
|
+
? {
|
|
275
|
+
promptTokens: out.promptTokens ?? 0,
|
|
276
|
+
completionTokens: out.completionTokens ?? 0,
|
|
277
|
+
cachedTokens: out.cachedTokens ?? 0,
|
|
278
|
+
}
|
|
279
|
+
: extractUsage(out.usage);
|
|
280
|
+
return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
|
|
270
281
|
}
|
|
271
282
|
/**
|
|
272
283
|
* One-shot chat through the AI Gateway with tracking + limits — sugar over
|
|
@@ -302,6 +313,72 @@ export class AIBudget {
|
|
|
302
313
|
};
|
|
303
314
|
});
|
|
304
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Budget a structured decision through the AI Gateway's Decisions ("Jev")
|
|
318
|
+
* endpoint — sugar over `meter`. Evaluates typed `questions` (choice / score /
|
|
319
|
+
* boolean) about the `state` you provide, with the same reserve→settle
|
|
320
|
+
* limits, audit log, cost tracking, and per-tag attribution as `chat`. Call
|
|
321
|
+
* from an action. `userId` defaults to the authenticated caller.
|
|
322
|
+
*
|
|
323
|
+
* Requires `@convex-dev/ai-sdk-provider` >= 0.2.1 and an `ai` version that
|
|
324
|
+
* exposes `experimental_evaluate` (AI SDK 7's evaluation interface); both are
|
|
325
|
+
* imported lazily, so consumers who never call `decisions()` are unaffected.
|
|
326
|
+
*
|
|
327
|
+
* const { answers } = await ai.decisions(ctx, {
|
|
328
|
+
* state: { ticket: "Customer cannot sign in" },
|
|
329
|
+
* questions: {
|
|
330
|
+
* priority: { type: "choice", instructions: "...", criteria: { urgent: "...", normal: "..." } },
|
|
331
|
+
* needsReview: { type: "boolean", instructions: "..." },
|
|
332
|
+
* },
|
|
333
|
+
* });
|
|
334
|
+
* answers.priority.choice; // "urgent" | "normal"
|
|
335
|
+
*/
|
|
336
|
+
async decisions(ctx, args) {
|
|
337
|
+
const model = args.model ?? this.defaultEvalModel;
|
|
338
|
+
// `evaluate` is an experimental, version-gated export; import it lazily and
|
|
339
|
+
// untyped so consumers on an older `ai` (who never call this) aren't broken.
|
|
340
|
+
const evaluate = (await import("ai")).experimental_evaluate;
|
|
341
|
+
if (typeof evaluate !== "function") {
|
|
342
|
+
throw new Error("ai-budget: decisions() needs `experimental_evaluate` from the `ai` " +
|
|
343
|
+
"package (AI SDK 7's evaluation interface). Upgrade `ai` to a " +
|
|
344
|
+
"version that exports it.");
|
|
345
|
+
}
|
|
346
|
+
// Likewise, `evaluationModel` exists on @convex-dev/ai-sdk-provider >= 0.2.1.
|
|
347
|
+
const evaluationModel = convexGateway.evaluationModel;
|
|
348
|
+
if (typeof evaluationModel !== "function") {
|
|
349
|
+
throw new Error("ai-budget: decisions() needs `convexGateway.evaluationModel` from " +
|
|
350
|
+
"@convex-dev/ai-sdk-provider >= 0.2.1. Upgrade the provider.");
|
|
351
|
+
}
|
|
352
|
+
let decision;
|
|
353
|
+
const result = await this.meter(ctx, {
|
|
354
|
+
model,
|
|
355
|
+
// Store the structured request for audit/replay.
|
|
356
|
+
messages: [
|
|
357
|
+
{
|
|
358
|
+
role: "user",
|
|
359
|
+
content: JSON.stringify({ state: args.state, questions: args.questions }),
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
userId: args.userId,
|
|
363
|
+
action: args.action,
|
|
364
|
+
tags: args.tags,
|
|
365
|
+
estimatedCostNanos: args.estimatedCostNanos,
|
|
366
|
+
rerunOf: args.rerunOf,
|
|
367
|
+
}, async () => {
|
|
368
|
+
decision = await evaluate({
|
|
369
|
+
model: evaluationModel(model),
|
|
370
|
+
state: args.state,
|
|
371
|
+
questions: args.questions,
|
|
372
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
373
|
+
});
|
|
374
|
+
return {
|
|
375
|
+
usage: decision?.usage,
|
|
376
|
+
costNanos: extractGatewayCostNanos(decision),
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
const { text: _text, ...tracking } = result;
|
|
380
|
+
return { ...tracking, answers: decision?.answers ?? {}, response: decision?.response };
|
|
381
|
+
}
|
|
305
382
|
/**
|
|
306
383
|
* An AI SDK LanguageModel that enforces limits and records usage/cost for
|
|
307
384
|
* `userId` on every call. Drop it into `generateText`, `streamText`, or the
|
|
@@ -347,15 +424,10 @@ export class AIBudget {
|
|
|
347
424
|
wrapGenerate: async ({ doGenerate, params }) => {
|
|
348
425
|
const requestId = await begin(params);
|
|
349
426
|
const start = Date.now();
|
|
427
|
+
// Only a failure of the generation itself settles as an error.
|
|
428
|
+
let result;
|
|
350
429
|
try {
|
|
351
|
-
|
|
352
|
-
await finish(requestId, {
|
|
353
|
-
responseText: extractText(result),
|
|
354
|
-
...extractUsage(result.usage),
|
|
355
|
-
costNanos: extractGatewayCostNanos(result),
|
|
356
|
-
latencyMs: Date.now() - start,
|
|
357
|
-
});
|
|
358
|
-
return result;
|
|
430
|
+
result = await doGenerate();
|
|
359
431
|
}
|
|
360
432
|
catch (e) {
|
|
361
433
|
await finish(requestId, {
|
|
@@ -364,6 +436,15 @@ export class AIBudget {
|
|
|
364
436
|
});
|
|
365
437
|
throw e;
|
|
366
438
|
}
|
|
439
|
+
// Generation succeeded (provider may have charged). Settle the real
|
|
440
|
+
// usage; a failure here rethrows rather than recording a false zero.
|
|
441
|
+
await finish(requestId, {
|
|
442
|
+
responseText: extractText(result),
|
|
443
|
+
...extractUsage(result.usage),
|
|
444
|
+
costNanos: extractGatewayCostNanos(result),
|
|
445
|
+
latencyMs: Date.now() - start,
|
|
446
|
+
});
|
|
447
|
+
return result;
|
|
367
448
|
},
|
|
368
449
|
wrapStream: async ({ doStream, params }) => {
|
|
369
450
|
const requestId = await begin(params);
|
|
@@ -378,21 +459,22 @@ export class AIBudget {
|
|
|
378
459
|
// chunk, or a cancel — is safe: the first wins, the rest no-op.
|
|
379
460
|
// Without this an errored or abandoned stream would never settle and
|
|
380
461
|
// its real usage would be lost (recorded as free by the reconciler).
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
})
|
|
393
|
-
|
|
462
|
+
// Settle at most once, memoizing the PROMISE so the finish chunk, an
|
|
463
|
+
// error chunk, and flush all await the same settlement instead of
|
|
464
|
+
// racing, dropping it (the old `void settle()`), or flipping a
|
|
465
|
+
// "settled" flag before the mutation actually committed. A stream
|
|
466
|
+
// that is cancelled/never fully consumed won't deliver finish or
|
|
467
|
+
// flush; the reconciler's reservation expiry is the backstop there.
|
|
468
|
+
let settlement;
|
|
469
|
+
const settle = (error) => (settlement ??= finish(requestId, {
|
|
470
|
+
responseText: text,
|
|
471
|
+
error,
|
|
472
|
+
...extractUsage(usage),
|
|
473
|
+
costNanos: extractGatewayCostNanos({ providerMetadata }),
|
|
474
|
+
latencyMs: Date.now() - start,
|
|
475
|
+
}));
|
|
394
476
|
const tapped = result.stream.pipeThrough(new TransformStream({
|
|
395
|
-
transform(chunk, controller) {
|
|
477
|
+
async transform(chunk, controller) {
|
|
396
478
|
if (chunk?.type === "text-delta") {
|
|
397
479
|
text += chunk.delta ?? chunk.textDelta ?? "";
|
|
398
480
|
}
|
|
@@ -400,9 +482,11 @@ export class AIBudget {
|
|
|
400
482
|
usage = chunk.usage;
|
|
401
483
|
providerMetadata = chunk.providerMetadata ?? providerMetadata;
|
|
402
484
|
}
|
|
403
|
-
if (chunk?.type === "error")
|
|
404
|
-
void settle(String(chunk.error));
|
|
405
485
|
controller.enqueue(chunk);
|
|
486
|
+
// Settle after forwarding the terminal error chunk, and AWAIT
|
|
487
|
+
// it so a failed settle surfaces instead of being dropped.
|
|
488
|
+
if (chunk?.type === "error")
|
|
489
|
+
await settle(String(chunk.error));
|
|
406
490
|
},
|
|
407
491
|
async flush() {
|
|
408
492
|
await settle();
|
|
@@ -590,16 +674,26 @@ export class AIBudget {
|
|
|
590
674
|
return { ok: false, token: "" };
|
|
591
675
|
const url = new URL(request.url);
|
|
592
676
|
const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
593
|
-
// `?token=` is accepted
|
|
594
|
-
//
|
|
595
|
-
// JSON API
|
|
596
|
-
|
|
677
|
+
// `?token=` is accepted ONLY for the initial page navigation (a browser GET
|
|
678
|
+
// can't set headers); the page strips it from the URL on load and calls the
|
|
679
|
+
// JSON API with the bearer header. Restrict it to that GET page route so a
|
|
680
|
+
// token can't be smuggled in a query string on API/mutation calls (where it
|
|
681
|
+
// would also land in access logs). Compared in constant time.
|
|
682
|
+
const sub = url.pathname.slice(prefix.length) || "/";
|
|
683
|
+
const isPageNav = request.method === "GET" && !sub.startsWith("/api");
|
|
684
|
+
const provided = bearer || (isPageNav ? url.searchParams.get("token") ?? "" : "");
|
|
597
685
|
return { ok: timingSafeEqual(provided, token), token };
|
|
598
686
|
};
|
|
599
687
|
const json = (data, status = 200) => new Response(JSON.stringify(data ?? null), {
|
|
600
688
|
status,
|
|
601
|
-
|
|
689
|
+
// Never let a shared cache/proxy retain budget data or the token-bearing
|
|
690
|
+
// page — these responses are per-viewer and sensitive.
|
|
691
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
602
692
|
});
|
|
693
|
+
// JSON.stringify does NOT escape `<`, so a value containing `</script>`
|
|
694
|
+
// would close the inline <script> and break out. Escape `<` (and the JS line
|
|
695
|
+
// separators) before embedding in HTML.
|
|
696
|
+
const jsonForScript = (x) => JSON.stringify(x).replace(/[<\u2028\u2029]/g, (ch) => "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
603
697
|
const handle = async (ctx, request) => {
|
|
604
698
|
const url = new URL(request.url);
|
|
605
699
|
const sub = url.pathname.slice(prefix.length) || "/";
|
|
@@ -656,12 +750,16 @@ export class AIBudget {
|
|
|
656
750
|
return json({ error: "not found" }, 404);
|
|
657
751
|
}
|
|
658
752
|
}
|
|
659
|
-
// Inject as JSON literals (function replacers so `$` in the
|
|
660
|
-
// treated as a replacement pattern
|
|
661
|
-
//
|
|
662
|
-
const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () =>
|
|
753
|
+
// Inject as script-safe JSON literals (function replacers so `$` in the
|
|
754
|
+
// value isn't treated as a replacement pattern; `jsonForScript` escapes
|
|
755
|
+
// `<` so a token containing `</script>` can't break out of the inline JS).
|
|
756
|
+
const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () => jsonForScript(`${prefix}/api`)).replace(/__TOKEN__/g, () => jsonForScript(token));
|
|
757
|
+
// The page embeds the bearer token — never let a shared cache retain it.
|
|
663
758
|
return new Response(html, {
|
|
664
|
-
headers: {
|
|
759
|
+
headers: {
|
|
760
|
+
"content-type": "text/html; charset=utf-8",
|
|
761
|
+
"cache-control": "no-store",
|
|
762
|
+
},
|
|
665
763
|
});
|
|
666
764
|
};
|
|
667
765
|
const handler = httpActionGeneric(handle);
|
package/dist/component/lib.d.ts
CHANGED
|
@@ -377,9 +377,9 @@ export declare const getGlobalStatus: import("convex/server").RegisteredQuery<"p
|
|
|
377
377
|
defaultWarnAtPct: number | null;
|
|
378
378
|
}>>;
|
|
379
379
|
export declare const setGlobalLimits: import("convex/server").RegisteredMutation<"public", {
|
|
380
|
-
dailySpendLimitNanos?: number | undefined;
|
|
381
|
-
lifetimeSpendLimitNanos?: number | undefined;
|
|
382
|
-
enforcement?: "hard" | "soft" | undefined;
|
|
380
|
+
dailySpendLimitNanos?: number | null | undefined;
|
|
381
|
+
lifetimeSpendLimitNanos?: number | null | undefined;
|
|
382
|
+
enforcement?: "hard" | "soft" | null | undefined;
|
|
383
383
|
}, Promise<null>>;
|
|
384
384
|
export declare const bumpGlobal: import("convex/server").RegisteredMutation<"public", {
|
|
385
385
|
dailyNanos?: number | undefined;
|