@agent-native/dispatch 0.2.5 → 0.2.7

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.
@@ -77,7 +77,17 @@ interface RecentUsageMetric {
77
77
  costCents: number;
78
78
  }
79
79
 
80
+ interface UsageBillingMode {
81
+ unit: "usd" | "builder-credits";
82
+ label: string;
83
+ shortLabel: string;
84
+ source: "estimated-provider-cost" | "builder-agent-credits";
85
+ hardCostMarginMultiplier?: number;
86
+ creditsPerUsd?: number;
87
+ }
88
+
80
89
  interface DispatchUsageMetrics {
90
+ billing?: UsageBillingMode;
81
91
  sinceDays: number;
82
92
  access: {
83
93
  viewerEmail: string;
@@ -110,7 +120,37 @@ interface DispatchUsageMetrics {
110
120
 
111
121
  const RANGES = [7, 30, 90] as const;
112
122
 
113
- function formatCost(cents: number): string {
123
+ const USD_BILLING: UsageBillingMode = {
124
+ unit: "usd",
125
+ label: "Estimated spend",
126
+ shortLabel: "Cost",
127
+ source: "estimated-provider-cost",
128
+ };
129
+
130
+ function displayAmountFromCostCents(
131
+ cents: number,
132
+ billing: UsageBillingMode,
133
+ ): number {
134
+ if (billing.unit !== "builder-credits") return cents;
135
+ const margin = billing.hardCostMarginMultiplier ?? 1.25;
136
+ const creditsPerUsd = billing.creditsPerUsd ?? 20;
137
+ const credits = (cents / 100) * margin * creditsPerUsd;
138
+ return credits <= 0 ? 0 : Math.ceil(credits * 1000) / 1000;
139
+ }
140
+
141
+ function formatCredits(credits: number): string {
142
+ if (!Number.isFinite(credits) || credits === 0) return "0 credits";
143
+ const maximumFractionDigits = credits < 1 ? 3 : credits < 10 ? 2 : 1;
144
+ const value = credits.toLocaleString(undefined, {
145
+ maximumFractionDigits,
146
+ });
147
+ return `${value} ${credits === 1 ? "credit" : "credits"}`;
148
+ }
149
+
150
+ function formatSpend(cents: number, billing: UsageBillingMode): string {
151
+ if (billing.unit === "builder-credits") {
152
+ return formatCredits(displayAmountFromCostCents(cents, billing));
153
+ }
114
154
  if (!Number.isFinite(cents) || cents === 0) return "$0.00";
115
155
  if (Math.abs(cents) < 1) return `${cents.toFixed(3)}¢`;
116
156
  if (Math.abs(cents) < 100) return `${cents.toFixed(2)}¢`;
@@ -150,8 +190,15 @@ function displayApp(value: string | null | undefined): string {
150
190
  return trimmed;
151
191
  }
152
192
 
153
- function maxCost(rows: Array<{ costCents: number }>): number {
154
- return rows.reduce((max, row) => Math.max(max, row.costCents), 0);
193
+ function maxSpend(
194
+ rows: Array<{ costCents: number }>,
195
+ billing: UsageBillingMode,
196
+ ): number {
197
+ return rows.reduce(
198
+ (max, row) =>
199
+ Math.max(max, displayAmountFromCostCents(row.costCents, billing)),
200
+ 0,
201
+ );
155
202
  }
156
203
 
157
204
  function barWidth(value: number, max: number): string {
@@ -257,8 +304,14 @@ function LoadingMetrics() {
257
304
  );
258
305
  }
259
306
 
260
- function AppSpendRows({ rows }: { rows: UsageMetricBucket[] }) {
261
- const max = maxCost(rows);
307
+ function AppSpendRows({
308
+ rows,
309
+ billing,
310
+ }: {
311
+ rows: UsageMetricBucket[];
312
+ billing: UsageBillingMode;
313
+ }) {
314
+ const max = maxSpend(rows, billing);
262
315
  if (rows.length === 0) {
263
316
  return (
264
317
  <div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
@@ -282,7 +335,7 @@ function AppSpendRows({ rows }: { rows: UsageMetricBucket[] }) {
282
335
  </div>
283
336
  <div className="shrink-0 text-right">
284
337
  <div className="font-medium tabular-nums text-foreground">
285
- {formatCost(row.costCents)}
338
+ {formatSpend(row.costCents, billing)}
286
339
  </div>
287
340
  <div className="text-xs text-muted-foreground">
288
341
  {formatNumber(row.calls)} calls
@@ -292,7 +345,12 @@ function AppSpendRows({ rows }: { rows: UsageMetricBucket[] }) {
292
345
  <div className="h-2 overflow-hidden rounded-full bg-muted">
293
346
  <div
294
347
  className="h-full rounded-full bg-foreground"
295
- style={{ width: barWidth(row.costCents, max) }}
348
+ style={{
349
+ width: barWidth(
350
+ displayAmountFromCostCents(row.costCents, billing),
351
+ max,
352
+ ),
353
+ }}
296
354
  />
297
355
  </div>
298
356
  </div>
@@ -335,7 +393,13 @@ function DailyActivity({ rows }: { rows: DailyUsageMetric[] }) {
335
393
  );
336
394
  }
337
395
 
338
- function AppAccessTable({ rows }: { rows: AppAccessMetric[] }) {
396
+ function AppAccessTable({
397
+ rows,
398
+ billing,
399
+ }: {
400
+ rows: AppAccessMetric[];
401
+ billing: UsageBillingMode;
402
+ }) {
339
403
  const visibleRows = rows.filter((row) => !row.isDispatch);
340
404
  if (visibleRows.length === 0) {
341
405
  return (
@@ -353,7 +417,9 @@ function AppAccessTable({ rows }: { rows: AppAccessMetric[] }) {
353
417
  <th className="px-2 py-2 font-medium">Access</th>
354
418
  <th className="px-2 py-2 text-right font-medium">Users</th>
355
419
  <th className="px-2 py-2 text-right font-medium">Chats</th>
356
- <th className="px-2 py-2 text-right font-medium">Cost</th>
420
+ <th className="px-2 py-2 text-right font-medium">
421
+ {billing.shortLabel}
422
+ </th>
357
423
  <th className="px-2 py-2 text-right font-medium">Last activity</th>
358
424
  </tr>
359
425
  </thead>
@@ -385,7 +451,7 @@ function AppAccessTable({ rows }: { rows: AppAccessMetric[] }) {
385
451
  {formatNumber(row.chatCalls)}
386
452
  </td>
387
453
  <td className="px-2 py-3 text-right tabular-nums">
388
- {formatCost(row.costCents)}
454
+ {formatSpend(row.costCents, billing)}
389
455
  </td>
390
456
  <td className="px-2 py-3 text-right text-muted-foreground">
391
457
  {timeAgo(row.lastActiveAt)}
@@ -398,7 +464,13 @@ function AppAccessTable({ rows }: { rows: AppAccessMetric[] }) {
398
464
  );
399
465
  }
400
466
 
401
- function UserTable({ rows }: { rows: UserUsageMetric[] }) {
467
+ function UserTable({
468
+ rows,
469
+ billing,
470
+ }: {
471
+ rows: UserUsageMetric[];
472
+ billing: UsageBillingMode;
473
+ }) {
402
474
  if (rows.length === 0) {
403
475
  return (
404
476
  <div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
@@ -417,7 +489,9 @@ function UserTable({ rows }: { rows: UserUsageMetric[] }) {
417
489
  <th className="px-2 py-2 text-right font-medium">Chats</th>
418
490
  <th className="px-2 py-2 text-right font-medium">Threads</th>
419
491
  <th className="px-2 py-2 text-right font-medium">Tokens</th>
420
- <th className="px-2 py-2 text-right font-medium">Cost</th>
492
+ <th className="px-2 py-2 text-right font-medium">
493
+ {billing.shortLabel}
494
+ </th>
421
495
  </tr>
422
496
  </thead>
423
497
  <tbody>
@@ -447,7 +521,7 @@ function UserTable({ rows }: { rows: UserUsageMetric[] }) {
447
521
  {formatTokens(row.inputTokens + row.outputTokens)}
448
522
  </td>
449
523
  <td className="px-2 py-3 text-right tabular-nums">
450
- {formatCost(row.costCents)}
524
+ {formatSpend(row.costCents, billing)}
451
525
  </td>
452
526
  </tr>
453
527
  ))}
@@ -460,11 +534,13 @@ function UserTable({ rows }: { rows: UserUsageMetric[] }) {
460
534
  function CompactBreakdown({
461
535
  rows,
462
536
  empty,
537
+ billing,
463
538
  }: {
464
539
  rows: UsageMetricBucket[];
465
540
  empty: string;
541
+ billing: UsageBillingMode;
466
542
  }) {
467
- const max = maxCost(rows);
543
+ const max = maxSpend(rows, billing);
468
544
  if (rows.length === 0) {
469
545
  return <div className="text-sm text-muted-foreground">{empty}</div>;
470
546
  }
@@ -477,13 +553,18 @@ function CompactBreakdown({
477
553
  {row.label}
478
554
  </span>
479
555
  <span className="shrink-0 tabular-nums text-muted-foreground">
480
- {formatCost(row.costCents)}
556
+ {formatSpend(row.costCents, billing)}
481
557
  </span>
482
558
  </div>
483
559
  <div className="h-1.5 overflow-hidden rounded-full bg-muted">
484
560
  <div
485
561
  className="h-full rounded-full bg-muted-foreground"
486
- style={{ width: barWidth(row.costCents, max) }}
562
+ style={{
563
+ width: barWidth(
564
+ displayAmountFromCostCents(row.costCents, billing),
565
+ max,
566
+ ),
567
+ }}
487
568
  />
488
569
  </div>
489
570
  </div>
@@ -492,7 +573,13 @@ function CompactBreakdown({
492
573
  );
493
574
  }
494
575
 
495
- function RecentTable({ rows }: { rows: RecentUsageMetric[] }) {
576
+ function RecentTable({
577
+ rows,
578
+ billing,
579
+ }: {
580
+ rows: RecentUsageMetric[];
581
+ billing: UsageBillingMode;
582
+ }) {
496
583
  if (rows.length === 0) {
497
584
  return (
498
585
  <div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
@@ -510,7 +597,9 @@ function RecentTable({ rows }: { rows: RecentUsageMetric[] }) {
510
597
  <th className="px-2 py-2 font-medium">App</th>
511
598
  <th className="px-2 py-2 font-medium">Label</th>
512
599
  <th className="px-2 py-2 font-medium">Model</th>
513
- <th className="px-2 py-2 text-right font-medium">Cost</th>
600
+ <th className="px-2 py-2 text-right font-medium">
601
+ {billing.shortLabel}
602
+ </th>
514
603
  </tr>
515
604
  </thead>
516
605
  <tbody>
@@ -534,7 +623,7 @@ function RecentTable({ rows }: { rows: RecentUsageMetric[] }) {
534
623
  </div>
535
624
  </td>
536
625
  <td className="px-2 py-3 text-right tabular-nums">
537
- {formatCost(row.costCents)}
626
+ {formatSpend(row.costCents, billing)}
538
627
  </td>
539
628
  </tr>
540
629
  ))}
@@ -552,6 +641,7 @@ export default function MetricsRoute() {
552
641
  { refetchInterval: 30_000 },
553
642
  );
554
643
  const metrics = data as DispatchUsageMetrics | undefined;
644
+ const billing = metrics?.billing ?? USD_BILLING;
555
645
  const totalTokens = useMemo(() => {
556
646
  if (!metrics) return 0;
557
647
  return (
@@ -565,7 +655,11 @@ export default function MetricsRoute() {
565
655
  return (
566
656
  <DispatchShell
567
657
  title="Metrics"
568
- description="Workspace-wide LLM spend, chat volume, user activity, and app access."
658
+ description={
659
+ billing.unit === "builder-credits"
660
+ ? "Workspace-wide Builder.io credit spend, chat volume, user activity, and app access."
661
+ : "Workspace-wide LLM spend, chat volume, user activity, and app access."
662
+ }
569
663
  >
570
664
  <div className="space-y-4">
571
665
  <div className="flex flex-wrap items-center justify-between gap-3">
@@ -593,8 +687,8 @@ export default function MetricsRoute() {
593
687
  <>
594
688
  <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
595
689
  <MetricCard
596
- label="Estimated spend"
597
- value={formatCost(metrics.totals.costCents)}
690
+ label={billing.label}
691
+ value={formatSpend(metrics.totals.costCents, billing)}
598
692
  detail={`${formatTokens(totalTokens)} total tokens`}
599
693
  icon={<IconCoin size={17} />}
600
694
  />
@@ -625,8 +719,15 @@ export default function MetricsRoute() {
625
719
  </div>
626
720
 
627
721
  <div className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)]">
628
- <Panel title="Spend By App" icon={<IconChartBar size={16} />}>
629
- <AppSpendRows rows={metrics.byApp} />
722
+ <Panel
723
+ title={
724
+ billing.unit === "builder-credits"
725
+ ? "Credit Spend By App"
726
+ : "Spend By App"
727
+ }
728
+ icon={<IconChartBar size={16} />}
729
+ >
730
+ <AppSpendRows rows={metrics.byApp} billing={billing} />
630
731
  </Panel>
631
732
  <Panel title="Daily Activity" icon={<IconClockHour4 size={16} />}>
632
733
  <DailyActivity rows={metrics.daily} />
@@ -634,11 +735,11 @@ export default function MetricsRoute() {
634
735
  </div>
635
736
 
636
737
  <Panel title="Access By App" icon={<IconApps size={16} />}>
637
- <AppAccessTable rows={metrics.appAccess} />
738
+ <AppAccessTable rows={metrics.appAccess} billing={billing} />
638
739
  </Panel>
639
740
 
640
741
  <Panel title="Users" icon={<IconUsersGroup size={16} />}>
641
- <UserTable rows={metrics.byUser} />
742
+ <UserTable rows={metrics.byUser} billing={billing} />
642
743
  </Panel>
643
744
 
644
745
  <div className="grid gap-4 lg:grid-cols-2">
@@ -646,18 +747,20 @@ export default function MetricsRoute() {
646
747
  <CompactBreakdown
647
748
  rows={metrics.byModel}
648
749
  empty="No model usage in this window."
750
+ billing={billing}
649
751
  />
650
752
  </Panel>
651
753
  <Panel title="Work Types" icon={<IconActivity size={16} />}>
652
754
  <CompactBreakdown
653
755
  rows={metrics.byLabel}
654
756
  empty="No labeled usage in this window."
757
+ billing={billing}
655
758
  />
656
759
  </Panel>
657
760
  </div>
658
761
 
659
762
  <Panel title="Recent LLM Calls" icon={<IconActivity size={16} />}>
660
- <RecentTable rows={metrics.recent} />
763
+ <RecentTable rows={metrics.recent} billing={billing} />
661
764
  </Panel>
662
765
  </>
663
766
  ) : null}
@@ -435,15 +435,15 @@ function StepRow({ step }: { step: ChecklistStep }) {
435
435
  <div
436
436
  className={`flex items-start gap-4 rounded-xl border px-5 py-4 ${done ? "border-border/50 bg-muted/20" : "bg-card"}`}
437
437
  >
438
- {/* Number / check circle */}
438
+ {/* Status marker */}
439
439
  <div className="flex-none pt-0.5">
440
440
  {done ? (
441
441
  <div className="flex h-7 w-7 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
442
442
  <IconCheck size={16} strokeWidth={2.5} />
443
443
  </div>
444
444
  ) : (
445
- <div className="flex h-7 w-7 items-center justify-center rounded-full border-2 border-muted-foreground/30 text-sm font-semibold text-muted-foreground">
446
- {step.number}
445
+ <div className="flex h-7 w-7 items-center justify-center rounded-full border border-muted-foreground/30 text-muted-foreground">
446
+ <IconListCheck size={15} />
447
447
  </div>
448
448
  )}
449
449
  </div>
@@ -1,5 +1,18 @@
1
- import { getUsageSummary } from "@agent-native/core/usage";
1
+ import {
2
+ getUsageSummary,
3
+ usageBillingForEngine,
4
+ type UsageBillingMode,
5
+ } from "@agent-native/core/usage";
2
6
  import { getDbExec } from "@agent-native/core/db";
7
+ import {
8
+ detectEngineFromEnv,
9
+ detectEngineFromUserSecrets,
10
+ getAgentEngineEntry,
11
+ isAgentEngineSettingConfigured,
12
+ isStoredEngineUsable,
13
+ registerBuiltinEngines,
14
+ } from "@agent-native/core/agent/engine";
15
+ import { getSetting } from "@agent-native/core/settings";
3
16
  import { currentOrgId, currentOwnerEmail } from "./dispatch-store.js";
4
17
  import {
5
18
  listWorkspaceApps,
@@ -8,6 +21,8 @@ import {
8
21
 
9
22
  const DAY_MS = 86_400_000;
10
23
 
24
+ registerBuiltinEngines();
25
+
11
26
  export interface UsageMetricBucket {
12
27
  key: string;
13
28
  label: string;
@@ -70,6 +85,7 @@ export interface RecentUsageMetric {
70
85
  }
71
86
 
72
87
  export interface DispatchUsageMetrics {
88
+ billing: UsageBillingMode;
73
89
  sinceMs: number;
74
90
  sinceDays: number;
75
91
  generatedAt: number;
@@ -159,6 +175,30 @@ function isEnvAdmin(email: string): boolean {
159
175
  ].includes(normalized);
160
176
  }
161
177
 
178
+ async function detectUsageEngineName(): Promise<string | null> {
179
+ try {
180
+ const stored = (await getSetting("agent-engine")) as {
181
+ engine?: string;
182
+ } | null;
183
+ if (isAgentEngineSettingConfigured(stored)) {
184
+ return (stored as { engine: string }).engine;
185
+ }
186
+ if (stored && typeof stored.engine === "string") {
187
+ const entry = getAgentEngineEntry(stored.engine);
188
+ if (entry && isStoredEngineUsable(stored, entry)) {
189
+ return stored.engine;
190
+ }
191
+ }
192
+
193
+ const detectedFromUser = await detectEngineFromUserSecrets();
194
+ if (detectedFromUser) return detectedFromUser.name;
195
+
196
+ return detectEngineFromEnv()?.name ?? null;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
162
202
  async function queryRows<T extends Record<string, unknown>>(
163
203
  sql: string,
164
204
  args: unknown[] = [],
@@ -353,6 +393,7 @@ export async function listDispatchUsageMetrics(input: {
353
393
  const { viewerEmail, orgId, role } = await assertCanViewMetrics();
354
394
  const sinceDays = Math.max(1, Math.min(365, input.sinceDays ?? 30));
355
395
  const sinceMs = Date.now() - sinceDays * DAY_MS;
396
+ const billing = usageBillingForEngine(await detectUsageEngineName());
356
397
 
357
398
  // Initializes token_usage on fresh deployments before the read-only
358
399
  // aggregate queries below. The fake owner avoids changing visible data.
@@ -568,6 +609,7 @@ export async function listDispatchUsageMetrics(input: {
568
609
  );
569
610
 
570
611
  return {
612
+ billing,
571
613
  sinceMs,
572
614
  sinceDays,
573
615
  generatedAt: Date.now(),