From c66bcbc2958ecde3028b864f30671860a23fd366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 11:11:17 +0200 Subject: [PATCH] feat(admin): the AI card shows the day's spend split by who paid The provider-health route answered a spendToday payload that nothing rendered, which is the pattern the two-ended rule exists to stop. It is also the screen the operator opens when a surface refuses on budget, and the one mixed number could not answer the question that raises: a day at 1.24 million tokens is alarming until the card says that 151 200 of them were the instance's own key and the rest ran on the users' own plans. The operator ceiling is enforced against the second figure. Copy lands in all seven locales. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- .../provider-health-spend-today.test.tsx | 72 +++++++++++++++++++ .../admin/provider-health-section.tsx | 34 ++++++++- 9 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 src/components/admin/__tests__/provider-health-spend-today.test.tsx diff --git a/messages/de.json b/messages/de.json index bc23d2f08..75230309a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6632,7 +6632,8 @@ "typeAnthropic": "Anthropic-Schlüssel (persönlich)", "typeCodex": "ChatGPT-Anmeldung (persönlich)", "typeLocal": "Lokales Modell (persönlich)", - "typeGateway": "OpenAI-kompatibles Gateway (persönlich)" + "typeGateway": "OpenAI-kompatibles Gateway (persönlich)", + "spendToday": "Heute ({date}): {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers." }, "aiQuality": { "title": "Auswertungs-Qualität", diff --git a/messages/en.json b/messages/en.json index dc66aa61b..fd51ac17b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6632,7 +6632,8 @@ "typeAnthropic": "Anthropic key (personal)", "typeCodex": "ChatGPT sign-in (personal)", "typeLocal": "Local model (personal)", - "typeGateway": "OpenAI-compatible gateway (personal)" + "typeGateway": "OpenAI-compatible gateway (personal)", + "spendToday": "Today ({date}): {total} tokens, {operator} of them on the operator's own key." }, "aiQuality": { "title": "Insights Quality", diff --git a/messages/es.json b/messages/es.json index e5fddd66f..93979c8b1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6632,7 +6632,8 @@ "typeAnthropic": "Clave de Anthropic (personal)", "typeCodex": "Inicio de sesión de ChatGPT (personal)", "typeLocal": "Modelo local (personal)", - "typeGateway": "Pasarela compatible con OpenAI (personal)" + "typeGateway": "Pasarela compatible con OpenAI (personal)", + "spendToday": "Hoy ({date}): {total} tokens, {operator} de ellos en la clave del operador." }, "aiQuality": { "title": "Calidad de los análisis", diff --git a/messages/fr.json b/messages/fr.json index 1640aa7e9..35b152aa8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6632,7 +6632,8 @@ "typeAnthropic": "Clé Anthropic (personnelle)", "typeCodex": "Connexion ChatGPT (personnelle)", "typeLocal": "Modèle local (personnel)", - "typeGateway": "Passerelle compatible OpenAI (personnelle)" + "typeGateway": "Passerelle compatible OpenAI (personnelle)", + "spendToday": "Aujourd'hui ({date}) : {total} jetons, dont {operator} sur la clé de l'opérateur." }, "aiQuality": { "title": "Qualité des analyses", diff --git a/messages/it.json b/messages/it.json index d965b3827..3cab23814 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6632,7 +6632,8 @@ "typeAnthropic": "Chiave Anthropic (personale)", "typeCodex": "Accesso ChatGPT (personale)", "typeLocal": "Modello locale (personale)", - "typeGateway": "Gateway compatibile OpenAI (personale)" + "typeGateway": "Gateway compatibile OpenAI (personale)", + "spendToday": "Oggi ({date}): {total} token, di cui {operator} sulla chiave dell'operatore." }, "aiQuality": { "title": "Qualità delle analisi", diff --git a/messages/ko.json b/messages/ko.json index 4dabc847b..6f5bb9ec2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7239,7 +7239,8 @@ "typeAnthropic": "Anthropic 키 (개인)", "typeCodex": "ChatGPT 로그인 (개인)", "typeLocal": "로컬 모델 (개인)", - "typeGateway": "OpenAI 호환 게이트웨이 (개인)" + "typeGateway": "OpenAI 호환 게이트웨이 (개인)", + "spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다." } }, "achievements": { diff --git a/messages/pl.json b/messages/pl.json index 9769c0838..a562fe7be 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6632,7 +6632,8 @@ "typeAnthropic": "Klucz Anthropic (osobisty)", "typeCodex": "Logowanie ChatGPT (osobiste)", "typeLocal": "Model lokalny (osobisty)", - "typeGateway": "Brama zgodna z OpenAI (osobista)" + "typeGateway": "Brama zgodna z OpenAI (osobista)", + "spendToday": "Dzisiaj ({date}): {total} tokenów, w tym {operator} na kluczu operatora." }, "aiQuality": { "title": "Jakość analiz", diff --git a/src/components/admin/__tests__/provider-health-spend-today.test.tsx b/src/components/admin/__tests__/provider-health-spend-today.test.tsx new file mode 100644 index 000000000..1f92bdc03 --- /dev/null +++ b/src/components/admin/__tests__/provider-health-spend-today.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.38.19 (Wave E) — the day's spend, split by who paid for it. + * + * `/api/admin/provider-health` answers `spendToday` and nothing rendered it, + * which is the "the reader is the follow-up" shape the two-ended rule exists to + * stop. It is also the one screen that answers the question a budget refusal + * raises: a day at 1.24 M tokens is alarming until you can see that 151 200 of + * them were the instance's own key and the rest ran on the users' own plans — + * and it is the second figure the operator ceiling is enforced against. + */ + +const mockQueryState = vi.hoisted(() => ({ + data: null as null | object, + isPending: false, + isError: false, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: mockQueryState.data, + isPending: mockQueryState.isPending, + isError: mockQueryState.isError, + }), +})); + +import { ProviderHealthSection } from "../provider-health-section"; + +function render(data: object | null) { + mockQueryState.data = data; + mockQueryState.isPending = false; + mockQueryState.isError = false; + return renderToStaticMarkup( + + + , + ); +} + +describe(" — today's spend by cost owner", () => { + it("shows both figures and the day they belong to", () => { + const html = render({ + providers: [], + spendToday: { + dateKey: "2026-09-11", + totalTokens: 1_240_000, + operatorTokens: 151_200, + }, + }); + expect(html).toContain("2026-09-11"); + expect(html).toContain("1,240,000"); + expect(html).toContain("151,200"); + }); + + it("renders a zero day rather than hiding the readout", () => { + const html = render({ + providers: [], + spendToday: { dateKey: "2026-09-11", totalTokens: 0, operatorTokens: 0 }, + }); + expect(html).toContain('data-slot="ai-spend-today"'); + }); + + it("says nothing when the payload carries no spend", () => { + expect(render({ providers: [] })).not.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 d4568c20f..fecc95556 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -26,6 +26,20 @@ import { formatDateTime } from "@/lib/format"; * failure, which never had a status to record. */ +/** + * 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, and one mixed number could + * not answer the question that raises: 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' own plans. The operator ceiling is enforced against the second + * figure, so both belong on screen. + */ +interface SpendToday { + dateKey: string; + totalTokens: number; + operatorTokens: number; +} + interface ProviderHealthRow { providerType: string; tracked: number; @@ -37,12 +51,18 @@ interface ProviderHealthRow { } export function ProviderHealthSection() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); + // Seven-digit token counts are the point of this readout; ungrouped digits + // are what made the mixed figure unreadable in the first place. + const groupDigits = (value: number): string => + new Intl.NumberFormat(locale).format(value); const { data, isPending, isError } = useQuery({ queryKey: queryKeys.adminProviderHealth(), queryFn: () => - apiGet<{ providers: ProviderHealthRow[] }>("/api/admin/provider-health"), + apiGet<{ providers: ProviderHealthRow[]; spendToday: SpendToday }>( + "/api/admin/provider-health", + ), }); const typeLabel = (type: string): string => { @@ -67,6 +87,7 @@ export function ProviderHealthSection() { }; const providers = data?.providers ?? []; + const spendToday = data?.spendToday ?? null; return ( @@ -75,6 +96,15 @@ export function ProviderHealthSection() { title={t("admin.providerHealth.title")} description={t("admin.providerHealth.description")} /> + {spendToday ? ( +

+ {t("admin.providerHealth.spendToday", { + date: spendToday.dateKey, + total: groupDigits(spendToday.totalTokens), + operator: groupDigits(spendToday.operatorTokens), + })} +

+ ) : null} {isPending ? (

{t("admin.providerHealth.loading")}