mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(admin): sum today's AI spend on each account's own local day
This commit is contained in:
1 parent
5dbc6fcec1
commit
3c6150e837
13 files changed
+187
-51
No files matched your search
+1
-1
@@ -7239,7 +7239,7 @@
|
||||
"typeCodex": "ChatGPT-Anmeldung (persönlich)",
|
||||
"typeLocal": "Lokales Modell (persönlich)",
|
||||
"typeGateway": "OpenAI-kompatibles Gateway (persönlich)",
|
||||
"spendToday": "Heute ({date}): {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers.",
|
||||
"spendToday": "Heute, jeweils am eigenen Kalendertag jedes Kontos: {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers (Konten mit Verbrauch: {accounts}).",
|
||||
"statusPaused": "Pausiert",
|
||||
"pausedHint": "Nach wiederholten Fehlern für {count} Nutzer pausiert bis {at}. Danach wird er automatisch wieder versucht.",
|
||||
"modelNotServed": "Der Endpunkt bietet das Modell {model} nicht mehr an. Wähle in den KI-Einstellungen ein Modell, das er anbietet."
|
||||
|
||||
+1
-1
@@ -7239,7 +7239,7 @@
|
||||
"typeCodex": "ChatGPT sign-in (personal)",
|
||||
"typeLocal": "Local model (personal)",
|
||||
"typeGateway": "OpenAI-compatible gateway (personal)",
|
||||
"spendToday": "Today ({date}): {total} tokens, {operator} of them on the operator's own key.",
|
||||
"spendToday": "Today, on each account's own calendar day: {total} tokens, {operator} of them on the operator's own key (accounts with spend: {accounts}).",
|
||||
"statusPaused": "Paused",
|
||||
"pausedHint": "Paused after repeated failures for {count} users until {at}. It is tried again automatically then.",
|
||||
"modelNotServed": "The endpoint no longer offers the model {model}. Choose a model it lists in the AI settings."
|
||||
|
||||
+1
-1
@@ -7239,7 +7239,7 @@
|
||||
"typeCodex": "Inicio de sesión de ChatGPT (personal)",
|
||||
"typeLocal": "Modelo local (personal)",
|
||||
"typeGateway": "Pasarela compatible con OpenAI (personal)",
|
||||
"spendToday": "Hoy ({date}): {total} tokens, {operator} de ellos en la clave del operador.",
|
||||
"spendToday": "Hoy, en el día natural de cada cuenta: {total} tokens, {operator} de ellos en la clave del operador (cuentas con consumo: {accounts}).",
|
||||
"statusPaused": "En pausa",
|
||||
"pausedHint": "En pausa tras fallos repetidos para {count} usuarios hasta {at}. Después se vuelve a intentar automáticamente.",
|
||||
"modelNotServed": "El endpoint ya no ofrece el modelo {model}. Elige en los ajustes de IA un modelo que ofrezca."
|
||||
|
||||
+1
-1
@@ -7239,7 +7239,7 @@
|
||||
"typeCodex": "Connexion ChatGPT (personnelle)",
|
||||
"typeLocal": "Modèle local (personnel)",
|
||||
"typeGateway": "Passerelle compatible OpenAI (personnelle)",
|
||||
"spendToday": "Aujourd'hui ({date}) : {total} jetons, dont {operator} sur la clé de l'opérateur.",
|
||||
"spendToday": "Aujourd'hui, selon le jour civil de chaque compte : {total} jetons, dont {operator} sur la clé de l'opérateur (comptes avec consommation : {accounts}).",
|
||||
"statusPaused": "En pause",
|
||||
"pausedHint": "En pause après des échecs répétés pour {count} utilisateurs jusqu’au {at}. Il sera ensuite réessayé automatiquement.",
|
||||
"modelNotServed": "Le point d’accès ne propose plus le modèle {model}. Choisissez dans les réglages IA un modèle qu’il propose."
|
||||
|
||||
+1
-1
@@ -7239,7 +7239,7 @@
|
||||
"typeCodex": "Accesso ChatGPT (personale)",
|
||||
"typeLocal": "Modello locale (personale)",
|
||||
"typeGateway": "Gateway compatibile OpenAI (personale)",
|
||||
"spendToday": "Oggi ({date}): {total} token, di cui {operator} sulla chiave dell'operatore.",
|
||||
"spendToday": "Oggi, secondo il giorno di calendario di ciascun account: {total} token, di cui {operator} sulla chiave dell'operatore (account con consumo: {accounts}).",
|
||||
"statusPaused": "In pausa",
|
||||
"pausedHint": "In pausa dopo errori ripetuti per {count} utenti fino al {at}. Poi viene ritentato automaticamente.",
|
||||
"modelNotServed": "L’endpoint non offre più il modello {model}. Scegli nelle impostazioni IA un modello che offre."
|
||||
|
||||
+1
-1
@@ -7957,7 +7957,7 @@
|
||||
"typeCodex": "ChatGPT 로그인 (개인)",
|
||||
"typeLocal": "로컬 모델 (개인)",
|
||||
"typeGateway": "OpenAI 호환 게이트웨이 (개인)",
|
||||
"spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다.",
|
||||
"spendToday": "오늘(각 계정의 현지 날짜 기준): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다(사용량이 있는 계정: {accounts}개).",
|
||||
"statusPaused": "일시 중지됨",
|
||||
"pausedHint": "반복된 오류로 사용자 {count}명에 대해 {at}까지 일시 중지되었습니다. 그 후 자동으로 다시 시도합니다.",
|
||||
"modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요."
|
||||
|
||||
+1
-1
@@ -7239,7 +7239,7 @@
|
||||
"typeCodex": "Logowanie ChatGPT (osobiste)",
|
||||
"typeLocal": "Model lokalny (osobisty)",
|
||||
"typeGateway": "Brama zgodna z OpenAI (osobista)",
|
||||
"spendToday": "Dzisiaj ({date}): {total} tokenów, w tym {operator} na kluczu operatora.",
|
||||
"spendToday": "Dzisiaj, według dnia kalendarzowego każdego konta: {total} tokenów, w tym {operator} na kluczu operatora (konta ze zużyciem: {accounts}).",
|
||||
"statusPaused": "Wstrzymany",
|
||||
"pausedHint": "Wstrzymany po powtarzających się błędach dla {count} użytkowników do {at}. Potem zostanie automatycznie ponowiony.",
|
||||
"modelNotServed": "Punkt końcowy nie oferuje już modelu {model}. Wybierz w ustawieniach AI model, który oferuje."
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
coachUsage: {
|
||||
aggregate: vi.fn(),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
// v1.42 — the operator key's model check reads the admin settings.
|
||||
appSettings: { findUnique: vi.fn(async () => null) },
|
||||
@@ -59,7 +59,19 @@ 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);
|
||||
const usageRows = vi.mocked(prisma.coachUsage.findMany);
|
||||
|
||||
/** A spend row as the reader selects it. */
|
||||
function usage(
|
||||
dateKey: string,
|
||||
totalTokens: number,
|
||||
operatorTokens: number,
|
||||
timezone: string | null = "UTC",
|
||||
) {
|
||||
return { dateKey, totalTokens, operatorTokens, user: { timezone } };
|
||||
}
|
||||
|
||||
const utcToday = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
function group(
|
||||
providerType: string,
|
||||
@@ -91,9 +103,7 @@ describe("GET /api/admin/provider-health", () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue({
|
||||
user: { id: "admin1" },
|
||||
} as never);
|
||||
aggregate.mockResolvedValue({
|
||||
_sum: { totalTokens: 0, operatorTokens: 0 },
|
||||
} as never);
|
||||
usageRows.mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
it("is admin-gated before it touches the ledger", async () => {
|
||||
@@ -231,31 +241,44 @@ describe("GET /api/admin/provider-health", () => {
|
||||
*/
|
||||
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);
|
||||
usageRows.mockResolvedValue([
|
||||
usage(utcToday(), 1_000_000, 100_000),
|
||||
usage(utcToday(), 240_000, 51_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,
|
||||
accounts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts each account on its own today and nothing from another day", async () => {
|
||||
groupBy.mockResolvedValue([] as never);
|
||||
const yesterday = new Date(Date.now() - 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
usageRows.mockResolvedValue([
|
||||
usage(utcToday(), 500, 100),
|
||||
// The same UTC account's yesterday: already closed, not today's spend.
|
||||
usage(yesterday, 9_999, 9_999),
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.spendToday).toEqual({
|
||||
totalTokens: 500,
|
||||
operatorTokens: 100,
|
||||
accounts: 1,
|
||||
});
|
||||
// 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);
|
||||
usageRows.mockResolvedValue([] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
@@ -281,9 +304,7 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", ()
|
||||
vi.mocked(requireAdmin).mockResolvedValue({
|
||||
user: { id: "admin" },
|
||||
} as never);
|
||||
aggregate.mockResolvedValue({
|
||||
_sum: { totalTokens: 0, operatorTokens: 0 },
|
||||
} as never);
|
||||
usageRows.mockResolvedValue([] as never);
|
||||
findMany.mockResolvedValue([] as never);
|
||||
settings.mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { buildDateKey } from "@/lib/ai/coach/budget";
|
||||
import { readInstanceSpendToday } from "@/lib/ai/coach/budget";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -73,14 +73,9 @@ interface ProviderHealthSummary {
|
||||
const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES];
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAdmin();
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.provider-health.get" } });
|
||||
|
||||
// The ledger buckets each account by its own local day; the operator's
|
||||
// "today" is the day in the operator's own zone, which on a single-zone
|
||||
// instance is everyone's.
|
||||
const dateKey = await buildDateKey(user.id);
|
||||
|
||||
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
|
||||
@@ -177,17 +172,19 @@ export const GET = apiHandler(async () => {
|
||||
// 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 },
|
||||
});
|
||||
//
|
||||
// "Today" is each account's own calendar day, the day its ceilings are
|
||||
// enforced on (`readInstanceSpendToday`), not one date for the instance:
|
||||
// the budget day is per account local day, so a single date key would
|
||||
// count some accounts' yesterday or tomorrow and miss part of their today.
|
||||
const spend = await readInstanceSpendToday();
|
||||
|
||||
return apiSuccess({
|
||||
providers,
|
||||
spendToday: {
|
||||
dateKey,
|
||||
totalTokens: spend._sum.totalTokens ?? 0,
|
||||
operatorTokens: spend._sum.operatorTokens ?? 0,
|
||||
totalTokens: spend.total,
|
||||
operatorTokens: spend.operator,
|
||||
accounts: spend.accounts,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,24 +42,26 @@ function render(data: object | null) {
|
||||
}
|
||||
|
||||
describe("<ProviderHealthSection> — today's spend by cost owner", () => {
|
||||
it("shows both figures and the day they belong to", () => {
|
||||
it("shows both figures, the account count, and which day they count", () => {
|
||||
const html = render({
|
||||
providers: [],
|
||||
spendToday: {
|
||||
dateKey: "2026-09-11",
|
||||
totalTokens: 1_240_000,
|
||||
operatorTokens: 151_200,
|
||||
accounts: 3,
|
||||
},
|
||||
});
|
||||
expect(html).toContain("2026-09-11");
|
||||
expect(html).toContain("1,240,000");
|
||||
expect(html).toContain("151,200");
|
||||
expect(html).toContain("accounts with spend: 3");
|
||||
// Each account's own day, not one date for the whole instance.
|
||||
expect(html).toContain("each account's own calendar day");
|
||||
});
|
||||
|
||||
it("renders a zero day rather than hiding the readout", () => {
|
||||
const html = render({
|
||||
providers: [],
|
||||
spendToday: { dateKey: "2026-09-11", totalTokens: 0, operatorTokens: 0 },
|
||||
spendToday: { totalTokens: 0, operatorTokens: 0, accounts: 0 },
|
||||
});
|
||||
expect(html).toContain('data-slot="ai-spend-today"');
|
||||
});
|
||||
|
||||
@@ -34,10 +34,11 @@ import { formatDateTime } from "@/lib/format";
|
||||
* the users' own plans. The operator ceiling is enforced against the second
|
||||
* figure, so both belong on screen.
|
||||
*/
|
||||
/** Summed over every account's own current day, the day its ceilings use. */
|
||||
interface SpendToday {
|
||||
dateKey: string;
|
||||
totalTokens: number;
|
||||
operatorTokens: number;
|
||||
accounts: number;
|
||||
}
|
||||
|
||||
interface ProviderHealthRow {
|
||||
@@ -107,7 +108,7 @@ export function ProviderHealthSection() {
|
||||
{spendToday ? (
|
||||
<p className="text-muted-foreground text-sm" data-slot="ai-spend-today">
|
||||
{t("admin.providerHealth.spendToday", {
|
||||
date: spendToday.dateKey,
|
||||
accounts: groupDigits(spendToday.accounts),
|
||||
total: groupDigits(spendToday.totalTokens),
|
||||
operator: groupDigits(spendToday.operatorTokens),
|
||||
})}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
OPERATOR_COST_CAP,
|
||||
USER_PLAN_CAP,
|
||||
INTERACTIVE_RESERVE_SHARE,
|
||||
readInstanceSpendToday,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
resolveDailyCapFor,
|
||||
@@ -15,6 +16,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
coachUsage: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
$executeRaw: vi.fn(),
|
||||
@@ -31,7 +33,10 @@ const { zoneOf } = vi.hoisted(() => ({
|
||||
: "UTC",
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: zoneOf }));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: zoneOf,
|
||||
resolveServerDefaultTimezone: async () => "Europe/Berlin",
|
||||
}));
|
||||
|
||||
/**
|
||||
* The budget day turns at the account's own midnight, the boundary the daily
|
||||
@@ -616,3 +621,51 @@ describe("OPERATOR_COST_CAP (F2 — reasoning-aware operator cap)", () => {
|
||||
expect(USER_PLAN_CAP).toBeGreaterThan(OPERATOR_COST_CAP);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The operator's "today" is every account's own today. One date key for the
|
||||
* whole instance counted some accounts' yesterday or tomorrow once the budget
|
||||
* day turned local.
|
||||
*/
|
||||
describe("readInstanceSpendToday", () => {
|
||||
it("sums each account's row for its own current day and nothing else", async () => {
|
||||
const { prisma } = await import("@/lib/db");
|
||||
// 22:30 UTC on May 10: Berlin is on May 11, New York and UTC on May 10.
|
||||
const at = new Date("2026-05-10T22:30:00.000Z");
|
||||
const row = (
|
||||
dateKey: string,
|
||||
total: number,
|
||||
operator: number,
|
||||
timezone: string | null,
|
||||
) => ({
|
||||
dateKey,
|
||||
totalTokens: total,
|
||||
operatorTokens: operator,
|
||||
user: { timezone },
|
||||
});
|
||||
vi.mocked(prisma.coachUsage.findMany).mockResolvedValue([
|
||||
row("2026-05-11", 100, 10, "Europe/Berlin"), // Berlin today
|
||||
row("2026-05-10", 1_000, 1_000, "Europe/Berlin"), // Berlin yesterday
|
||||
row("2026-05-10", 200, 20, "America/New_York"), // New York today
|
||||
row("2026-05-11", 7_000, 7_000, "America/New_York"), // not yet begun
|
||||
row("2026-05-10", 300, 30, "UTC"), // UTC today
|
||||
// No usable zone: the server default (Berlin here), so May 11.
|
||||
row("2026-05-11", 40, 4, null),
|
||||
row("2026-05-11", 50, 5, "+05:30"),
|
||||
] as never);
|
||||
|
||||
await expect(readInstanceSpendToday(at)).resolves.toEqual({
|
||||
total: 100 + 200 + 300 + 40 + 50,
|
||||
operator: 10 + 20 + 30 + 4 + 5,
|
||||
accounts: 5,
|
||||
});
|
||||
// Only the three keys any zone can be on right now are read.
|
||||
expect(prisma.coachUsage.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
dateKey: { in: ["2026-05-09", "2026-05-10", "2026-05-11"] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,11 @@
|
||||
* every other per-day surface.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { isValidTimezone, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
resolveServerDefaultTimezone,
|
||||
resolveUserTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
||||
|
||||
/**
|
||||
@@ -229,6 +232,65 @@ export async function readDailySpend(
|
||||
};
|
||||
}
|
||||
|
||||
/** The day's spend across every account, for the operator's readout. */
|
||||
export interface InstanceDailySpend extends DailySpend {
|
||||
/** Accounts with a spend row on their own current day. */
|
||||
accounts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every account's spend on that account's own current day, summed.
|
||||
*
|
||||
* Since the budget day turned local, one date key no longer names "today"
|
||||
* for everybody: at 23:30 in Berlin a row keyed with the Berlin date belongs
|
||||
* to a Berlin account's today, while an account in New York is still on the
|
||||
* day before. Each ceiling is enforced on the account's own day, so the
|
||||
* operator's figure is the sum of exactly those rows, one per account: the
|
||||
* spend that counts against the ceilings right now.
|
||||
*
|
||||
* Every zone's today lies within one day of the UTC date, so the read
|
||||
* fetches those three keys only and keeps the rows whose key is their
|
||||
* account's current day, resolved the way `buildDateKey` resolves it (the
|
||||
* account's zone when valid, otherwise the server default).
|
||||
*/
|
||||
export async function readInstanceSpendToday(
|
||||
at: Date = new Date(),
|
||||
): Promise<InstanceDailySpend> {
|
||||
const utcKey = userDayKey(at, "UTC");
|
||||
const rows = await prisma.coachUsage.findMany({
|
||||
where: {
|
||||
dateKey: {
|
||||
in: [shiftDateKey(utcKey, -1), utcKey, shiftDateKey(utcKey, 1)],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
dateKey: true,
|
||||
totalTokens: true,
|
||||
operatorTokens: true,
|
||||
user: { select: { timezone: true } },
|
||||
},
|
||||
});
|
||||
const serverTz = await resolveServerDefaultTimezone();
|
||||
const todayByZone = new Map<string, string>();
|
||||
const spend: InstanceDailySpend = { total: 0, operator: 0, accounts: 0 };
|
||||
for (const row of rows) {
|
||||
const zone =
|
||||
row.user.timezone && isValidTimezone(row.user.timezone)
|
||||
? row.user.timezone
|
||||
: serverTz;
|
||||
let today = todayByZone.get(zone);
|
||||
if (today === undefined) {
|
||||
today = userDayKey(at, zone);
|
||||
todayByZone.set(zone, today);
|
||||
}
|
||||
if (row.dateKey !== today) continue;
|
||||
spend.total += row.totalTokens;
|
||||
spend.operator += row.operatorTokens;
|
||||
spend.accounts += 1;
|
||||
}
|
||||
return spend;
|
||||
}
|
||||
|
||||
/**
|
||||
* The budget day of one account at `at` (default now): the calendar day in
|
||||
* the account's own time zone.
|
||||
|
||||
Reference in new issue
Block a user