Skip to content

Commit 40f0eb3

Browse files
feat(api): surface per-tenant AI cost on the operator dashboard (#7191)
Adds listAiCostByTenantSince (one GROUP BY query, highest-cost-first) and wires it into /v1/app/operator-dashboard. The ledger and per-tenant column already existed (#7176/#7183); nothing consumed them yet. Always empty for self-host, unchanged behavior there. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent e2ea2d2 commit 40f0eb3

5 files changed

Lines changed: 162 additions & 12 deletions

File tree

apps/loopover-ui/src/routes/app.operator.test.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,57 @@ describe("OperatorDashboard loading skeleton (#6816)", () => {
6060
expect(container.querySelectorAll(".animate-pulse").length).toBe(0);
6161
});
6262
});
63+
64+
describe("OperatorDashboard AI cost by tenant (#4916)", () => {
65+
function mockDashboard(aiCostByTenant?: Array<{ installationId: string; totalCostUsd: number }>) {
66+
useApiResource.mockImplementation((path: string) => {
67+
if (path === "/v1/app/operator-dashboard") {
68+
return {
69+
status: "ready",
70+
data: {
71+
metrics: [{ label: "Installs", value: "12", delta: "+2" }],
72+
noiseReduction: [],
73+
weeklyReport: [],
74+
aiCostByTenant,
75+
},
76+
error: null,
77+
loadedAt: "2026-07-17T00:00:00.000Z",
78+
reload: () => {},
79+
};
80+
}
81+
return {
82+
status: "error",
83+
data: null,
84+
error: "unavailable in this test",
85+
errorKind: "unknown",
86+
loadedAt: null,
87+
reload: () => {},
88+
};
89+
});
90+
}
91+
92+
it("renders no section at all when aiCostByTenant is absent (self-host, the common case)", () => {
93+
mockDashboard(undefined);
94+
render(<OperatorDashboard />);
95+
expect(screen.queryByText("AI cost by tenant")).toBeNull();
96+
});
97+
98+
it("renders no section when aiCostByTenant is an empty list", () => {
99+
mockDashboard([]);
100+
render(<OperatorDashboard />);
101+
expect(screen.queryByText("AI cost by tenant")).toBeNull();
102+
});
103+
104+
it("renders each tenant's formatted cost, highest-cost-first as the backend already ordered them", () => {
105+
mockDashboard([
106+
{ installationId: "inst-2", totalCostUsd: 4 },
107+
{ installationId: "inst-1", totalCostUsd: 2 },
108+
]);
109+
render(<OperatorDashboard />);
110+
expect(screen.getByText("AI cost by tenant")).toBeTruthy();
111+
expect(screen.getByText("inst-2")).toBeTruthy();
112+
expect(screen.getByText("$4.00")).toBeTruthy();
113+
expect(screen.getByText("inst-1")).toBeTruthy();
114+
expect(screen.getByText("$2.00")).toBeTruthy();
115+
});
116+
});

apps/loopover-ui/src/routes/app.operator.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type OperatorDashboardResponse = {
3434
metrics: Array<{ id: string; label: string; value: number; detail: string }>;
3535
};
3636
upstreamDrift?: { status?: string } | null;
37+
aiCostByTenant?: Array<{ installationId: string; totalCostUsd: number }>;
3738
};
3839

