diff --git a/messages/de.json b/messages/de.json index 3ddbf9f68..60d3a4dfa 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." diff --git a/messages/en.json b/messages/en.json index 22c59c452..6e22ad25f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." diff --git a/messages/es.json b/messages/es.json index 6bae1f78f..93c709eab 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." diff --git a/messages/fr.json b/messages/fr.json index ebec3595d..b477b0f18 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." diff --git a/messages/it.json b/messages/it.json index 992c4b835..172e52d8a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." diff --git a/messages/ko.json b/messages/ko.json index a813b4978..02b93b9f5 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7957,7 +7957,7 @@ "typeCodex": "ChatGPT 로그인 (개인)", "typeLocal": "로컬 모델 (개인)", "typeGateway": "OpenAI 호환 게이트웨이 (개인)", - "spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다.", + "spendToday": "오늘(각 계정의 현지 날짜 기준): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다(사용량이 있는 계정: {accounts}개).", "statusPaused": "일시 중지됨", "pausedHint": "반복된 오류로 사용자 {count}명에 대해 {at}까지 일시 중지되었습니다. 그 후 자동으로 다시 시도합니다.", "modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요." diff --git a/messages/pl.json b/messages/pl.json index 9950e6411..76f37282e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." 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 6735c919b..fa9f4259e 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -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); }); diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index bfe9eeffd..8d35cada5 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -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, }, }); }); diff --git a/src/components/admin/__tests__/provider-health-spend-today.test.tsx b/src/components/admin/__tests__/provider-health-spend-today.test.tsx index bfe056560..5b334023d 100644 --- a/src/components/admin/__tests__/provider-health-spend-today.test.tsx +++ b/src/components/admin/__tests__/provider-health-spend-today.test.tsx @@ -42,24 +42,26 @@ function render(data: object | null) { } describe(" — 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"'); }); diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index c0e0c066e..c54a88b65 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -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 ? (

{t("admin.providerHealth.spendToday", { - date: spendToday.dateKey, + accounts: groupDigits(spendToday.accounts), total: groupDigits(spendToday.totalTokens), operator: groupDigits(spendToday.operatorTokens), })} diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index aea8edaa7..c4511b2c8 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -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"] }, + }, + }), + ); + }); +}); diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 2849190b2..25dba016a 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -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 { + 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(); + 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.