@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.
@@ -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
- const API = "__API_BASE__";
58
- const TOKEN = "__TOKEN__";
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);
@@ -51,20 +51,25 @@ function extractUsage(usage) {
51
51
  usage?.cached_tokens),
52
52
  };
53
53
  }
54
- // The gateway doesn't report a dollar cost today, but if a future response ever
55
- // carries an unambiguous nanodollar cost we pass it straight through as
56
- // authoritative (finishRequest prefers it over the token-based estimate). Only
57
- // an explicitly nano-denominated field is trusted a bare `cost` could be in
58
- // dollars and silently mis-bill by 1e9×.
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 candidates = [meta?.costNanos, result?.usage?.costNanos];
62
- for (const c of candidates) {
63
- if (typeof c === "number" && Number.isFinite(c) && c >= 0)
64
- return c;
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 === token, token };
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
- const html = DASHBOARD_HTML.replace(/__API_BASE__/g, `${prefix}/api`).replace(/__TOKEN__/g, token);
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
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/ai-budget/issues"
9
9
  },
10
- "version": "0.0.2-alpha.7",
10
+ "version": "0.0.2-alpha.9",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -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
- const API = "__API_BASE__";
58
- const TOKEN = "__TOKEN__";
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);
@@ -174,20 +174,27 @@ function extractUsage(usage: any): {
174
174
  };
175
175
  }
176
176
 
177
- // The gateway doesn't report a dollar cost today, but if a future response ever
178
- // carries an unambiguous nanodollar cost we pass it straight through as
179
- // authoritative (finishRequest prefers it over the token-based estimate). Only
180
- // an explicitly nano-denominated field is trusted a bare `cost` could be in
181
- // dollars and silently mis-bill by 1e9×.
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 candidates = [meta?.costNanos, result?.usage?.costNanos];
185
- for (const c of candidates) {
186
- if (typeof c === "number" && Number.isFinite(c) && c >= 0) return c;
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") usage = chunk.usage;
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 === token, token };
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
- const html = DASHBOARD_HTML.replace(/__API_BASE__/g, `${prefix}/api`).replace(
831
- /__TOKEN__/g,
832
- token
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
  });