3940
type FleetMetrics = {
@@ -51,6 +52,7 @@ type FleetMetrics = {
5152
};
5253

5354
const formatPct = (v: number | null): string => (v === null ? "—" : `${Math.round(v * 100)}%`);
55+
const usdFmt = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
5456
const formatMs = (v: number | null): string =>
5557
v === null
5658
? "—"
@@ -482,6 +484,30 @@ export function OperatorDashboard() {
482484
) : null}
483485
</div>
484486
</section>
487+
{data.aiCostByTenant && data.aiCostByTenant.length > 0 ? (
488+
<section className="rounded-token border border-border bg-transparent p-5">
489+
<h2 className="font-display text-token-lg font-semibold">AI cost by tenant</h2>
490+
<p className="mt-1 text-token-xs text-muted-foreground">
491+
Hosted-deployment AI spend, highest-cost tenant first. Empty for self-host — this
492+
only populates once an installation carries hosted AI-usage records.
493+
</p>
494+
<ul className="mt-4 space-y-2">
495+
{data.aiCostByTenant.map((tenant) => (
496+
<li
497+
key={tenant.installationId}
498+
className="flex items-center justify-between gap-4 border-b-hairline pb-2 last:border-b-0 last:pb-0"
499+
>
500+
<span className="font-mono text-token-xs text-foreground/90">
501+
{tenant.installationId}
502+
</span>
503+
<span className="font-mono text-token-sm text-foreground">
504+
{usdFmt.format(tenant.totalCostUsd)}
505+
</span>
506+
</li>
507+
))}
508+
</ul>
509+
</section>
510+
) : null}
485511
<DeadLetterQueuePanel />
486512
<NotificationReadinessCard />
487513
</div>

