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:
Marc-André Bombeck committed 2026-10-11 13:09:12 +02:00
1 parent 7d8ff0f2df
commit dcb7a0bc09
35 files changed
+175 -65

No files matched your search

+5 -3
View File
@@ -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
+1 -1
View File
@@ -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),
+2 -2
View File
@@ -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,
+5 -2
View File
@@ -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;
+36 -7
View File
@@ -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
View File
@@ -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));
}
/**
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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 =
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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({