@convex-dev/ai-budget 0.0.2-alpha.7 → 0.0.2-alpha.9
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/dist/client/dashboard.js +10 -2
- package/dist/client/index.js +38 -12
- package/package.json +1 -1
- package/src/client/dashboard.ts +10 -2
- package/src/client/index.ts +41 -14
package/dist/client/dashboard.js
CHANGED
|
@@ -54,8 +54,16 @@ export const DASHBOARD_HTML = String.raw `<!doctype html>
|
|
|
54
54
|
</nav>
|
|
55
55
|
<main id="main"></main>
|
|
56
56
|
<script>
|
|
57
|
-
|
|
58
|
-
const
|
|
57
|
+
// Injected as JSON literals by registerRoutes (no surrounding quotes here).
|
|
58
|
+
const API = __API_BASE__;
|
|
59
|
+
const TOKEN = __TOKEN__;
|
|
60
|
+
// If we were opened with ?token=… (needed for the initial navigation, which
|
|
61
|
+
// can't set headers), drop it from the address bar so it doesn't linger in
|
|
62
|
+
// history; API calls below use the Authorization header instead.
|
|
63
|
+
try {
|
|
64
|
+
const u = new URL(location.href);
|
|
65
|
+
if (u.searchParams.has("token")) { u.searchParams.delete("token"); history.replaceState(null, "", u.toString()); }
|
|
66
|
+
} catch (e) {}
|
|
59
67
|
const H = TOKEN ? { Authorization: "Bearer " + TOKEN } : {};
|
|
60
68
|
const NANOS = 1e9;
|
|
61
69
|
const usd = (n) => n == null ? "—" : "$" + (n / NANOS).toFixed(n && n < NANOS/100 ? 6 : 2);
|
package/dist/client/index.js
CHANGED
|
@@ -51,20 +51,25 @@ function extractUsage(usage) {
|
|
|
51
51
|
usage?.cached_tokens),
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
-
// The
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
59
|
function extractGatewayCostNanos(result) {
|
|
60
60
|
const meta = result?.providerMetadata?.convexGateway;
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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);
|
|
65
69
|
}
|
|
66
70
|
return undefined;
|
|
67
71
|
}
|
|
72
|
+
const NANOS_PER_DOLLAR = 1e9;
|
|
68
73
|
// Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
|
|
69
74
|
function simplifyPrompt(prompt) {
|
|
70
75
|
if (!Array.isArray(prompt))
|
|
@@ -96,6 +101,17 @@ function extractText(result) {
|
|
|
96
101
|
}
|
|
97
102
|
return "";
|
|
98
103
|
}
|
|
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
|
+
}
|
|
99
115
|
export class AIBudget {
|
|
100
116
|
component;
|
|
101
117
|
defaultModel;
|
|
@@ -275,6 +291,7 @@ export class AIBudget {
|
|
|
275
291
|
const start = Date.now();
|
|
276
292
|
let text = "";
|
|
277
293
|
let usage = undefined;
|
|
294
|
+
let providerMetadata = undefined;
|
|
278
295
|
try {
|
|
279
296
|
const result = await doStream();
|
|
280
297
|
// finishRequest is idempotent (terminal-guarded server-side), so
|
|
@@ -291,6 +308,7 @@ export class AIBudget {
|
|
|
291
308
|
responseText: text,
|
|
292
309
|
error,
|
|
293
310
|
...extractUsage(usage),
|
|
311
|
+
costNanos: extractGatewayCostNanos({ providerMetadata }),
|
|
294
312
|
latencyMs: Date.now() - start,
|
|
295
313
|
});
|
|
296
314
|
};
|
|
@@ -299,8 +317,10 @@ export class AIBudget {
|
|
|
299
317
|
if (chunk?.type === "text-delta") {
|
|
300
318
|
text += chunk.delta ?? chunk.textDelta ?? "";
|
|
301
319
|
}
|
|
302
|
-
if (chunk?.type === "finish")
|
|
320
|
+
if (chunk?.type === "finish") {
|
|
303
321
|
usage = chunk.usage;
|
|
322
|
+
providerMetadata = chunk.providerMetadata ?? providerMetadata;
|
|
323
|
+
}
|
|
304
324
|
if (chunk?.type === "error")
|
|
305
325
|
void settle(String(chunk.error));
|
|
306
326
|
controller.enqueue(chunk);
|
|
@@ -485,8 +505,11 @@ export class AIBudget {
|
|
|
485
505
|
return { ok: false, token: "" };
|
|
486
506
|
const url = new URL(request.url);
|
|
487
507
|
const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
508
|
+
// `?token=` is accepted only for the initial page navigation (a browser
|
|
509
|
+
// GET can't set headers); the page strips it from the URL on load and the
|
|
510
|
+
// JSON API is called with the bearer header. Compared in constant time.
|
|
488
511
|
const provided = bearer || url.searchParams.get("token") || "";
|
|
489
|
-
return { ok: provided
|
|
512
|
+
return { ok: timingSafeEqual(provided, token), token };
|
|
490
513
|
};
|
|
491
514
|
const json = (data, status = 200) => new Response(JSON.stringify(data ?? null), {
|
|
492
515
|
status,
|
|
@@ -548,7 +571,10 @@ export class AIBudget {
|
|
|
548
571
|
return json({ error: "not found" }, 404);
|
|
549
572
|
}
|
|
550
573
|
}
|
|
551
|
-
|
|
574
|
+
// Inject as JSON literals (function replacers so `$` in the value isn't
|
|
575
|
+
// treated as a replacement pattern). This keeps a token/prefix containing
|
|
576
|
+
// quotes, backslashes, or `</script>` from breaking out of the JS string.
|
|
577
|
+
const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () => JSON.stringify(`${prefix}/api`)).replace(/__TOKEN__/g, () => JSON.stringify(token));
|
|
552
578
|
return new Response(html, {
|
|
553
579
|
headers: { "content-type": "text/html; charset=utf-8" },
|
|
554
580
|
});
|
package/package.json
CHANGED
package/src/client/dashboard.ts
CHANGED
|
@@ -54,8 +54,16 @@ export const DASHBOARD_HTML = String.raw`<!doctype html>
|
|
|
54
54
|
</nav>
|
|
55
55
|
<main id="main"></main>
|
|
56
56
|
<script>
|
|
57
|
-
|
|
58
|
-
const
|
|
57
|
+
// Injected as JSON literals by registerRoutes (no surrounding quotes here).
|
|
58
|
+
const API = __API_BASE__;
|
|
59
|
+
const TOKEN = __TOKEN__;
|
|
60
|
+
// If we were opened with ?token=… (needed for the initial navigation, which
|
|
61
|
+
// can't set headers), drop it from the address bar so it doesn't linger in
|
|
62
|
+
// history; API calls below use the Authorization header instead.
|
|
63
|
+
try {
|
|
64
|
+
const u = new URL(location.href);
|
|
65
|
+
if (u.searchParams.has("token")) { u.searchParams.delete("token"); history.replaceState(null, "", u.toString()); }
|
|
66
|
+
} catch (e) {}
|
|
59
67
|
const H = TOKEN ? { Authorization: "Bearer " + TOKEN } : {};
|
|
60
68
|
const NANOS = 1e9;
|
|
61
69
|
const usd = (n) => n == null ? "—" : "$" + (n / NANOS).toFixed(n && n < NANOS/100 ? 6 : 2);
|
package/src/client/index.ts
CHANGED
|
@@ -174,20 +174,27 @@ function extractUsage(usage: any): {
|
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
// The
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
177
|
+
// The AI Gateway reports the authoritative dollar cost of each request.
|
|
178
|
+
// @convex-dev/ai-sdk-provider surfaces it at
|
|
179
|
+
// `providerMetadata.convexGateway.cost` (USD); convert to nanodollars and pass
|
|
180
|
+
// it through as authoritative (finishRequest prefers it over the token-based
|
|
181
|
+
// estimate). No-op on older provider versions that don't surface it.
|
|
182
182
|
function extractGatewayCostNanos(result: any): number | undefined {
|
|
183
183
|
const meta = result?.providerMetadata?.convexGateway;
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
const costUsd = meta?.cost;
|
|
185
|
+
if (typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd >= 0) {
|
|
186
|
+
return Math.round(costUsd * NANOS_PER_DOLLAR);
|
|
187
|
+
}
|
|
188
|
+
// Back-compat: honor an explicitly nano-denominated field if ever present.
|
|
189
|
+
const costNanos = meta?.costNanos ?? result?.usage?.costNanos;
|
|
190
|
+
if (typeof costNanos === "number" && Number.isFinite(costNanos) && costNanos >= 0) {
|
|
191
|
+
return Math.round(costNanos);
|
|
187
192
|
}
|
|
188
193
|
return undefined;
|
|
189
194
|
}
|
|
190
195
|
|
|
196
|
+
const NANOS_PER_DOLLAR = 1e9;
|
|
197
|
+
|
|
191
198
|
// Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
|
|
192
199
|
function simplifyPrompt(prompt: any): Message[] {
|
|
193
200
|
if (!Array.isArray(prompt)) return [];
|
|
@@ -221,6 +228,15 @@ function extractText(result: any): string {
|
|
|
221
228
|
|
|
222
229
|
// ---------- client ----------
|
|
223
230
|
|
|
231
|
+
// Length-independent-branch string compare, so the dashboard token check
|
|
232
|
+
// doesn't leak the token via response timing. (Length itself is not secret.)
|
|
233
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
234
|
+
if (a.length !== b.length) return false;
|
|
235
|
+
let r = 0;
|
|
236
|
+
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
237
|
+
return r === 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
224
240
|
/** Limits/controls settable on any budget bucket (user, action, or tag). */
|
|
225
241
|
export type BucketLimits = {
|
|
226
242
|
requestsPerMinute?: number;
|
|
@@ -474,6 +490,7 @@ export class AIBudget {
|
|
|
474
490
|
const start = Date.now();
|
|
475
491
|
let text = "";
|
|
476
492
|
let usage: any = undefined;
|
|
493
|
+
let providerMetadata: any = undefined;
|
|
477
494
|
try {
|
|
478
495
|
const result = await doStream();
|
|
479
496
|
// finishRequest is idempotent (terminal-guarded server-side), so
|
|
@@ -489,6 +506,7 @@ export class AIBudget {
|
|
|
489
506
|
responseText: text,
|
|
490
507
|
error,
|
|
491
508
|
...extractUsage(usage),
|
|
509
|
+
costNanos: extractGatewayCostNanos({ providerMetadata }),
|
|
492
510
|
latencyMs: Date.now() - start,
|
|
493
511
|
});
|
|
494
512
|
};
|
|
@@ -498,7 +516,10 @@ export class AIBudget {
|
|
|
498
516
|
if (chunk?.type === "text-delta") {
|
|
499
517
|
text += chunk.delta ?? chunk.textDelta ?? "";
|
|
500
518
|
}
|
|
501
|
-
if (chunk?.type === "finish")
|
|
519
|
+
if (chunk?.type === "finish") {
|
|
520
|
+
usage = chunk.usage;
|
|
521
|
+
providerMetadata = chunk.providerMetadata ?? providerMetadata;
|
|
522
|
+
}
|
|
502
523
|
if (chunk?.type === "error") void settle(String(chunk.error));
|
|
503
524
|
controller.enqueue(chunk);
|
|
504
525
|
},
|
|
@@ -756,8 +777,11 @@ export class AIBudget {
|
|
|
756
777
|
if (!token) return { ok: false, token: "" };
|
|
757
778
|
const url = new URL(request.url);
|
|
758
779
|
const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
780
|
+
// `?token=` is accepted only for the initial page navigation (a browser
|
|
781
|
+
// GET can't set headers); the page strips it from the URL on load and the
|
|
782
|
+
// JSON API is called with the bearer header. Compared in constant time.
|
|
759
783
|
const provided = bearer || url.searchParams.get("token") || "";
|
|
760
|
-
return { ok: provided
|
|
784
|
+
return { ok: timingSafeEqual(provided, token), token };
|
|
761
785
|
};
|
|
762
786
|
const json = (data: unknown, status = 200) =>
|
|
763
787
|
new Response(JSON.stringify(data ?? null), {
|
|
@@ -827,10 +851,13 @@ export class AIBudget {
|
|
|
827
851
|
}
|
|
828
852
|
}
|
|
829
853
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
854
|
+
// Inject as JSON literals (function replacers so `$` in the value isn't
|
|
855
|
+
// treated as a replacement pattern). This keeps a token/prefix containing
|
|
856
|
+
// quotes, backslashes, or `</script>` from breaking out of the JS string.
|
|
857
|
+
const html = DASHBOARD_HTML.replace(
|
|
858
|
+
/__API_BASE__/g,
|
|
859
|
+
() => JSON.stringify(`${prefix}/api`)
|
|
860
|
+
).replace(/__TOKEN__/g, () => JSON.stringify(token));
|
|
834
861
|
return new Response(html, {
|
|
835
862
|
headers: { "content-type": "text/html; charset=utf-8" },
|
|
836
863
|
});
|