src/db/repositories.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { parsePullRequestTargetKey } from "@loopover/engine";
2-
import { and, asc, desc, eq, gte, inArray, not, or, sql, type SQL } from "drizzle-orm";
2+
import { and, asc, desc, eq, gte, inArray, isNotNull, not, or, sql, type SQL } from "drizzle-orm";
33
import { getDb } from "./client";
44
import {
55
activeReviewTracking,
@@ -3621,6 +3621,27 @@ export async function sumAiCostForTenantSince(env: Env, installationId: string,
36213621
return Number(row?.total ?? 0);
36223622
}
36233623

3624+
export type AiCostByTenant = { installationId: string; totalCostUsd: number };
3625+
3626+
/** #4916: fleet-wide, per-tenant AI cost breakdown for the operator dashboard — "who is costing what", not just
3627+
* one tenant's own total (sumAiCostForTenantSince above). One GROUP BY query rather than N per-tenant calls.
3628+
* Self-host rows (installation_id IS NULL) are excluded by construction, matching sumAiCostForTenantSince's own
3629+
* hosted-only scope; the ai_usage_events_installation_created_idx index this shares with that function covers
3630+
* both the equality/range lookup there and the grouped scan here. Ordered highest-cost-first so the dashboard
3631+
* never needs its own client-side sort. */
3632+
export async function listAiCostByTenantSince(env: Env, sinceIso: string): Promise<AiCostByTenant[]> {
3633+
const db = getDb(env.DB);
3634+
const rows = await db
3635+
.select({ installationId: aiUsageEvents.installationId, total: sql<number>`coalesce(sum(${aiUsageEvents.costUsd}), 0)` })
3636+
.from(aiUsageEvents)
3637+
.where(and(isNotNull(aiUsageEvents.installationId), gte(aiUsageEvents.createdAt, sinceIso)))
3638+
.groupBy(aiUsageEvents.installationId)
3639+
.orderBy(desc(sql`coalesce(sum(${aiUsageEvents.costUsd}), 0)`));
3640+
/* v8 ignore next -- installationId is the GROUP BY key under an isNotNull filter; D1 cannot return a null
3641+
* group here, so the fallback only guards the driver's own typing, not a real runtime path. */
3642+
return rows.map((row) => ({ installationId: row.installationId ?? "", totalCostUsd: Number(row.total) }));
3643+
}
3644+
36243645
/** Spend-attempt statuses `countByokAiEventsForRepoSince`/`sumByokAiUsageForRepoSince` count: a real request
36253646
* reached the provider, whether or not it returned something usable ("ok") or genuinely failed ("error" --
36263647
* timeout/http_error/exception, see e.g. queue/processors.ts's recordVisualVisionUsage). Deliberately an

src/services/operator-dashboard.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getCommandUsefulnessSummary,
55
getLatestScoringModelSnapshot,
66
getProductUsageRollupStatus,
7+
listAiCostByTenantSince,
78
listAllPullRequests,
89
listInstallationHealth,
910
listInstallations,
@@ -12,6 +13,7 @@ import {
1213
listRepositories,
1314
summarizeMcpCompatibilityAdoption,
1415
summarizeProductUsageEvents,
16+
type AiCostByTenant,
1517
} from "../db/repositories";
1618
import { getLatestRegistrySnapshot } from "../registry/sync";
1719
import type {
@@ -90,6 +92,10 @@ export type OperatorDashboardPayload = {
9092
// Finding acceptance rate (#1967): share of gate-flagged (hold|close) PRs later merged, reshaped to the
9193
// AcceptanceRateCard's field names. Fails safe to an empty aggregate (rate: null) on any read error.
9294
acceptance: OperatorDashboardFindingAcceptance;
95+
// #4916: per-tenant AI cost breakdown for hosted deployments, highest-cost-first. Always [] for a self-host
96+
// operator (no installation-scoped ai_usage_events rows exist there) -- this surfaces the #7176/#7183 ledger
97+
// data that had no dashboard consumer until now.
98+
aiCostByTenant: AiCostByTenant[];
9399
};
94100

95101
const USAGE_WINDOW_DAYS = 7;
@@ -126,6 +132,7 @@ export async function buildOperatorDashboardPayload(
126132
agentHealth,
127133
slopCalibration,
128134
findingAcceptance,
135+
aiCostByTenant,
129136
] = await Promise.all([
130137
listRepositories(env),
131138
listInstallations(env),
@@ -153,6 +160,8 @@ export async function buildOperatorDashboardPayload(
153160
// #1967: reuse the existing finding-acceptance aggregate (no new compute); fails safe to an empty
154161
// aggregate on any read error.
155162
computeFindingAcceptance(env, { days: GATE_ANALYTICS_WINDOW_DAYS, nowMs: Date.now() }),
163+
// #4916: per-tenant AI cost breakdown, same window as the rest of the usage metrics above.
164+
listAiCostByTenantSince(env, usageSince),
156165
]);
157166
const weeklyValueReport = buildWeeklyValueReport({
158167
generatedAt: nowIso(),
@@ -271,6 +280,7 @@ export async function buildOperatorDashboardPayload(
271280
agentHealth,
272281
slopCalibration,
273282
acceptance,
283+
aiCostByTenant,
274284
};
275285
}
276286

test/unit/ai-usage-tenant.test.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { recordAiUsageEvent, sumAiCostForTenantSince } from "../../src/db/repositories";
2+
import { listAiCostByTenantSince, recordAiUsageEvent, sumAiCostForTenantSince } from "../../src/db/repositories";
33
import { createTestEnv } from "../helpers/d1";
44

55
// #7176: ai_usage_events gained a nullable installation_id tenant column for centralized hosted billing, plus a
@@ -18,6 +18,14 @@ async function installationOf(env: Env, id: string): Promise<string | null> {
1818
return row?.installation_id ?? null;
1919
}
2020

21+
/** Insert one ai_usage_events row and backdate it, so time-window filters (sinceIso) are actually exercised
22+
* instead of every row landing at "now". Shared by the per-tenant sum tests and the fleet-wide breakdown tests
23+
* below, which seed the identical fixture shape. */
24+
async function seedCostEvent(env: Env, installationId: string | null, costUsd: number, createdAt: string): Promise<void> {
25+
await recordAiUsageEvent(env, { ...base, costUsd, installationId });
26+
await env.DB.prepare("UPDATE ai_usage_events SET created_at = ? WHERE created_at = (SELECT max(created_at) FROM ai_usage_events)").bind(createdAt).run();
27+
}
28+
2129
describe("ai_usage_events tenant column + billing aggregate (#7176)", () => {
2230
it("writes installation_id when a hosted caller supplies it", async () => {
2331
const env = createTestEnv();
@@ -42,20 +50,51 @@ describe("ai_usage_events tenant column + billing aggregate (#7176)", () => {
4250
const env = createTestEnv();
4351
// Two events for inst-1 after the window, one before it, one for inst-2, one self-host (null tenant).
4452
const since = "2026-07-10T00:00:00.000Z";
45-
const seed = async (installationId: string | null, costUsd: number, createdAt: string) => {
46-
await recordAiUsageEvent(env, { ...base, costUsd, installationId });
47-
// Backdate the just-inserted row (recordAiUsageEvent stamps nowIso()), so the window filter is exercised.
48-
await env.DB.prepare("UPDATE ai_usage_events SET created_at = ? WHERE created_at = (SELECT max(created_at) FROM ai_usage_events)").bind(createdAt).run();
49-
};
50-
await seed("inst-1", 1.25, "2026-07-11T00:00:00.000Z");
51-
await seed("inst-1", 0.75, "2026-07-12T00:00:00.000Z");
52-
await seed("inst-1", 9.0, "2026-07-01T00:00:00.000Z"); // before the window
53-
await seed("inst-2", 4.0, "2026-07-13T00:00:00.000Z"); // different tenant
54-
await seed(null, 3.0, "2026-07-14T00:00:00.000Z"); // self-host, null tenant
53+
await seedCostEvent(env, "inst-1", 1.25, "2026-07-11T00:00:00.000Z");
54+
await seedCostEvent(env, "inst-1", 0.75, "2026-07-12T00:00:00.000Z");
55+
await seedCostEvent(env, "inst-1", 9.0, "2026-07-01T00:00:00.000Z"); // before the window
56+
await seedCostEvent(env, "inst-2", 4.0, "2026-07-13T00:00:00.000Z"); // different tenant
57+
await seedCostEvent(env, null, 3.0, "2026-07-14T00:00:00.000Z"); // self-host, null tenant
5558

5659
expect(await sumAiCostForTenantSince(env, "inst-1", since)).toBeCloseTo(2.0, 5);
5760
expect(await sumAiCostForTenantSince(env, "inst-2", since)).toBeCloseTo(4.0, 5);
5861
// A tenant with no rows sums to 0, not an error.
5962
expect(await sumAiCostForTenantSince(env, "inst-none", since)).toBe(0);
6063
});
6164
});
65+
66+
describe("listAiCostByTenantSince (#4916): fleet-wide per-tenant breakdown for the operator dashboard", () => {
67+
it("groups by tenant, sums correctly, and orders highest-cost-first", async () => {
68+
const env = createTestEnv();
69+
const since = "2026-07-10T00:00:00.000Z";
70+
await seedCostEvent(env, "inst-1", 1.25, "2026-07-11T00:00:00.000Z");
71+
await seedCostEvent(env, "inst-1", 0.75, "2026-07-12T00:00:00.000Z"); // inst-1 total: 2.0
72+
await seedCostEvent(env, "inst-2", 4.0, "2026-07-13T00:00:00.000Z"); // inst-2 total: 4.0 (highest)
73+
await seedCostEvent(env, "inst-3", 0.5, "2026-07-13T00:00:00.000Z"); // inst-3 total: 0.5 (lowest)
74+
75+
const rows = await listAiCostByTenantSince(env, since);
76+
77+
expect(rows).toEqual([
78+
{ installationId: "inst-2", totalCostUsd: 4.0 },
79+
{ installationId: "inst-1", totalCostUsd: 2.0 },
80+
{ installationId: "inst-3", totalCostUsd: 0.5 },
81+
]);
82+
});
83+
84+
it("excludes self-host rows (null installation_id) and rows outside the time window", async () => {
85+
const env = createTestEnv();
86+
const since = "2026-07-10T00:00:00.000Z";
87+
await seedCostEvent(env, "inst-1", 2.0, "2026-07-11T00:00:00.000Z"); // in window
88+
await seedCostEvent(env, "inst-1", 9.0, "2026-07-01T00:00:00.000Z"); // before the window
89+
await seedCostEvent(env, null, 5.0, "2026-07-11T00:00:00.000Z"); // self-host, must never appear
90+
91+
const rows = await listAiCostByTenantSince(env, since);
92+
93+
expect(rows).toEqual([{ installationId: "inst-1", totalCostUsd: 2.0 }]);
94+
});
95+
96+
it("returns an empty list, not an error, when there are no hosted rows at all (the self-host default)", async () => {
97+
const env = createTestEnv();
98+
expect(await listAiCostByTenantSince(env, "2026-07-10T00:00:00.000Z")).toEqual([]);
99+
});
100+
});

0 commit comments

Comments
 (0)