mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
7b21840630
commit
c66bcbc295
9 files changed
+118
-9
No files matched your search
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -7239,7 +7239,8 @@
|
||||
"typeAnthropic": "Anthropic 키 (개인)",
|
||||
"typeCodex": "ChatGPT 로그인 (개인)",
|
||||
"typeLocal": "로컬 모델 (개인)",
|
||||
"typeGateway": "OpenAI 호환 게이트웨이 (개인)"
|
||||
"typeGateway": "OpenAI 호환 게이트웨이 (개인)",
|
||||
"spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ProviderHealthSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ProviderHealthSection> — 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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<SettingsCard>
|
||||
@@ -75,6 +96,15 @@ export function ProviderHealthSection() {
|
||||
title={t("admin.providerHealth.title")}
|
||||
description={t("admin.providerHealth.description")}
|
||||
/>
|
||||
{spendToday ? (
|
||||
<p className="text-muted-foreground text-sm" data-slot="ai-spend-today">
|
||||
{t("admin.providerHealth.spendToday", {
|
||||
date: spendToday.dateKey,
|
||||
total: groupDigits(spendToday.totalTokens),
|
||||
operator: groupDigits(spendToday.operatorTokens),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{isPending ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.providerHealth.loading")}
|
||||
|
||||
Reference in new issue
Block a user