mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(ai): count the daily AI budget on the account's own day
The token ledger keyed every day on UTC while the daily briefing and the other per-day generations run on the account's local day, so east of UTC the first generations after local midnight were refused against the previous day's spent ceiling. The budget day is now the calendar day in the account's time zone; each request still resolves it once and books its reservation and reconcile on the same row. The medication extraction reads relative phrases against the same local day, the operator's spend card shows the operator's own day, and the limit copy says the budget resets at midnight in the person's time zone.
This commit is contained in:
1 parent
7d8ff0f2df
commit
dcb7a0bc09
35 files changed
+175
-65
No files matched your search
@@ -26856,7 +26856,7 @@ components:
|
||||
- ko
|
||||
today:
|
||||
description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\").
|
||||
Format: `YYYY-MM-DD`. Defaults to the server's UTC day."
|
||||
Format: `YYYY-MM-DD`. Defaults to the account's own day in its time zone."
|
||||
type: string
|
||||
pattern: ^\d{4}-\d{2}-\d{2}$
|
||||
required:
|
||||
|
||||
+2
-2
@@ -2843,7 +2843,7 @@
|
||||
"coach": {
|
||||
"refusalOutOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen: Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.",
|
||||
"refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen. Formuliere deine Frage bitte in diesem Rahmen neu.",
|
||||
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
|
||||
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.",
|
||||
"errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
|
||||
"errorDemo": "In der Demo antwortet der Coach nicht.",
|
||||
"errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter. Verbinde deinen eigenen Schlüssel oder ein lokales Modell.",
|
||||
@@ -2999,7 +2999,7 @@
|
||||
"settingsSaved": "Gespeichert",
|
||||
"settingsCancel": "Abbrechen",
|
||||
"dailyLimitTitle": "Heute-Limit erreicht",
|
||||
"dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
|
||||
"dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.",
|
||||
"providerRateLimitTitle": "Provider rate-limited",
|
||||
"providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.",
|
||||
"cluster": {
|
||||
|
||||
+2
-2
@@ -2843,7 +2843,7 @@
|
||||
"coach": {
|
||||
"refusalOutOfScope": "I can only help with the health metrics in your log: measurements, medications, mood, trends. I cannot answer questions outside that scope.",
|
||||
"refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.",
|
||||
"errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.",
|
||||
"errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight in your time zone.",
|
||||
"errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.",
|
||||
"errorDemo": "The Coach doesn't answer in the demo.",
|
||||
"errorNoProvider": "The Coach reads your logged health data and answers questions about it (not medical advice). HealthLog needs an AI provider for that. Connect your own key or a local model.",
|
||||
@@ -2999,7 +2999,7 @@
|
||||
"settingsSaved": "Saved",
|
||||
"settingsCancel": "Cancel",
|
||||
"dailyLimitTitle": "Daily limit reached",
|
||||
"dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.",
|
||||
"dailyLimitBody": "Daily limit reached. The budget refreshes at midnight in your time zone.",
|
||||
"providerRateLimitTitle": "Provider rate-limited",
|
||||
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.",
|
||||
"cluster": {
|
||||
|
||||
+2
-2
@@ -2843,7 +2843,7 @@
|
||||
"coach": {
|
||||
"refusalOutOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. Preguntas fuera de ese ámbito no las respondo.",
|
||||
"refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud. Reformula tu pregunta dentro de ese marco.",
|
||||
"errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).",
|
||||
"errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche en tu zona horaria.",
|
||||
"errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.",
|
||||
"errorDemo": "En la demo, el Coach no responde.",
|
||||
"errorNoProvider": "El coach lee tus datos de salud registrados y responde preguntas sobre ellos (no es consejo médico). HealthLog necesita un proveedor de IA para ello: conecta tu propia clave o un modelo local.",
|
||||
@@ -2999,7 +2999,7 @@
|
||||
"settingsSaved": "Guardado",
|
||||
"settingsCancel": "Cancelar",
|
||||
"dailyLimitTitle": "Límite diario alcanzado",
|
||||
"dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).",
|
||||
"dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche en tu zona horaria.",
|
||||
"providerRateLimitTitle": "Proveedor con límite de tasa",
|
||||
"providerRateLimitBody": "El proveedor está temporalmente saturado; reinicio en ~5 min.",
|
||||
"cluster": {
|
||||
|
||||
+2
-2
@@ -2843,7 +2843,7 @@
|
||||
"coach": {
|
||||
"refusalOutOfScope": "Je ne peux aider que pour les données de santé de ton journal : mesures, médicaments, humeur, tendances. Les questions hors de ce périmètre ne sont pas traitées.",
|
||||
"refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé. Reformule ta question dans ce cadre.",
|
||||
"errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).",
|
||||
"errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit dans ton fuseau horaire.",
|
||||
"errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.",
|
||||
"errorDemo": "Dans la démo, le Coach ne répond pas.",
|
||||
"errorNoProvider": "Le coach lit tes données de santé enregistrées et répond aux questions à leur sujet (pas un avis médical). HealthLog a besoin d'un fournisseur d'IA pour cela : connecte ta propre clé ou un modèle local.",
|
||||
@@ -2999,7 +2999,7 @@
|
||||
"settingsSaved": "Enregistré",
|
||||
"settingsCancel": "Annuler",
|
||||
"dailyLimitTitle": "Limite quotidienne atteinte",
|
||||
"dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).",
|
||||
"dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit dans ton fuseau horaire.",
|
||||
"providerRateLimitTitle": "Fournisseur limité en débit",
|
||||
"providerRateLimitBody": "Le fournisseur est temporairement saturé ; réinitialisation dans ~5 min.",
|
||||
"cluster": {
|
||||
|
||||
+2
-2
@@ -2843,7 +2843,7 @@
|
||||
"coach": {
|
||||
"refusalOutOfScope": "Posso aiutarti solo con i dati di salute del tuo log: misurazioni, farmaci, umore, trend. Domande fuori da questo ambito non le rispondo.",
|
||||
"refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute: riformula la domanda in questo perimetro.",
|
||||
"errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).",
|
||||
"errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte nel tuo fuso orario.",
|
||||
"errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.",
|
||||
"errorDemo": "Nella demo il Coach non risponde.",
|
||||
"errorNoProvider": "Il coach legge i tuoi dati di salute registrati e risponde a domande su di essi (non è consulenza medica). HealthLog ha bisogno di un provider IA per questo: collega la tua chiave o un modello locale.",
|
||||
@@ -2999,7 +2999,7 @@
|
||||
"settingsSaved": "Salvato",
|
||||
"settingsCancel": "Annulla",
|
||||
"dailyLimitTitle": "Limite giornaliero raggiunto",
|
||||
"dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).",
|
||||
"dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte nel tuo fuso orario.",
|
||||
"providerRateLimitTitle": "Provider con rate-limit",
|
||||
"providerRateLimitBody": "Il provider è temporaneamente sovraccarico; reset in ~5 min.",
|
||||
"cluster": {
|
||||
|
||||
+2
-2
@@ -2843,7 +2843,7 @@
|
||||
"coach": {
|
||||
"refusalOutOfScope": "기록에 있는 건강 지표(측정값, 약, 기분, 추이)만 도와드릴 수 있어요. 그 밖의 질문에는 답할 수 없어요.",
|
||||
"refusalInjection": "제 지시를 덮어쓰려는 표현이 보였어요. 저는 HealthLog 코치이고 회원님의 건강 데이터만 정리해 드려요. 그 범위로 질문을 다시 적어 주세요.",
|
||||
"errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.",
|
||||
"errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.",
|
||||
"errorProvider": "지금은 코치가 인사이트 제공자에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.",
|
||||
"errorDemo": "데모에서는 코치가 답하지 않아요.",
|
||||
"errorNoProvider": "코치는 기록된 건강 데이터를 읽고 그에 대한 질문에 답해요(의학적 조언은 아니에요). 그러려면 HealthLog에 AI 제공자가 필요해요. 직접 발급한 키나 로컬 모델을 연결해 주세요.",
|
||||
@@ -2999,7 +2999,7 @@
|
||||
"settingsSaved": "저장됨",
|
||||
"settingsCancel": "취소",
|
||||
"dailyLimitTitle": "하루 한도 도달",
|
||||
"dailyLimitBody": "하루 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.",
|
||||
"dailyLimitBody": "하루 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.",
|
||||
"providerRateLimitTitle": "제공자 요청 제한",
|
||||
"providerRateLimitBody": "제공자가 일시적으로 요청을 제한하고 있어요. 5분쯤 뒤에 다시 시도해 주세요.",
|
||||
"cluster": {
|
||||
|
||||
+2
-2
@@ -2843,7 +2843,7 @@
|
||||
"coach": {
|
||||
"refusalOutOfScope": "Mogę pomóc tylko w sprawach danych zdrowotnych z twojego logu: pomiarów, leków, nastroju, trendów. Pytań spoza tego zakresu nie podejmuję.",
|
||||
"refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne. Przeformułuj pytanie w tych ramach.",
|
||||
"errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).",
|
||||
"errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy w Twojej strefie czasowej.",
|
||||
"errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.",
|
||||
"errorDemo": "W wersji demo Coach nie odpowiada.",
|
||||
"errorNoProvider": "Coach czyta Twoje zapisane dane zdrowotne i odpowiada na pytania o nie (to nie porada medyczna). HealthLog potrzebuje do tego dostawcy AI: połącz własny klucz lub model lokalny.",
|
||||
@@ -2999,7 +2999,7 @@
|
||||
"settingsSaved": "Zapisano",
|
||||
"settingsCancel": "Anuluj",
|
||||
"dailyLimitTitle": "Osiągnięto dzienny limit",
|
||||
"dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).",
|
||||
"dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy w Twojej strefie czasowej.",
|
||||
"providerRateLimitTitle": "Dostawca z ograniczeniem",
|
||||
"providerRateLimitBody": "Dostawca jest tymczasowo przeciążony; reset za ~5 min.",
|
||||
"cluster": {
|
||||
|
||||
@@ -73,11 +73,13 @@ interface ProviderHealthSummary {
|
||||
const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES];
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.provider-health.get" } });
|
||||
|
||||
// The UTC day the ledger buckets by — the same key every reservation writes.
|
||||
const dateKey = buildDateKey();
|
||||
// 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
|
||||
|
||||
@@ -145,7 +145,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// `admin-key` is the operator's own credential and draws the operator
|
||||
// ceiling; an override names the caller's own key, and every other resolved
|
||||
// type is the user's own egress, so those draw the user-plan ceiling.
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(user.id);
|
||||
const reservation = await reserveBudget(
|
||||
user.id,
|
||||
AI_TEST_MAX_TOKENS,
|
||||
|
||||
@@ -183,7 +183,7 @@ async function reserveFor(
|
||||
providerType: ProviderChainType,
|
||||
tokens: number,
|
||||
): Promise<AiRunBudget | null> {
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
tokens,
|
||||
|
||||
@@ -200,7 +200,7 @@ async function handleVisionIndex(
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
DOCUMENT_INDEX_RESERVE_TOKENS,
|
||||
|
||||
@@ -122,7 +122,7 @@ async function reserveFor(
|
||||
userId: string,
|
||||
providerType: ProviderChainType,
|
||||
): Promise<AiRunBudget | null> {
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.documentAssist.maxTokens,
|
||||
|
||||
@@ -216,7 +216,7 @@ async function reserveFor(
|
||||
ask: SummaryRequest,
|
||||
providerType: ProviderChainType,
|
||||
): Promise<AiRunBudget | null> {
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(ask.userId);
|
||||
const reservation = await reserveBudget(
|
||||
ask.userId,
|
||||
budgetFor(ask.mode),
|
||||
|
||||
@@ -157,7 +157,7 @@ async function handleTextExtract(
|
||||
// v1.21.0 (F1) — the operator-cost cap applies only when the picked provider
|
||||
// egresses on the operator's own key; a BYOK / Codex / local pick runs on the
|
||||
// user's own plan and gets the generous user-plan ceiling.
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
OCR_RESERVE_TOKENS.text,
|
||||
@@ -218,7 +218,7 @@ async function handleVisionExtract(
|
||||
// v1.21.0 (F1) — the operator-cost cap applies only when the picked vision
|
||||
// provider egresses on the operator's own key; a BYOK / Codex pick runs on
|
||||
// the user's own plan and gets the generous user-plan ceiling.
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
OCR_RESERVE_TOKENS.vision,
|
||||
|
||||
@@ -225,7 +225,10 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
chain.map((entry) => entry.providerType),
|
||||
);
|
||||
|
||||
const today = todayOverride ?? buildDateKey();
|
||||
// The account's own today: "tomorrow" in a description means the
|
||||
// person's tomorrow, and the same key is the budget day below.
|
||||
const accountToday = await buildDateKey(userId);
|
||||
const today = todayOverride ?? accountToday;
|
||||
const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({
|
||||
text,
|
||||
today,
|
||||
@@ -239,7 +242,7 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
// paying for their own key by the operator's ceiling. `resolveDailyCap`
|
||||
// charges the operator ceiling only when the chain's primary is the
|
||||
// operator's own credential; every other chain gets the user-plan ceiling.
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = accountToday;
|
||||
const estimatedTokens =
|
||||
MAX_OUTPUT_TOKENS +
|
||||
Math.ceil((systemPrompt.length + userPrompt.length) / 4);
|
||||
|
||||
@@ -560,7 +560,7 @@ function ChatBubbleImpl({
|
||||
|
||||
// v1.4.25 W5 — map server-emitted error codes to specific Coach
|
||||
// i18n keys. Distinct user-quota and provider-rate-limit copy so the
|
||||
// user understands daily-cap (resets at UTC midnight) vs. transient
|
||||
// user understands daily-cap (resets at their local midnight) vs. transient
|
||||
// provider load (retry in ~5 min). Codes that have no dedicated
|
||||
// translation fall back to the generic provider-unavailable copy.
|
||||
const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null;
|
||||
|
||||
@@ -22,16 +22,45 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { zoneOf } = vi.hoisted(() => ({
|
||||
zoneOf: vi.fn(async (userId: string) =>
|
||||
userId === "berlin"
|
||||
? "Europe/Berlin"
|
||||
: userId === "new-york"
|
||||
? "America/New_York"
|
||||
: "UTC",
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: zoneOf }));
|
||||
|
||||
/**
|
||||
* The budget day turns at the account's own midnight, the boundary the daily
|
||||
* briefing and the other per-day generations use. On the UTC day, the first
|
||||
* generations of a new local day east of UTC were counted against the old
|
||||
* day's exhausted ceiling.
|
||||
*/
|
||||
describe("buildDateKey", () => {
|
||||
it("formats UTC YYYY-MM-DD", () => {
|
||||
// 2026-05-10T22:30Z is UTC May 10 — Berlin would be May 11 already
|
||||
const key = buildDateKey(new Date("2026-05-10T22:30:00.000Z"));
|
||||
expect(key).toBe("2026-05-10");
|
||||
it("is the account's local day, not the UTC day", async () => {
|
||||
// 22:30 UTC on May 10 is already 00:30 on May 11 in Berlin (CEST).
|
||||
const at = new Date("2026-05-10T22:30:00.000Z");
|
||||
await expect(buildDateKey("berlin", at)).resolves.toBe("2026-05-11");
|
||||
await expect(buildDateKey("utc", at)).resolves.toBe("2026-05-10");
|
||||
expect(zoneOf).toHaveBeenCalledWith("berlin");
|
||||
});
|
||||
|
||||
it("rolls forward at UTC midnight", () => {
|
||||
const key = buildDateKey(new Date("2026-05-11T00:01:00.000Z"));
|
||||
expect(key).toBe("2026-05-11");
|
||||
it("turns at local midnight on either side of it", async () => {
|
||||
// Berlin: 23:59 local on May 10, then 00:01 local on May 11.
|
||||
await expect(
|
||||
buildDateKey("berlin", new Date("2026-05-10T21:59:00.000Z")),
|
||||
).resolves.toBe("2026-05-10");
|
||||
await expect(
|
||||
buildDateKey("berlin", new Date("2026-05-10T22:01:00.000Z")),
|
||||
).resolves.toBe("2026-05-11");
|
||||
// West of UTC the local day ends after the UTC one: 02:00 UTC on May 11
|
||||
// is still the evening of May 10 in New York.
|
||||
await expect(
|
||||
buildDateKey("new-york", new Date("2026-05-11T02:00:00.000Z")),
|
||||
).resolves.toBe("2026-05-10");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+23
-12
@@ -9,12 +9,13 @@
|
||||
* us add per-day analytics ("avg replies per active user") later
|
||||
* without further migrations.
|
||||
*
|
||||
* `dateKey` is a UTC `YYYY-MM-DD` string. UTC, not Europe/Berlin: the
|
||||
* job runner that may seed off-hour tasks (planned v1.5) needs a
|
||||
* boundary that does not jump on DST. Display layers can format with
|
||||
* the user's timezone.
|
||||
* `dateKey` is a `YYYY-MM-DD` string: the calendar day in the account's own
|
||||
* time zone (`buildDateKey`), the same boundary as the daily briefing and
|
||||
* every other per-day surface.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
||||
|
||||
/**
|
||||
@@ -215,8 +216,9 @@ export interface DailySpend {
|
||||
|
||||
export async function readDailySpend(
|
||||
userId: string,
|
||||
dateKey: string = buildDateKey(),
|
||||
dateKey?: string,
|
||||
): Promise<DailySpend> {
|
||||
dateKey ??= await buildDateKey(userId);
|
||||
const row = await prisma.coachUsage.findUnique({
|
||||
where: { userId_dateKey: { userId, dateKey } },
|
||||
select: { totalTokens: true, operatorTokens: true },
|
||||
@@ -228,15 +230,24 @@ export async function readDailySpend(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the UTC day-key for a given clock. Defaults to "now".
|
||||
* The budget day of one account at `at` (default now): the calendar day in
|
||||
* the account's own time zone.
|
||||
*
|
||||
* UTC choice: the budget guards spend against the operator's LLM
|
||||
* bill — the bill cycles at the provider's UTC midnight, so aligning
|
||||
* the local meter to the same boundary keeps reasoning trivial.
|
||||
* Up to v1.42.1 this was the UTC day. The daily briefing and the other
|
||||
* per-day generations run on the account's local day, so in a zone east of
|
||||
* UTC the first ones of a new local day still counted against the old UTC
|
||||
* day, whose ceiling the evening had used up: a burst of refusals right
|
||||
* after local midnight. The day now turns where the person's day turns.
|
||||
*
|
||||
* A caller resolves the key once per request or job and hands the same key
|
||||
* to the reservation and the reconcile, so both land on one row even when
|
||||
* the call spans midnight.
|
||||
*/
|
||||
export function buildDateKey(at: Date = new Date()): string {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the spend meter follows the provider's UTC billing day (see the doc comment)
|
||||
return at.toISOString().slice(0, 10);
|
||||
export async function buildDateKey(
|
||||
userId: string,
|
||||
at: Date = new Date(),
|
||||
): Promise<string> {
|
||||
return userDayKey(at, await resolveUserTimezone(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -240,7 +240,7 @@ export async function deriveClarifyingQuestions(
|
||||
// serialises them on the row's unique key instead. The cap follows the
|
||||
// chain's cost owner, so a user on their own plan is not held to the
|
||||
// operator-cost ceiling.
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
QUESTIONS_MAX_TOKENS,
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function reserveTurnBudget(args: {
|
||||
// egresses through the operator's own key or account; a chain on the
|
||||
// person's own plan or key gets the generous user-plan ceiling.
|
||||
const dailyCap = resolveDailyCap(chain);
|
||||
const reqDateKey = buildDateKey();
|
||||
const reqDateKey = await buildDateKey(userId);
|
||||
const firstRound = toolMode
|
||||
? Math.max(1, Math.floor(args.firstRound ?? AI_BUDGETS.coach.maxTokens))
|
||||
: AI_BUDGETS.coach.maxTokens;
|
||||
|
||||
@@ -290,7 +290,7 @@ export async function maybeAutoStageLabFacts(
|
||||
// ceiling under the cost owner the auto-index resolved.
|
||||
// v1.38.19 — auto-staging runs off the upload, with no one waiting
|
||||
// on it: a background surface, so half the day's ceiling.
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
|
||||
@@ -312,7 +312,7 @@ export async function streamFencedReply(
|
||||
await appendMessage({ conversationId, role: "user", content: message });
|
||||
|
||||
// ── Budget reservation (atomic, before the provider call) ──
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.documentChat.maxTokens,
|
||||
|
||||
@@ -242,7 +242,7 @@ async function tryProviderIndex(
|
||||
return { outcome: null, note: null };
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.documentTranscribe.maxTokens,
|
||||
|
||||
@@ -93,7 +93,7 @@ export interface RunBriefingCompletionArgs {
|
||||
* a pinned seed would reproduce the phrasing the re-roll exists to vary.
|
||||
*/
|
||||
seed?: number;
|
||||
/** UTC day-key override; defaults to today. Tests pin it. */
|
||||
/** Budget day-key override; defaults to the account's today. Tests pin it. */
|
||||
dateKey?: string;
|
||||
/**
|
||||
* v1.41 — `daily_briefing` on the calls that may reason (the generation and
|
||||
@@ -122,7 +122,7 @@ export interface RunBriefingCompletionArgs {
|
||||
export async function runBriefingCompletion(
|
||||
args: RunBriefingCompletionArgs,
|
||||
): Promise<RunRawWithFallbackResult> {
|
||||
const dateKey = args.dateKey ?? buildDateKey();
|
||||
const dateKey = args.dateKey ?? (await buildDateKey(args.userId));
|
||||
|
||||
// v1.41 — one resolution per call: the operator's switch and cap, the payer
|
||||
// from the chain's primary. `undefined` is today's wire.
|
||||
|
||||
@@ -250,7 +250,7 @@ export async function runStatusCompletion(
|
||||
// credential (`admin-openai` / `admin-codex`). A self-hoster on their own key
|
||||
// or a local model is measured against the generous user-plan ceiling, so
|
||||
// their own hardware/plan is never rationed by the operator's bill.
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
// Thinking is billed as output, so a reasoning call reserves its thinking
|
||||
// budget too; the reconcile below settles on the reported count.
|
||||
const estimatedTokens =
|
||||
|
||||
@@ -211,7 +211,7 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
|
||||
const maxTokens = budget.maxTokens ?? 160;
|
||||
// v1.38.19 — a background surface: half the day's ceiling.
|
||||
const dailyCap = resolveDailyCapFor("job", chain);
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(params.userId);
|
||||
|
||||
// Per-user budget gate (atomic reserve; refunds itself on refusal).
|
||||
const reservation = await reserveBudget(
|
||||
|
||||
@@ -268,7 +268,7 @@ export async function runContentIndexBackfillForUser(
|
||||
break outer;
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.documentTranscribe.maxTokens,
|
||||
|
||||
@@ -223,7 +223,7 @@ export async function runDocumentSummaryJob(
|
||||
return;
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const dateKey = await buildDateKey(userId);
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.documentSummary.maxTokens,
|
||||
|
||||
@@ -592,7 +592,7 @@ export async function runReactionLine(
|
||||
generationClaimId,
|
||||
job.userId,
|
||||
revision,
|
||||
buildDateKey(),
|
||||
await buildDateKey(job.userId),
|
||||
// v1.38.19 — a background surface: half the day's ceiling.
|
||||
resolveDailyCapFor("job", chain),
|
||||
resolveCostOwner(chain),
|
||||
|
||||
@@ -1036,7 +1036,7 @@ export const medicationExtractRequest = z
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the server\'s UTC day.',
|
||||
'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the account\'s own day in its time zone.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* prove both contracts against a real Postgres; a mocked client could not
|
||||
* detect a missing atomic increment.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
@@ -180,3 +180,64 @@ describe("reserveBudget (real Postgres)", () => {
|
||||
expect(row?.totalTokens).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The budget day turns at the account's own midnight, the boundary the daily
|
||||
* briefing uses. On the UTC day a Berlin account's first generations after
|
||||
* local midnight still counted against the previous day's spent ceiling.
|
||||
*/
|
||||
describe("the budget day is the account's local day", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("opens a fresh day at local midnight while the UTC day is still running", async () => {
|
||||
const { buildDateKey, reserveBudget } =
|
||||
await import("@/lib/ai/coach/budget");
|
||||
const user = await getPrismaClient().user.create({
|
||||
data: {
|
||||
username: "budget-berlin",
|
||||
email: "budget-berlin@example.test",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
// 22:30 UTC on 2026-06-19 is 00:30 on 2026-06-20 in Berlin (CEST).
|
||||
vi.useFakeTimers({
|
||||
toFake: ["Date"],
|
||||
now: new Date("2026-06-19T22:30:00.000Z"),
|
||||
});
|
||||
|
||||
// The evening used the whole ceiling, booked to the day that just ended.
|
||||
await getPrismaClient().coachUsage.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
dateKey: "2026-06-19",
|
||||
totalTokens: 1000,
|
||||
operatorTokens: 1000,
|
||||
messageCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const dateKey = await buildDateKey(user.id);
|
||||
expect(dateKey).toBe("2026-06-20");
|
||||
const first = await reserveBudget(
|
||||
user.id,
|
||||
200,
|
||||
dateKey,
|
||||
1000,
|
||||
"operator",
|
||||
"job",
|
||||
);
|
||||
expect(first.allowed).toBe(true);
|
||||
const rows = await getPrismaClient().coachUsage.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { dateKey: "asc" },
|
||||
select: { dateKey: true, totalTokens: true },
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ dateKey: "2026-06-19", totalTokens: 1000 },
|
||||
{ dateKey: "2026-06-20", totalTokens: 200 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -230,7 +230,9 @@ describe("POST /api/insights/chat — integration", () => {
|
||||
// the reservation is refunded so the ledger stays at the cap.
|
||||
const { userId } = await seedUserWithSession();
|
||||
const prisma = getPrismaClient();
|
||||
const dateKey = new Date().toISOString().slice(0, 10);
|
||||
// The account's own day, the key the route reserves under.
|
||||
const { buildDateKey } = await import("@/lib/ai/coach/budget");
|
||||
const dateKey = await buildDateKey(userId);
|
||||
// v1.21.0 — the daily cap is now provider-aware: the seeded user resolves
|
||||
// to a user-egress chain, so the applicable ceiling is USER_PLAN_CAP. Seed
|
||||
// the spend at that ceiling so the reservation trips the gate. (Seeding the
|
||||
|
||||
@@ -483,7 +483,7 @@ describe("the reaper", () => {
|
||||
const { readDailySpend, buildDateKey } =
|
||||
await import("@/lib/ai/coach/budget");
|
||||
expect(
|
||||
(await readDailySpend(user.id, buildDateKey())).total,
|
||||
(await readDailySpend(user.id, await buildDateKey(user.id))).total,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const { reapAiRuns } = await import("@/lib/documents/ai-runs/store");
|
||||
@@ -493,7 +493,9 @@ describe("the reaper", () => {
|
||||
timedOut: 0,
|
||||
deleted: 0,
|
||||
});
|
||||
expect((await readDailySpend(user.id, buildDateKey())).total).toBe(0);
|
||||
expect(
|
||||
(await readDailySpend(user.id, await buildDateKey(user.id))).total,
|
||||
).toBe(0);
|
||||
|
||||
const run = await getPrismaClient().documentAiRun.findUniqueOrThrow({
|
||||
where: { id: runId },
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("429 rate-limit headers (real Postgres)", () => {
|
||||
await getPrismaClient().coachUsage.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
dateKey: buildDateKey(),
|
||||
dateKey: await buildDateKey(TEST_USER_ID),
|
||||
totalTokens: USER_PLAN_CAP,
|
||||
messageCount: 1,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user