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")}