diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts index 03e0fb8de..f0f819c3f 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -18,6 +18,9 @@ vi.mock("@/lib/db", () => ({ providerHealth: { groupBy: vi.fn(), }, + coachUsage: { + aggregate: vi.fn(), + }, }, })); @@ -45,6 +48,7 @@ import { prisma } from "@/lib/db"; import { requireAdmin } from "@/lib/api-handler"; const groupBy = vi.mocked(prisma.providerHealth.groupBy); +const aggregate = vi.mocked(prisma.coachUsage.aggregate); function group( providerType: string, @@ -76,6 +80,9 @@ describe("GET /api/admin/provider-health", () => { vi.mocked(requireAdmin).mockResolvedValue({ user: { id: "admin1" }, } as never); + aggregate.mockResolvedValue({ + _sum: { totalTokens: 0, operatorTokens: 0 }, + } as never); }); it("is admin-gated before it touches the ledger", async () => { @@ -199,6 +206,49 @@ describe("GET /api/admin/provider-health", () => { ).toEqual(["admin-openai", "admin-codex", "anthropic", "local"]); }); + /** + * v1.38.19 (Wave E) — the two spend figures side by side. + * + * The operator reads this card when the chat refuses. Until now it showed + * delivery health only, and the day's token figure was one number that mixed + * his own ChatGPT plan with the instance's key — so a refusal at 513 539 + * tokens looked like his own doing. The split is what tells him which + * ceiling he hit. + */ + it("reports the day's spend split by who paid for it", async () => { + groupBy.mockResolvedValue([] as never); + aggregate.mockResolvedValue({ + _sum: { totalTokens: 1_240_000, operatorTokens: 151_200 }, + } as never); + + const res = await GET(); + const body = await res.json(); + + expect(body.data.spendToday).toEqual({ + dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + totalTokens: 1_240_000, + operatorTokens: 151_200, + }); + // Today only, across every account — the operator's ceiling is per day. + expect(aggregate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }, + }), + ); + }); + + it("reports a day with no spend as zero, not null", async () => { + groupBy.mockResolvedValue([] as never); + aggregate.mockResolvedValue({ + _sum: { totalTokens: null, operatorTokens: null }, + } as never); + + const res = await GET(); + const body = await res.json(); + expect(body.data.spendToday.totalTokens).toBe(0); + expect(body.data.spendToday.operatorTokens).toBe(0); + }); + it("answers an empty ledger with an empty list, not an error", async () => { groupBy.mockResolvedValue([] as never); diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index c0163dece..0e8b42e13 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -1,4 +1,5 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { buildDateKey } from "@/lib/ai/coach/budget"; import { prisma } from "@/lib/db"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -49,6 +50,9 @@ export const GET = apiHandler(async () => { await requireAdmin(); annotate({ action: { name: "admin.provider-health.get" } }); + // The UTC day the ledger buckets by — the same key every reservation writes. + const dateKey = buildDateKey(); + const groups = await prisma.providerHealth.groupBy({ // `lastStatus` joins the grouping key rather than an aggregate: the // MAX of two HTTP statuses is not a status, it is arithmetic on a @@ -107,5 +111,24 @@ export const GET = apiHandler(async () => { return a.providerType.localeCompare(b.providerType); }); - return apiSuccess({ providers }); + // v1.38.19 (Wave E) — the day's spend, split by who pays for it. + // + // The operator opens this card when a surface refuses on budget. One mixed + // number could not answer the question it raised: a day at 1.24 M tokens is + // alarming until you see that 151 200 of them were the instance's own key + // and the rest ran on the users' plans. The operator ceiling is enforced + // against the second figure, so both belong on screen. + const spend = await prisma.coachUsage.aggregate({ + where: { dateKey }, + _sum: { totalTokens: true, operatorTokens: true }, + }); + + return apiSuccess({ + providers, + spendToday: { + dateKey, + totalTokens: spend._sum.totalTokens ?? 0, + operatorTokens: spend._sum.operatorTokens ?? 0, + }, + }); });