Merge remote-tracking branch 'origin/main' into fix/close-account-enumeration

This commit is contained in:
Marc-André Bombeck committed 2026-09-11 16:01:25 +02:00
commit 9bf78e32d6
87 files changed
+2831 -207

No files matched your search

+64
View File
@@ -1,5 +1,69 @@
# Changelog
## [1.38.19] — 2026-09-11
The first run tells you what is actually true about your account, an
invitation link behaves, and the AI you ask a question to has budget left
when you ask it.
### Fixed
- **An invitation link opened while you are already signed in no longer
offers to register.** It showed a registration form, and completing it
quietly swapped the session you were in for a brand new account. The link
now says you are signed in and what you can do about it, and the server
refuses the registration outright rather than relying on the screen to
hide the button.
- **The setup step that connects a device says what is true about it.**
Every provider was described as "connected, first sync running", including
ones that had never synced and ones nobody had connected. A provider with
no history of its own says so now, and the step offers to connect it.
- **The last screen of the setup no longer claims the AI works when nobody
has asked it anything.** The offer appeared, and said insights were ready,
as soon as an instance had a provider configured. It appears only when
that provider has actually answered, and a freshly installed instance says
its provider has not been tried yet instead of announcing a failure.
- **A page whose module is switched off says which switch it is** instead of
showing an empty page, and the getting-started checklist no longer asks
for things that are already done.
- **Background insights can no longer use up the day before you ask a
question.** The daily AI limit counted every request against one number,
whoever was paying for the model behind it, so a day of automatic insights
could leave your own question refused. It is counted per payer now, and a
fifth of it is held back for what you do by hand: on a plan you bring
yourself the automatic work runs up to 1 600 000 tokens a day and your
questions keep 400 000. Nothing changes for an instance whose AI is only
used by hand.
### Security
- **The demo account can no longer be taken over through the setup step.**
The step that asks for date of birth, height and sex went through a route
that accepts eleven fields, including the contact address of the account
everyone shares. It writes those three and nothing else now, refused on
the server rather than left out of the form.
### Changed
- **`GET /api/integrations/status` can answer `unknown`** for a provider
with no sync history, next to the existing values; that case used to be
reported as `connected`. The native client does not read this field.
- **`GET /api/user/ai-provider` gains three fields** describing the
instance's shared AI: whether it is healthy, untried or failing, whether
it may be offered, and whether the person already agreed to it.
- **When the AI limit stops a request, the answer says which ceiling was
reached** and whose budget it was.
- **Two migrations run on start** (`0340_coach_usage_operator_tokens`,
`0341_arrival_reaction_cost_owner`), both adding one column with a
default.
## [1.38.18] — 2026-09-10
The first run asks what you need and switches on exactly that; the old
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.38.18
version: 1.38.19
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+2 -1
View File
@@ -6685,7 +6685,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
View File
@@ -6685,7 +6685,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
View File
@@ -6685,7 +6685,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
View File
@@ -6685,7 +6685,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
View File
@@ -6685,7 +6685,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
View File
@@ -7292,7 +7292,8 @@
"typeAnthropic": "Anthropic 키 (개인)",
"typeCodex": "ChatGPT 로그인 (개인)",
"typeLocal": "로컬 모델 (개인)",
"typeGateway": "OpenAI 호환 게이트웨이 (개인)"
"typeGateway": "OpenAI 호환 게이트웨이 (개인)",
"spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다."
}
},
"achievements": {
+2 -1
View File
@@ -6685,7 +6685,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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.38.18",
"version": "1.38.19",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,15 @@
-- v1.38.19 — the daily coach ledger gains a cost-owner dimension.
--
-- `coach_usage` counted one number per (user, day), so tokens the USER's own
-- plan paid for (a `codex` fallback hop after the operator's shared key
-- answered 500) were charged against the operator-cost cap and locked the
-- interactive chat out every morning. `operator_tokens` is the share of
-- `total_tokens` served by an operator-funded provider (`admin-openai` /
-- `admin-codex`); the operator cap is enforced against it, the user-plan cap
-- against `total_tokens`.
--
-- Existing rows start at 0: their owner split is unknown and 0 is the side
-- that cannot lock a user out of a chat the operator never paid for. The
-- counter is exact from the first turn after deploy.
ALTER TABLE "coach_usage"
ADD COLUMN IF NOT EXISTS "operator_tokens" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,18 @@
-- v1.38.19 — the arrival reaction remembers who paid for
-- its reservation.
--
-- The row persisted the reserved amount and the date key but not the cost
-- owner, and two readers need it. The supersede refund in `data-arrival.ts`
-- reverses a pre-provider reservation with no chain in hand at all, so without
-- this column it could only reverse `total_tokens` — leaving the operator
-- share of every superseded reservation on the row forever, walking the
-- operator's ceiling upward against spend that never happened. The resume path
-- in `reaction-line.ts` re-derived the owner from the chain as it stands NOW,
-- and the chain is re-resolved per run and reordered by the health ledger, so
-- a reservation booked to the operator could be reconciled as the user's (the
-- same leak) or vice versa (draining another request's balance).
--
-- Nullable, no default: an existing row's owner is genuinely unknown, and the
-- two readers fall back to the previous behaviour for those.
ALTER TABLE "arrival_reactions"
ADD COLUMN IF NOT EXISTS "generation_cost_owner" TEXT;
+19 -7
View File
@@ -6267,13 +6267,19 @@ model HealthProfileFactRevision {
// secondary signal used by the frontend to render a "≈ N replies left"
// hint without exposing raw token figures.
model CoachUsage {
id String @id @default(cuid())
userId String @map("user_id")
dateKey String @map("date_key")
totalTokens Int @default(0) @map("total_tokens")
messageCount Int @default(0) @map("message_count")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
id String @id @default(cuid())
userId String @map("user_id")
dateKey String @map("date_key")
totalTokens Int @default(0) @map("total_tokens")
/// v1.38.19 — the share of `totalTokens` served by a provider the
/// OPERATOR funds (`admin-openai` / `admin-codex`). The operator-cost cap is
/// enforced against this counter and the user-plan cap against
/// `totalTokens`, so background work served by the user's own plan can no
/// longer lock the interactive chat out of the operator's ceiling.
operatorTokens Int @default(0) @map("operator_tokens")
messageCount Int @default(0) @map("message_count")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -6755,6 +6761,12 @@ model ArrivalReaction {
/// `coach_usage` increment. A reclaimed pre-provider attempt reuses it.
generationReservedTokens Int? @map("generation_reserved_tokens")
generationBudgetDateKey String? @map("generation_budget_date_key")
/// v1.38.19 — who the reservation above was booked to, `"operator"`
/// or `"user"`. Persisted rather than recomputed: the supersede refund in
/// `data-arrival.ts` has no chain to ask, and the resume path's chain may
/// have been reordered by the health ledger since the reservation was made.
/// Null on rows written before the column existed.
generationCostOwner String? @map("generation_cost_owner")
/// Durable provider boundary. Once set, this marker is terminal unless the
/// encrypted line is committed; no retry may invoke a provider again.
generationProviderInvokedAt DateTime? @map("generation_provider_invoked_at")
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.38.18";
/* @sw-version-fallback */ "v1.38.19";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -0,0 +1,133 @@
/**
* Structural guard — `coach_usage.total_tokens` and `coach_usage.operator_tokens`
* move together in EVERY raw statement.
*
* v1.38.19 gave the daily ledger a second counter: `operator_tokens`
* is the share of `total_tokens` an operator-funded provider served, and the
* operator ceiling is enforced against it. The counters are only meaningful
* while they stay in step. A statement that writes the total and forgets the
* operator share under-counts the operator's exposure; a statement that
* reverses the total and forgets the operator share leaks budget permanently
* toward the operator cap, because nothing else ever subtracts it; a statement
* that RETURNS the total alone forces its caller to gate on the wrong number,
* which is how an operator-funded chain ends up refused over tokens the user's
* own plan paid for.
*
* Two of the three writers shipped with precisely those bugs, and both were a
* grep away. This guard is that grep, executable: every INSERT column list,
* every SET clause and every RETURNING list that names one counter must name
* the other. It also pins the writer set, so a fourth place that learns to
* write the ledger has to come past this file first.
*/
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, relative, resolve } from "node:path";
import { describe, it, expect } from "vitest";
const SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const REPO_ROOT = resolve(SRC_DIR, "..");
/**
* The production files allowed to hold raw `coach_usage` SQL. The ledger's
* invariants (two counters in one statement, one row per user and UTC day) are
* reasoned about here and nowhere else; a new entry means a new place those
* invariants can be broken, so it is a deliberate edit, not a side effect.
*/
const EXPECTED_SQL_WRITERS = [
"src/lib/ai/coach/budget.ts",
"src/lib/jobs/data-arrival.ts",
];
function collectSourceFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = resolve(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "__tests__" || entry.name === "generated") continue;
collectSourceFiles(full, out);
continue;
}
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
if (entry.name.endsWith(".test.ts") || entry.name.endsWith(".test.tsx")) {
continue;
}
out.push(full);
}
}
return out;
}
/**
* Every backtick-delimited SQL template literal in a source file. A prose
* mention of `coach_usage` in a doc comment sits between backticks too, so a
* literal only counts once it also reads like a statement.
*/
const SQL_STATEMENT = /\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM|SELECT)\b/i;
function sqlLiterals(source: string): string[] {
return (source.match(/`[^`]*`/g) ?? []).filter(
(literal) => literal.includes("coach_usage") && SQL_STATEMENT.test(literal),
);
}
/**
* The clauses of one SQL statement that name columns: the INSERT column list,
* every SET assignment block, and every RETURNING list. Each is checked on its
* own, because a statement can write both counters and still return only one —
* which is how the arrival-reaction reservation ended up gating the operator's
* cap on a mixed total.
*/
function columnClauses(sql: string): { label: string; text: string }[] {
const clauses: { label: string; text: string }[] = [];
for (const m of sql.matchAll(/INSERT\s+INTO\s+[\w"]+\s*\(([^)]*)\)/gi)) {
clauses.push({ label: "INSERT column list", text: m[1] });
}
for (const m of sql.matchAll(
/\bSET\b([\s\S]*?)(?=\bWHERE\b|\bRETURNING\b|$)/gi,
)) {
clauses.push({ label: "SET clause", text: m[1] });
}
for (const m of sql.matchAll(/\bRETURNING\b([\s\S]*?)(?=;|$)/gi)) {
clauses.push({ label: "RETURNING list", text: m[1] });
}
return clauses;
}
describe("coach_usage — the two counters move together", () => {
const files = collectSourceFiles(SRC_DIR);
it("names operator_tokens in every clause that names total_tokens", () => {
const offences: string[] = [];
for (const file of files) {
const source = readFileSync(file, "utf8");
if (!source.includes("coach_usage")) continue;
for (const literal of sqlLiterals(source)) {
for (const clause of columnClauses(literal)) {
if (!clause.text.includes("total_tokens")) continue;
if (clause.text.includes("operator_tokens")) continue;
offences.push(
`${relative(REPO_ROOT, file)} — ${clause.label}: ${clause.text
.replace(/\s+/g, " ")
.trim()}`,
);
}
}
}
expect(
offences,
`Every raw coach_usage clause that touches total_tokens must touch operator_tokens too:\n${offences.join("\n")}`,
).toEqual([]);
});
it("keeps the raw-SQL writer set to the files that reason about the ledger", () => {
const writers = files
.filter((file) => {
return sqlLiterals(readFileSync(file, "utf8")).length > 0;
})
.map((file) => relative(REPO_ROOT, file))
.sort();
expect(writers).toEqual([...EXPECTED_SQL_WRITERS].sort());
});
});
@@ -18,6 +18,9 @@ vi.mock("@/lib/db", () => ({
providerHealth: {
groupBy: vi.fn(),
},
coachUsage: {
aggregate: vi.fn(),
},
},
}));
@@ -45,6 +48,7 @@ import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/api-handler";
const groupBy = vi.mocked(prisma.providerHealth.groupBy);
const aggregate = vi.mocked(prisma.coachUsage.aggregate);
function group(
providerType: string,
@@ -76,6 +80,9 @@ describe("GET /api/admin/provider-health", () => {
vi.mocked(requireAdmin).mockResolvedValue({
user: { id: "admin1" },
} as never);
aggregate.mockResolvedValue({
_sum: { totalTokens: 0, operatorTokens: 0 },
} as never);
});
it("is admin-gated before it touches the ledger", async () => {
@@ -199,6 +206,49 @@ describe("GET /api/admin/provider-health", () => {
).toEqual(["admin-openai", "admin-codex", "anthropic", "local"]);
});
/**
* v1.38.19 — the two spend figures side by side.
*
* The operator reads this card when the chat refuses. Until now it showed
* delivery health only, and the day's token figure was one number that mixed
* his own ChatGPT plan with the instance's key — so a refusal at 513 539
* tokens looked like his own doing. The split is what tells him which
* ceiling he hit.
*/
it("reports the day's spend split by who paid for it", async () => {
groupBy.mockResolvedValue([] as never);
aggregate.mockResolvedValue({
_sum: { totalTokens: 1_240_000, operatorTokens: 151_200 },
} as never);
const res = await GET();
const body = await res.json();
expect(body.data.spendToday).toEqual({
dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
totalTokens: 1_240_000,
operatorTokens: 151_200,
});
// Today only, across every account — the operator's ceiling is per day.
expect(aggregate).toHaveBeenCalledWith(
expect.objectContaining({
where: { dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
}),
);
});
it("reports a day with no spend as zero, not null", async () => {
groupBy.mockResolvedValue([] as never);
aggregate.mockResolvedValue({
_sum: { totalTokens: null, operatorTokens: null },
} as never);
const res = await GET();
const body = await res.json();
expect(body.data.spendToday.totalTokens).toBe(0);
expect(body.data.spendToday.operatorTokens).toBe(0);
});
it("answers an empty ledger with an empty list, not an error", async () => {
groupBy.mockResolvedValue([] as never);
+24 -1
View File
@@ -1,4 +1,5 @@
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { buildDateKey } from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -49,6 +50,9 @@ export const GET = apiHandler(async () => {
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();
const groups = await prisma.providerHealth.groupBy({
// `lastStatus` joins the grouping key rather than an aggregate: the
// MAX of two HTTP statuses is not a status, it is arithmetic on a
@@ -107,5 +111,24 @@ export const GET = apiHandler(async () => {
return a.providerType.localeCompare(b.providerType);
});
return apiSuccess({ providers });
// v1.38.19 — the day's spend, split by who pays for it.
//
// The operator opens this card when a surface refuses on budget. One mixed
// number could not answer the question it raised: 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' plans. The operator ceiling is enforced
// against the second figure, so both belong on screen.
const spend = await prisma.coachUsage.aggregate({
where: { dateKey },
_sum: { totalTokens: true, operatorTokens: true },
});
return apiSuccess({
providers,
spendToday: {
dateKey,
totalTokens: spend._sum.totalTokens ?? 0,
operatorTokens: spend._sum.operatorTokens ?? 0,
},
});
});
@@ -30,9 +30,13 @@ vi.mock("@/lib/ai/coach/budget", () => ({
allowed: true,
reserved: 32,
totalAfter: 32,
owner: "operator" as const,
operatorAfter: 32,
})),
reconcileSpend: vi.fn(async () => undefined),
resolveDailyCap: () => 200_000,
resolveDailyCapFor: () => 200_000,
resolveCostOwner: () => "operator" as const,
}));
vi.mock("@/lib/logging/context", () => ({
@@ -376,6 +380,8 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
32,
expect.any(Number),
"2026-01-01",
0,
{ servedBy: expect.any(String), reservedOwner: "operator" },
);
});
});
+16 -1
View File
@@ -19,6 +19,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { aiTestOverrideSchema } from "@/lib/validations/ai-provider";
@@ -113,6 +114,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
providerType: provider.type === "admin-key" ? "admin-openai" : "local",
},
]),
resolveCostOwner([
{
providerType: provider.type === "admin-key" ? "admin-openai" : "local",
},
]),
"coach",
);
if (!reservation.allowed) {
annotate({ action: { name: "ai.test.budget_exceeded" } });
@@ -137,6 +144,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
reservation.reserved,
result.tokensUsed ?? reservation.reserved,
dateKey,
0,
{
servedBy: provider.type === "admin-key" ? "admin-openai" : "local",
reservedOwner: reservation.owner,
},
);
return apiSuccess({
@@ -155,7 +167,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
// A failed probe produced no reported usage — refund the reservation so a
// provider that is simply misconfigured doesn't burn the caller's ledger
// while they fix it.
await reconcileSpend(user.id, reservation.reserved, 0, dateKey);
await reconcileSpend(user.id, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
annotate({
meta: {
ai_test_error: err.message.slice(0, 500),
@@ -51,6 +51,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 600 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 100000),
resolveDailyCapFor: vi.fn(() => 100000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
runStreamingRawCompletionWithFallback: vi.fn(),
@@ -69,6 +69,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/documents/extract", () => ({
runInboundExtraction: vi.fn(),
@@ -41,6 +41,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
@@ -254,6 +255,8 @@ async function handleTextExtract(
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
@@ -273,6 +276,8 @@ async function handleTextExtract(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
const updated = await stageExtraction(document.id, userId, result);
@@ -289,7 +294,10 @@ async function handleTextExtract(
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof InboundExtractError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
@@ -351,6 +359,8 @@ async function handleStoredExtract(
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
@@ -370,6 +380,8 @@ async function handleStoredExtract(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
const updated = await stageExtraction(document.id, userId, result);
@@ -386,7 +398,10 @@ async function handleStoredExtract(
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof InboundExtractError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
@@ -465,6 +480,8 @@ async function handleVisionExtract(
AI_BUDGETS.ocrExtract.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
@@ -493,6 +510,8 @@ async function handleVisionExtract(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
const updated = await stageExtraction(document.id, userId, result);
@@ -509,7 +528,10 @@ async function handleVisionExtract(
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof InboundExtractError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
@@ -52,6 +52,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
@@ -37,6 +37,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
@@ -249,6 +250,8 @@ async function handleVisionIndex(
AI_BUDGETS.documentTranscribe.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
@@ -269,6 +272,8 @@ async function handleVisionIndex(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
// Refs #776 — the empty-transcription guard, same contract as the auto
// path (`tryProviderIndex`): a provider answer with no text must never
@@ -295,7 +300,10 @@ async function handleVisionIndex(
});
return finishIndex(request, userId, document.id, "vision", tokenCount);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
await recordIndexAttempt(userId, document.id, {
indexed: false,
reason: "provider-error",
@@ -40,6 +40,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
@@ -29,6 +29,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
@@ -166,6 +167,8 @@ async function handleTextSuggest(
AI_BUDGETS.documentAssist.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
@@ -185,10 +188,15 @@ async function handleTextSuggest(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return finishSuggest(request, userId, document.id, "text", suggestion);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof DocumentAssistError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
@@ -255,6 +263,8 @@ async function handleVisionSuggest(
AI_BUDGETS.documentAssist.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
@@ -275,10 +285,15 @@ async function handleVisionSuggest(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return finishSuggest(request, userId, document.id, "vision", suggestion);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof DocumentAssistError) {
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
errorCode: "documents.inbound.extractFailed",
@@ -53,6 +53,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
@@ -36,6 +36,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
@@ -345,6 +346,8 @@ async function handleTextSummary(
budgetFor(mode),
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
@@ -369,10 +372,15 @@ async function handleTextSummary(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return finishSummary(request, userId, document.id, "text", mode, result);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return summaryError(err, "text");
}
}
@@ -430,6 +438,8 @@ async function handleVisionSummary(
budgetFor(mode),
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
await refundDocumentAiSlot(userId);
@@ -455,10 +465,15 @@ async function handleVisionSummary(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return finishSummary(request, userId, document.id, "vision", mode, result);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return summaryError(err, "vision");
}
}
@@ -93,7 +93,11 @@ vi.mock("@/lib/ai/coach/facts", () => ({
}));
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 3000 })),
reserveBudget: vi.fn(async () => ({
allowed: true,
reserved: 3000,
owner: "user" as const,
})),
reconcileSpend: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
@@ -101,6 +105,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({
@@ -205,7 +211,11 @@ function abortError(): Error {
describe("coach chat — client abort mid-generation (#781)", () => {
beforeEach(() => {
vi.clearAllMocks();
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
reserveBudget.mockResolvedValue({
allowed: true,
reserved: 3000,
owner: "user" as const,
});
// No-tools chain so the turn runs the streaming runner (which rejects
// with the abort the torn-down connection produces).
resolveProviderChain.mockResolvedValue([
@@ -236,7 +246,16 @@ describe("coach chat — client abort mid-generation (#781)", () => {
await post(abortedChatReq({ message: "How is my BP?" }));
await sse.done;
expect(reconcileSpend).toHaveBeenCalledWith("u1", 3000, 0, "2026-08-13");
expect(reconcileSpend).toHaveBeenCalledWith(
"u1",
3000,
0,
"2026-08-13",
0,
// No hop served the cancelled turn, so nothing is attributed to an
// owner — the reservation is reversed on both counters.
{ servedBy: null, reservedOwner: "user" },
);
const actions = annotate.mock.calls
.map((c) => (c[0] as { action?: { name?: string } }).action?.name)
.filter(Boolean);
@@ -86,6 +86,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
reconcileSpend: vi.fn(async () => undefined),
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({
detectRefusal: vi.fn(() => ({ refuse: false })),
@@ -134,6 +134,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
const { detectRefusal } = vi.hoisted(() => ({
@@ -127,6 +127,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
const { detectRefusal } = vi.hoisted(() => ({
@@ -109,6 +109,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
const { detectRefusal } = vi.hoisted(() => ({
@@ -101,6 +101,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn(),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
@@ -100,6 +100,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
})),
reconcileSpend: vi.fn(),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
@@ -103,6 +103,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
reconcileSpend: vi.fn(async () => undefined),
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({
detectRefusal: vi.fn(() => ({ refuse: false })),
@@ -123,8 +123,9 @@ const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
allowed: boolean;
reserved: number;
totalAfter?: number;
owner?: "operator" | "user";
}>
>(async () => ({ allowed: true, reserved: 3000 })),
>(async () => ({ allowed: true, reserved: 3000, owner: "user" as const })),
reconcileSpend: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
@@ -132,6 +133,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
const { detectRefusal } = vi.hoisted(() => ({
@@ -266,7 +269,11 @@ function chatReq(body: Record<string, unknown>): Request {
describe("coach chat — tool-mode routing (F1)", () => {
beforeEach(() => {
vi.clearAllMocks();
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
reserveBudget.mockResolvedValue({
allowed: true,
reserved: 3000,
owner: "user" as const,
});
detectRefusal.mockReturnValue({ refuse: false });
checkRateLimit.mockResolvedValue({ allowed: true });
assertConsentForChain.mockResolvedValue(undefined);
@@ -318,6 +325,8 @@ describe("coach chat — tool-mode routing (F1)", () => {
4500,
"2026-06-21",
2_000_000,
"user",
"coach",
);
});
@@ -347,6 +356,8 @@ describe("coach chat — tool-mode routing (F1)", () => {
3000,
0,
expect.anything(),
0,
{ servedBy: null, reservedOwner: "user" },
);
});
@@ -558,6 +569,8 @@ describe("coach chat — tool-mode routing (F1)", () => {
1500,
"2026-06-21",
2_000_000,
"user",
"coach",
);
// The legacy path ships the snapshot figures in the user turn.
const params = (
@@ -177,6 +177,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
reconcileSpend: vi.fn(async () => undefined),
resolveDailyCap: vi.fn(() => 2_000_000),
resolveDailyCapFor: vi.fn(() => 2_000_000),
resolveCostOwner: vi.fn(() => "user" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({
detectRefusal: vi.fn(() => ({ refuse: false })),
+31 -6
View File
@@ -52,6 +52,7 @@ import {
runStreamingRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import { singleUserTurn, type CompletionResult } from "@/lib/ai/types";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
@@ -74,6 +75,7 @@ import {
buildDateKey,
reserveBudget,
reconcileSpend,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { detectRefusal } from "@/lib/ai/coach/refusal";
@@ -603,11 +605,26 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
: AI_BUDGETS.coach.maxTokens,
reqDateKey,
dailyCap,
resolveCostOwner(chain),
"coach",
);
if (!reservation.allowed) {
// v1.38.19 — say WHICH ceiling refused and WHOSE.
// `totalAfter` alone is the day's mixed total; on an operator refusal that
// is not the counter that tripped, and reading `totalAfter: 1200000`
// against a 200 000 ceiling is what turned the 06:42Z incident into a
// production log dig. Integers and two small enums — no key, host or model
// name is added.
annotate({
action: { name: "coach.budget.exceeded" },
meta: { totalAfter: reservation.totalAfter },
meta: {
owner: reservation.owner,
surface: "coach",
limit: reservation.limit,
cap: dailyCap,
totalAfter: reservation.totalAfter,
operatorAfter: reservation.operatorAfter,
},
});
return streamProviderError({ code: "coach.budget.exceeded" });
}
@@ -637,7 +654,10 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
async function produceReply(): Promise<ReplyOutcome> {
let result: CompletionResult;
let workingProviderType: string;
// v1.38.19 — typed as the chain's provider union, not a bare
// string: the budget reconcile attributes the turn's tokens to the cost
// owner of the hop named here.
let workingProviderType: ProviderChainType;
let toolTrace: CoachToolTrace[] = [];
// v1.21.0 (P6) — the present tool-result payloads this turn, for the post-hoc
// prose number-verifier. Empty on the no-tools path.
@@ -702,7 +722,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
timeoutMs: aiResponseTimeoutMs,
});
result = loop.result;
workingProviderType = loop.workingProviderType;
// The loop reports the hop it landed on as a bare string; it is
// assigned from `workingProvider.providerType` one frame up.
workingProviderType = loop.workingProviderType as ProviderChainType;
toolTrace = loop.toolTrace;
// v1.32.1 — the numeric verifier ACTIVATES only when this turn actually
// delivered figures the model was told to ground against: a pinned
@@ -740,6 +762,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// returned in full so every guard below still runs on the complete text.
let streamedDeltas = 0;
const fallback = await runStreamingRawCompletionWithFallback({
surface: "coach",
userId,
providers: chain,
onDelta: () => {
@@ -784,9 +807,10 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
} catch (err) {
// The provider chain failed outright — no tokens were billed, so refund
// the full reservation before surfacing the error frame.
await reconcileSpend(userId, reservation.reserved, 0, reqDateKey).catch(
() => {},
);
await reconcileSpend(userId, reservation.reserved, 0, reqDateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
}).catch(() => {});
// #781 — the client walked away mid-generation. The request's abort
// signal is threaded into every provider call, so the teardown surfaces
// here as an abort-shaped failure with `request.signal` already flipped.
@@ -887,6 +911,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
totalTokensSpent,
reqDateKey,
cachedTokensSpent,
{ servedBy: workingProviderType, reservedOwner: reservation.owner },
).catch(() => {
// Ledger reconcile is best-effort; a failure leaves the conservative
// reservation in place (never an undercount) and never breaks the turn.
@@ -39,7 +39,7 @@ vi.mock("@/lib/db", () => ({
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$queryRaw: vi.fn(async () => [{ total_tokens: 0, operator_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: vi.fn(async () => ({
@@ -183,7 +183,7 @@ beforeEach(() => {
// `clearAllMocks` does not undo a `mockImplementation`, so the ledger stub is
// re-seeded per test — otherwise the over-cap case below leaks into the rest.
vi.mocked(prisma.$queryRaw).mockImplementation((async () => [
{ total_tokens: 0 },
{ total_tokens: 0, operator_tokens: 0 },
]) as never);
selfContextText.mockResolvedValue(null);
vi.mocked(checkRateLimit).mockResolvedValue({
@@ -254,8 +254,8 @@ describe("POST /api/insights/generate — daily token ceiling", () => {
strings: TemplateStringsArray,
) =>
String(strings[0]).includes("coach_usage")
? [{ total_tokens: 10_000_000 }]
: [{ total_tokens: 0 }]) as never);
? [{ total_tokens: 10_000_000, operator_tokens: 10_000_000 }]
: [{ total_tokens: 0, operator_tokens: 0 }]) as never);
makeWorkingProvider();
const res = await POST(jsonRequest({ force: true }) as never);
@@ -45,6 +45,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/labs/ocr-extract", async () => {
const actual = await vi.importActual<typeof import("@/lib/labs/ocr-extract")>(
@@ -95,6 +97,8 @@ beforeEach(() => {
allowed: true,
reserved: AI_BUDGETS.ocrExtractText.maxTokens ?? 0,
totalAfter: AI_BUDGETS.ocrExtractText.maxTokens ?? 0,
owner: "operator",
operatorAfter: AI_BUDGETS.ocrExtractText.maxTokens ?? 0,
} as never);
});
@@ -116,6 +120,10 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => {
"2026-06-26",
// F1 — the provider-aware daily cap (mocked) is threaded as the 4th arg.
200_000,
// ...the cost owner that cap is enforced against as the 5th...
"operator",
// ...and the surface the ceiling is rationed for as the 6th.
"coach",
);
});
@@ -134,6 +142,8 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => {
AI_BUDGETS.ocrExtractText.maxTokens,
0,
"2026-06-26",
0,
{ servedBy: null, reservedOwner: "operator" },
);
});
@@ -182,6 +192,8 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
allowed: true,
reserved: AI_BUDGETS.ocrExtract.maxTokens ?? 0,
totalAfter: AI_BUDGETS.ocrExtract.maxTokens ?? 0,
owner: "operator",
operatorAfter: AI_BUDGETS.ocrExtract.maxTokens ?? 0,
} as never);
});
@@ -222,6 +234,8 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
AI_BUDGETS.ocrExtract.maxTokens,
0,
"2026-06-26",
0,
{ servedBy: null, reservedOwner: "operator" },
);
});
});
+55 -12
View File
@@ -41,6 +41,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
@@ -183,6 +184,8 @@ async function handleTextExtract(
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
annotate({
@@ -208,12 +211,17 @@ async function handleTextExtract(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return apiSuccess(result);
} catch (err) {
// A failed structuring call produced no usable rows; mirror the vision
// path and refund the reservation in full rather than charging it.
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof OcrExtractError) {
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
errorCode: "labs.ocr.extractFailed",
@@ -281,6 +289,8 @@ async function handleVisionExtract(
AI_BUDGETS.ocrExtract.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
annotate({
@@ -303,7 +313,10 @@ async function handleVisionExtract(
action: { name: "labs.ocr.fileRejected" },
meta: { reason: "content_length_exceeded" },
});
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return apiError("File is too large (max 12 MB).", 413, {
errorCode: "labs.ocr.fileTooLarge",
});
@@ -316,7 +329,10 @@ async function handleVisionExtract(
headers: { "content-type": request.headers.get("content-type") ?? "" },
}).formData();
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (err instanceof BodyTooLargeError) {
annotate({
action: { name: "labs.ocr.fileRejected" },
@@ -331,7 +347,10 @@ async function handleVisionExtract(
const file = formData.get("file");
if (!(file instanceof File)) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return apiError("Field 'file' must be a file", 422);
}
@@ -339,7 +358,10 @@ async function handleVisionExtract(
try {
buffer = Buffer.from(await file.arrayBuffer());
} catch {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return apiError("Failed to read uploaded file", 400);
}
@@ -350,7 +372,10 @@ async function handleVisionExtract(
action: { name: "labs.ocr.fileRejected" },
meta: { reason: "unsupported_mime" },
});
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, {
errorCode: "labs.ocr.fileType",
});
@@ -388,7 +413,10 @@ async function handleVisionExtract(
action: { name: "labs.ocr.fileRejected" },
meta: { reason: "pdf_rasterize_failed" },
});
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return apiError(
"Couldn't read this PDF; upload a photo instead.",
422,
@@ -416,12 +444,26 @@ async function handleVisionExtract(
// The orchestration does not surface token counts; reconcile against the
// reserved estimate as the spend ceiling (the provider already billed it).
actualTokens = reservation.reserved;
await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey);
await reconcileSpend(
userId,
reservation.reserved,
actualTokens,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
return apiSuccess(result);
} catch (err) {
// Provider/extraction failure — the call may still have burned tokens, so
// reconcile against the reserved estimate rather than refunding in full.
await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey);
await reconcileSpend(
userId,
reservation.reserved,
actualTokens,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
if (err instanceof OcrExtractError) {
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
errorCode: "labs.ocr.extractFailed",
@@ -439,9 +481,10 @@ async function handleVisionExtract(
}
} catch (err) {
// A guard threw after the reservation (e.g. consent races) — refund fully.
await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch(
() => {},
);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
}).catch(() => {});
throw err;
}
}
@@ -44,6 +44,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
// returns a distinctive figure so an accidental default-cap regression is
// visible in the assertion rather than silently passing.
resolveDailyCap: vi.fn(() => 1_234_567),
resolveDailyCapFor: vi.fn(() => 1_234_567),
resolveCostOwner: vi.fn(() => "user" as const),
}));
vi.mock("@/lib/ai/provider", () => ({
@@ -118,6 +120,9 @@ beforeEach(() => {
allowed: true,
reserved: 700,
totalAfter: 700,
owner: "user",
operatorAfter: 0,
limit: null,
});
vi.mocked(resolveDailyCap).mockReturnValue(1_234_567);
vi.mocked(resolveProviderChain).mockResolvedValue([
@@ -274,6 +279,8 @@ describe("POST /api/medications/extract — daily budget", () => {
expect.any(Number),
"2026-05-28",
1_234_567,
"user",
"coach",
);
});
@@ -283,6 +290,9 @@ describe("POST /api/medications/extract — daily budget", () => {
allowed: false,
reserved: 700,
totalAfter: 9_999_999,
owner: "user",
operatorAfter: 0,
limit: "owner-cap",
});
const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
@@ -307,6 +317,8 @@ describe("POST /api/medications/extract — daily budget", () => {
700,
120,
"2026-05-28",
0,
{ servedBy: "openai", reservedOwner: "user" },
);
});
@@ -323,6 +335,8 @@ describe("POST /api/medications/extract — daily budget", () => {
700,
700,
"2026-05-28",
0,
{ servedBy: "openai", reservedOwner: "user" },
);
});
@@ -335,7 +349,14 @@ describe("POST /api/medications/extract — daily budget", () => {
const res = await POST(postReq({ text: "5mg weekly" }) as never);
expect(res.status).toBe(503);
expect(reconcileSpend).toHaveBeenCalledWith("user-1", 700, 0, "2026-05-28");
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
700,
0,
"2026-05-28",
0,
{ servedBy: null, reservedOwner: "user" },
);
});
});
+12 -4
View File
@@ -70,6 +70,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import {
@@ -210,6 +211,8 @@ async function handleExtract(request: NextRequest): Promise<Response> {
estimatedTokens,
dateKey,
resolveDailyCap(chain),
resolveCostOwner(chain),
"coach",
);
if (!reservation.allowed) {
annotate({
@@ -222,6 +225,7 @@ async function handleExtract(request: NextRequest): Promise<Response> {
let completion;
try {
completion = await runRawCompletionWithFallback({
surface: "coach",
userId,
providers: chain,
params: singleUserTurn({
@@ -241,9 +245,10 @@ async function handleExtract(request: NextRequest): Promise<Response> {
} catch (err) {
// Nothing was generated — refund the whole reservation rather than bill an
// invented figure. Best-effort: a failed refund must not mask the 503.
await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch(
() => {},
);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
}).catch(() => {});
if (err instanceof AllProvidersFailedError) {
annotate({
action: { name: "medications.extract.provider-failed" },
@@ -260,7 +265,10 @@ async function handleExtract(request: NextRequest): Promise<Response> {
// a free retry loop. Falls back to the reservation when the provider reports
// no count, so an unreported generation is never billed as zero.
const actualTokens = completion.result.tokensUsed ?? reservation.reserved;
await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey);
await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey, 0, {
servedBy: completion.workingProvider.providerType,
reservedOwner: reservation.owner,
});
const reply = completion.result.content?.trim() ?? "";
if (!reply) {
@@ -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 — 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 — 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")}
@@ -280,6 +280,7 @@ describe("the chain runner and the health ledger", () => {
const failure = (await runRawCompletionWithFallback({
userId: "u1",
surface: "coach",
providers: [
{
providerType: "openai-compatible",
@@ -318,6 +319,7 @@ describe("the chain runner and the health ledger", () => {
const failure = (await runRawCompletionWithFallback({
userId: "u2",
surface: "coach",
providers: [
{
providerType: "openai-compatible",
@@ -350,6 +352,7 @@ describe("the chain runner and the health ledger", () => {
const outcome = await runRawCompletionWithFallback({
userId: "u3",
surface: "coach",
providers: [
{
providerType: "openai-compatible",
+190 -2
View File
@@ -13,13 +13,16 @@ import {
// v1.37.19 (A7-2) — the runner consults the day's recorded spend before an
// operator-funded hop. These pure chain tests do not stand up a DB, so the
// ledger read is swapped for a controllable in-memory figure.
const budgetState = vi.hoisted(() => ({ spent: 0 }));
const budgetState = vi.hoisted(() => ({ spent: 0, operatorSpent: 0 }));
vi.mock("../coach/budget", async () => {
const actual =
await vi.importActual<typeof import("../coach/budget")>("../coach/budget");
return {
...actual,
readDailySpend: vi.fn(async () => budgetState.spent),
readDailySpend: vi.fn(async () => ({
total: budgetState.spent,
operator: budgetState.operatorSpent,
})),
};
});
@@ -111,6 +114,7 @@ function err(status: number, msg = "boom"): Error & { httpStatus: number } {
beforeEach(() => {
clearLastWorkingProviderCache();
budgetState.spent = 0;
budgetState.operatorSpent = 0;
});
afterEach(() => {
@@ -169,6 +173,7 @@ describe("runRawCompletionWithFallback — last-working cache", () => {
// First call: codex fails, openai succeeds.
await runRawCompletionWithFallback({
userId: "u-cache",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
@@ -182,6 +187,7 @@ describe("runRawCompletionWithFallback — last-working cache", () => {
// never re-invoked even though it appears first in the input chain.
await runRawCompletionWithFallback({
userId: "u-cache",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
@@ -212,6 +218,7 @@ describe("runRawCompletionWithFallback — last-working cache", () => {
// First call: codex fails, openai succeeds, cache = openai.
await runRawCompletionWithFallback({
userId: "u-ttl",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
@@ -228,6 +235,7 @@ describe("runRawCompletionWithFallback — last-working cache", () => {
// and this time codex's second scripted entry succeeds).
await runRawCompletionWithFallback({
userId: "u-ttl",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
@@ -244,6 +252,7 @@ describe("runRawCompletionWithFallback — last-working cache", () => {
});
await runRawCompletionWithFallback({
userId: "u-clear",
surface: "coach",
providers: [{ providerType: "openai", instance: ok }],
params: singleUserTurn({ system: "s", user: "u" }),
});
@@ -265,6 +274,7 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
});
const result = await runRawCompletionWithFallback({
userId: "u-raw",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: openai },
@@ -292,6 +302,7 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
});
const result = await runRawCompletionWithFallback({
userId: "u-raw-auth-advance",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
@@ -320,6 +331,7 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
try {
await runRawCompletionWithFallback({
userId: "u-raw-allfail",
surface: "coach",
providers: [
{ providerType: "codex", instance: a },
{ providerType: "admin-openai", instance: b },
@@ -357,6 +369,7 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
try {
await runRawCompletionWithFallback({
userId: "u-raw-codex400",
surface: "coach",
providers: [{ providerType: "codex", instance: codex }],
params: singleUserTurn({ system: "s", user: "u" }),
});
@@ -379,6 +392,7 @@ describe("runRawCompletionWithFallback — empty input", () => {
try {
await runRawCompletionWithFallback({
userId: "u-empty",
surface: "coach",
providers: [],
params: singleUserTurn({ system: "s", user: "u" }),
});
@@ -413,6 +427,7 @@ describe("provider-health ledger — auth-failure negative cache", () => {
// First call: codex 401 → openai succeeds. Ledger now benches codex.
await runRawCompletionWithFallback({
userId: "u-neg",
surface: "coach",
providers,
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
@@ -430,6 +445,7 @@ describe("provider-health ledger — auth-failure negative cache", () => {
const codexCallsBefore = codex.callCount;
await runRawCompletionWithFallback({
userId: "u-neg",
surface: "coach",
providers,
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
@@ -449,6 +465,7 @@ describe("provider-health ledger — auth-failure negative cache", () => {
// A direct success on codex (e.g. user re-linked) clears the row.
await runRawCompletionWithFallback({
userId: "u-clear-neg",
surface: "coach",
providers: [{ providerType: "codex", instance: codex }],
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
@@ -484,6 +501,7 @@ describe("provider-health ledger — local model as guaranteed floor", () => {
const result = await runRawCompletionWithFallback({
userId: "u-floor",
surface: "coach",
providers: [
{ providerType: "codex", instance: dead("codex") },
{ providerType: "openai", instance: dead("admin-key") },
@@ -513,6 +531,7 @@ describe("provider-health ledger — local model as guaranteed floor", () => {
try {
await runRawCompletionWithFallback({
userId: "u-local-hard",
surface: "coach",
providers: [{ providerType: "local", instance: local }],
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
@@ -527,6 +546,7 @@ describe("provider-health ledger — local model as guaranteed floor", () => {
clearLastWorkingProviderCache();
const result = await runRawCompletionWithFallback({
userId: "u-local-hard",
surface: "coach",
providers: [{ providerType: "local", instance: local }],
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
@@ -550,6 +570,7 @@ describe("AllProvidersFailedError — primaryCredentialExpired", () => {
try {
await runRawCompletionWithFallback({
userId: "u-primary-auth",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: openai },
@@ -578,6 +599,7 @@ describe("AllProvidersFailedError — primaryCredentialExpired", () => {
try {
await runRawCompletionWithFallback({
userId: "u-primary-5xx",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: openai },
@@ -633,6 +655,7 @@ describe("runStreamingRawCompletionWithFallback", () => {
const deltas: string[] = [];
const result = await runStreamingRawCompletionWithFallback({
userId: "u-stream",
surface: "coach",
providers: [{ providerType: "local", instance: provider }],
params: singleUserTurn({ system: "s", user: "u", timeoutMs: 12345 }),
onDelta: (d) => deltas.push(d),
@@ -655,6 +678,7 @@ describe("runStreamingRawCompletionWithFallback", () => {
const deltas: string[] = [];
const result = await runStreamingRawCompletionWithFallback({
userId: "u-nostream",
surface: "coach",
providers: [{ providerType: "local", instance: provider }],
params: singleUserTurn({ system: "s", user: "u" }),
onDelta: (d) => deltas.push(d),
@@ -673,6 +697,7 @@ describe("runStreamingRawCompletionWithFallback", () => {
const good = new StreamingProvider("ok");
const result = await runStreamingRawCompletionWithFallback({
userId: "u-cascade",
surface: "coach",
providers: [
{ providerType: "openai", instance: broken },
{ providerType: "local", instance: good },
@@ -697,6 +722,7 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () =>
it("refuses the admin-* fallback hop once the operator cap is exhausted", async () => {
budgetState.spent = 200_000; // OPERATOR_COST_CAP
budgetState.operatorSpent = 200_000;
const codex = new ScriptedProvider({
type: "codex",
script: [{ ok: false, error: err(500) }],
@@ -706,6 +732,7 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () =>
await expect(
runRawCompletionWithFallback({
userId: "u-cap",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: adminOpenai },
@@ -727,6 +754,7 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () =>
it("lets the admin-* fallback run while the operator cap has headroom", async () => {
budgetState.spent = 100;
budgetState.operatorSpent = 100;
const codex = new ScriptedProvider({
type: "codex",
script: [{ ok: false, error: err(500) }],
@@ -735,6 +763,7 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () =>
const outcome = await runRawCompletionWithFallback({
userId: "u-headroom",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: adminOpenai },
@@ -745,11 +774,170 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () =>
expect(adminOpenai.callCount).toBe(1);
});
// v1.38.19 — the guard reads the OPERATOR-funded share.
//
// Production evidence (2026-09-11): the operator's day held ~1.2 M tokens,
// nearly all served by `codex` on his own ChatGPT plan. Comparing the day's
// TOTAL against the operator ceiling closed the operator's own fallback hop
// on money he had never spent.
it("lets the admin-* hop run on a big day the user's own plan paid for", async () => {
budgetState.spent = 1_200_000;
budgetState.operatorSpent = 150_000;
const codex = new ScriptedProvider({
type: "codex",
script: [{ ok: false, error: err(500) }],
});
const adminOpenai = new ScriptedProvider({ script: [{ ok: true }] });
const outcome = await runRawCompletionWithFallback({
userId: "u-owner-split",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: adminOpenai },
],
params,
});
expect(outcome.workingProvider.providerType).toBe("admin-openai");
expect(adminOpenai.callCount).toBe(1);
});
it("refuses the admin-* hop once the OPERATOR-funded share is exhausted", async () => {
budgetState.spent = 1_200_000;
budgetState.operatorSpent = 250_000;
const codex = new ScriptedProvider({
type: "codex",
script: [{ ok: false, error: err(500) }],
});
const adminOpenai = new ScriptedProvider({ script: [{ ok: true }] });
await expect(
runRawCompletionWithFallback({
userId: "u-owner-exhausted",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: adminOpenai },
],
params,
}),
).rejects.toMatchObject({
attempts: [
expect.objectContaining({ providerType: "codex" }),
expect.objectContaining({
providerType: "admin-openai",
failureReason: "operator-cost-cap-exhausted",
}),
],
});
expect(adminOpenai.callCount).toBe(0);
});
// v1.38.19 — the hop guard knows which SURFACE asked.
//
// Without this the job share was enforced at reservation time only: a
// background reservation admitted under `resolveDailyCapFor("job", …)` could
// then reach an operator-funded FALLBACK hop and spend against the full
// 200 000. That is the original defect by another route — chain
// `[codex, admin-openai]` reserves under the user-plan ceiling, `codex` 429s,
// every job falls back onto the shared key, and background work fills the
// operator's ceiling before the user opens the chat.
it("refuses a JOB's admin-* fallback hop once only the reserve is left", async () => {
budgetState.spent = 160_000;
budgetState.operatorSpent = 160_000;
const codex = new ScriptedProvider({
type: "codex",
script: [{ ok: false, error: err(500) }],
});
const adminOpenai = new ScriptedProvider({ script: [{ ok: true }] });
await expect(
runRawCompletionWithFallback({
userId: "u-job-share",
surface: "job",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: adminOpenai },
],
params,
}),
).rejects.toMatchObject({
attempts: [
expect.objectContaining({ providerType: "codex" }),
expect.objectContaining({
providerType: "admin-openai",
failureReason: "operator-cost-cap-exhausted",
}),
],
});
expect(adminOpenai.callCount).toBe(0);
});
it("still lets the interactive chat take that same hop", async () => {
// The whole point of the reserve: at 160 000 operator tokens the background
// work is done for the day and the person waiting is not.
budgetState.spent = 160_000;
budgetState.operatorSpent = 160_000;
const codex = new ScriptedProvider({
type: "codex",
script: [{ ok: false, error: err(500) }],
});
const adminOpenai = new ScriptedProvider({ script: [{ ok: true }] });
const outcome = await runRawCompletionWithFallback({
userId: "u-job-share-chat",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: adminOpenai },
],
params,
});
expect(outcome.workingProvider.providerType).toBe("admin-openai");
expect(adminOpenai.callCount).toBe(1);
});
it("bounds the operator hop on the day's mixed total as well", async () => {
// `operator_tokens` returns to ~0 on every reconcile the user's own plan
// settled, so the operator arm alone leaves a runaway loop unbounded. The
// abuse ceiling on the total is checked at the hop for the same reason it
// is checked at reservation time.
budgetState.spent = 2_000_000;
budgetState.operatorSpent = 0;
const codex = new ScriptedProvider({
type: "codex",
script: [{ ok: false, error: err(500) }],
});
const adminOpenai = new ScriptedProvider({ script: [{ ok: true }] });
await expect(
runRawCompletionWithFallback({
userId: "u-total-ceiling",
surface: "coach",
providers: [
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: adminOpenai },
],
params,
}),
).rejects.toMatchObject({
attempts: [
expect.objectContaining({ providerType: "codex" }),
expect.objectContaining({
providerType: "admin-openai",
failureReason: "operator-cost-cap-exhausted",
}),
],
});
expect(adminOpenai.callCount).toBe(0);
});
it("never consults the ledger for a user-funded chain", async () => {
budgetState.spent = 999_999_999;
const openai = new ScriptedProvider({ script: [{ ok: true }] });
const outcome = await runRawCompletionWithFallback({
userId: "u-own-key",
surface: "coach",
providers: [{ providerType: "openai", instance: openai }],
params,
});
+380 -7
View File
@@ -4,7 +4,11 @@ import {
buildDateKey,
OPERATOR_COST_CAP,
USER_PLAN_CAP,
INTERACTIVE_RESERVE_SHARE,
resolveCostOwner,
resolveDailyCap,
resolveDailyCapFor,
resolveTotalCapFor,
} from "../budget";
vi.mock("@/lib/db", () => ({
@@ -60,19 +64,40 @@ describe("reserveBudget — ledger clamps", () => {
it("clamps a non-finite reservation to 0", async () => {
const { reserveBudget } = await import("../budget");
const res = await reserveBudget("u", Number.NaN, "2026-05-10");
const res = await reserveBudget(
"u",
Number.NaN,
"2026-05-10",
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.reserved).toBe(0);
});
it("clamps a negative reservation to 0", async () => {
const { reserveBudget } = await import("../budget");
const res = await reserveBudget("u", -42, "2026-05-10");
const res = await reserveBudget(
"u",
-42,
"2026-05-10",
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.reserved).toBe(0);
});
it("floors a fractional reservation", async () => {
const { reserveBudget } = await import("../budget");
const res = await reserveBudget("u", 12.7, "2026-05-10");
const res = await reserveBudget(
"u",
12.7,
"2026-05-10",
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.reserved).toBe(12);
});
});
@@ -143,6 +168,8 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => {
1_200,
"2026-05-10",
resolveDailyCap([{ providerType: "codex" }]),
resolveCostOwner([{ providerType: "codex" }]),
"coach",
);
expect(res.allowed).toBe(true);
// The reservation upsert ran; no refund executeRaw fired.
@@ -152,7 +179,7 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => {
it("the operator-key path STILL trips once prior spend reaches the operator cap", async () => {
const priorSpend = OPERATOR_COST_CAP;
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: priorSpend + 1_200 },
{ total_tokens: priorSpend + 1_200, operator_tokens: priorSpend + 1_200 },
]);
prismaMock.$executeRaw.mockResolvedValue(0);
const { reserveBudget } = await import("../budget");
@@ -161,6 +188,8 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => {
1_200,
"2026-05-10",
resolveDailyCap([{ providerType: "admin-openai" }]),
resolveCostOwner([{ providerType: "admin-openai" }]),
"coach",
);
expect(res.allowed).toBe(false);
// Refund of the reservation fired on refusal.
@@ -168,6 +197,341 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => {
});
});
/**
* v1.38.19 — the cap is enforced against the counter it is ABOUT.
*
* Production evidence (2026-09-11): the operator's day held 1.0–1.45 M tokens,
* almost all served by `codex` on his own ChatGPT plan after the shared
* `admin-openai` key answered 500 — and the chat refused at
* `totalAfter: 513539` against the 200 k operator ceiling. Comparing the total
* is the defect; the operator ceiling may only see operator-funded tokens.
*/
/**
* v1.38.20 — the interactive surface is never locked out by background work.
*
* Production evidence (2026-09-11): 150–330 ledger rows a day on the
* operator's account, 172 `insights.metric` generations by 06:42Z — the
* automatic jobs had spent the day's ceiling before he opened the chat.
*
* The guarantee is a floor under the person waiting, NOT a quota over the
* jobs: background generation runs freely until the day's spend would leave
* less than the reserve for interactive use. That distinction is the whole
* design. An account doing 1.45 M tokens of background work a day on its own
* plan costs nobody anything and keeps all of it; what it cannot do is take
* the last 400 000 the chat is holding.
*/
describe("resolveDailyCapFor — the interactive reserve", () => {
it("lets a background surface reach everything but the reserve", () => {
expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe(
OPERATOR_COST_CAP -
Math.floor(OPERATOR_COST_CAP * INTERACTIVE_RESERVE_SHARE),
);
expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe(
160_000,
);
});
it("leaves the interactive surfaces the whole ceiling", () => {
expect(
resolveDailyCapFor("coach", [{ providerType: "admin-openai" }]),
).toBe(OPERATOR_COST_CAP);
expect(resolveDailyCapFor("coach", [{ providerType: "codex" }])).toBe(
USER_PLAN_CAP,
);
});
it("holds the reserve on a chain the operator does not pay for too", () => {
// The earlier cut exempted these chains entirely, on the grounds that the
// operator's invoice is what a quota protects. True of a quota, and beside
// the point here: a self-hoster's chat is locked out by his own background
// jobs exactly as painfully, and the recommended remedy for the operator's
// own account — put Codex first — moves him onto this arm. The reserve is
// about who is waiting, not about who is billed.
expect(resolveDailyCapFor("job", [{ providerType: "codex" }])).toBe(
1_600_000,
);
expect(resolveDailyCapFor("job", [{ providerType: "local" }])).toBe(
1_600_000,
);
// ...and what is held back is the reserve, exactly.
expect(
USER_PLAN_CAP - resolveDailyCapFor("job", [{ providerType: "codex" }]),
).toBe(400_000);
});
});
describe("resolveTotalCapFor — the abuse ceiling", () => {
it("keeps the user-plan ceiling on the day's mixed total", () => {
// The operator arm compares `operator_tokens`, a counter that returns to
// ~0 on every reconcile the user's own plan settled. Without a ceiling on
// the total, an operator-primary chain has no ceiling at all: every job
// reservation is admitted forever and a runaway client loop writes an
// unbounded row.
expect(resolveTotalCapFor("coach")).toBe(USER_PLAN_CAP);
});
it("holds the same reserve on the total for a background surface", () => {
expect(resolveTotalCapFor("job")).toBe(1_600_000);
expect(USER_PLAN_CAP - resolveTotalCapFor("job")).toBe(400_000);
});
});
describe("reserveBudget — the cap follows the cost owner", () => {
let prismaMock: {
$queryRaw: ReturnType<typeof vi.fn>;
$executeRaw: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
const dbModule = await import("@/lib/db");
prismaMock = dbModule.prisma as unknown as typeof prismaMock;
prismaMock.$queryRaw.mockReset();
prismaMock.$executeRaw.mockReset();
prismaMock.$executeRaw.mockResolvedValue(0);
});
it("admits an operator turn on a day the user's own plan filled", async () => {
// 1.2 M tokens on the day, but only 150 k of them on the operator's key.
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: 1_200_000 + 1_200, operator_tokens: 150_000 + 1_200 },
]);
const { reserveBudget } = await import("../budget");
const res = await reserveBudget(
"u",
1_200,
"2026-09-11",
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.allowed).toBe(true);
expect(res.operatorAfter).toBe(151_200);
expect(prismaMock.$executeRaw).not.toHaveBeenCalled();
});
it("refuses an operator turn once the OPERATOR-funded share reaches the cap", async () => {
prismaMock.$queryRaw.mockResolvedValue([
{
total_tokens: 1_200_000 + 1_200,
operator_tokens: OPERATOR_COST_CAP + 1_200,
},
]);
const { reserveBudget } = await import("../budget");
const res = await reserveBudget(
"u",
1_200,
"2026-09-11",
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.allowed).toBe(false);
expect(prismaMock.$executeRaw).toHaveBeenCalled();
});
it("keeps the user-plan ceiling on the TOTAL, not on the operator share", async () => {
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: USER_PLAN_CAP + 1_200, operator_tokens: 0 },
]);
const { reserveBudget } = await import("../budget");
const res = await reserveBudget(
"u",
1_200,
"2026-09-11",
USER_PLAN_CAP,
"user",
"coach",
);
expect(res.allowed).toBe(false);
});
// v1.38.19 — the abuse ceiling survives the owner split.
//
// The first cut compared `operator_tokens` and NOTHING else for an
// operator-funded chain. On the 2026-09-11 shape that counter never grows:
// `admin-openai` 500s, `codex` serves, and every reconcile moves the
// reservation back out of the operator's column. So an operator-primary chain
// had no ceiling at all — every job reservation admitted forever, and a
// runaway client loop free to write an unbounded row.
it("refuses a JOB on an operator chain once the day's TOTAL fills its share", async () => {
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: 1_900_000 + 1_200, operator_tokens: 0 },
]);
const { reserveBudget, resolveDailyCapFor } = await import("../budget");
const res = await reserveBudget(
"u",
1_200,
"2026-09-11",
resolveDailyCapFor("job", [{ providerType: "admin-openai" }]),
"operator",
"job",
);
expect(res.allowed).toBe(false);
expect(res.limit).toBe("total-cap");
expect(prismaMock.$executeRaw).toHaveBeenCalled();
});
it("still admits the interactive chat on that same day", async () => {
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: 1_900_000 + 1_200, operator_tokens: 0 },
]);
const { reserveBudget } = await import("../budget");
const res = await reserveBudget(
"u",
1_200,
"2026-09-11",
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.allowed).toBe(true);
expect(res.limit).toBeNull();
});
it("refuses an operator chain once the TOTAL reaches the abuse ceiling", async () => {
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: USER_PLAN_CAP + 1_200, operator_tokens: 0 },
]);
const { reserveBudget } = await import("../budget");
const res = await reserveBudget(
"u",
1_200,
"2026-09-11",
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.allowed).toBe(false);
expect(res.limit).toBe("total-cap");
});
// v1.38.20 — the reserve, from the reservation's side.
//
// `resolveDailyCapFor` only answers what a surface MAY reach; these pin what
// actually happens at the edge of it, on both cost owners, because the claim
// the ceiling exists to support is behavioural: the person waiting gets a
// turn no matter how much background work ran first.
it("admits the chat on a USER-funded chain whose background work is at its limit", async () => {
// Nobody is billed for this egress — the user's own ChatGPT plan served
// every one of those tokens — so the jobs were never rationed. They still
// cannot take the last of the day.
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: 1_600_000 + 3_000, operator_tokens: 0 },
]);
const { reserveBudget, resolveDailyCapFor, resolveCostOwner } =
await import("../budget");
const chain = [{ providerType: "codex" as const }];
const job = await reserveBudget(
"u",
3_000,
"2026-09-11",
resolveDailyCapFor("job", chain),
resolveCostOwner(chain),
"job",
);
expect(job.allowed).toBe(false);
const chat = await reserveBudget(
"u",
3_000,
"2026-09-11",
resolveDailyCapFor("coach", chain),
resolveCostOwner(chain),
"coach",
);
expect(chat.allowed).toBe(true);
expect(chat.limit).toBeNull();
});
it("admits the chat on an OPERATOR-funded chain whose background work is at its limit", async () => {
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: 160_000 + 3_000, operator_tokens: 160_000 + 3_000 },
]);
const { reserveBudget, resolveDailyCapFor, resolveCostOwner } =
await import("../budget");
const chain = [{ providerType: "admin-openai" as const }];
const job = await reserveBudget(
"u",
3_000,
"2026-09-11",
resolveDailyCapFor("job", chain),
resolveCostOwner(chain),
"job",
);
expect(job.allowed).toBe(false);
expect(job.limit).toBe("owner-cap");
const chat = await reserveBudget(
"u",
3_000,
"2026-09-11",
resolveDailyCapFor("coach", chain),
resolveCostOwner(chain),
"coach",
);
expect(chat.allowed).toBe(true);
});
it("admits the background reservation one token below that boundary", async () => {
// The ledger's rule throughout is that a request is admitted when the spend
// BEFORE it was under the ceiling, so the boundary is exact: at the ceiling
// the job stops, one token below it runs.
const { reserveBudget, resolveDailyCapFor, resolveCostOwner } =
await import("../budget");
const chain = [{ providerType: "codex" as const }];
const jobCap = resolveDailyCapFor("job", chain);
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: jobCap - 1 + 3_000, operator_tokens: 0 },
]);
const admitted = await reserveBudget(
"u",
3_000,
"2026-09-11",
jobCap,
resolveCostOwner(chain),
"job",
);
expect(admitted.allowed).toBe(true);
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: jobCap + 3_000, operator_tokens: 0 },
]);
const refused = await reserveBudget(
"u",
3_000,
"2026-09-11",
jobCap,
resolveCostOwner(chain),
"job",
);
expect(refused.allowed).toBe(false);
});
it("names the owner cap when that is the ceiling that tripped", async () => {
prismaMock.$queryRaw.mockResolvedValue([
{
total_tokens: 300_000 + 1_200,
operator_tokens: OPERATOR_COST_CAP + 1_200,
},
]);
const { reserveBudget } = await import("../budget");
const res = await reserveBudget(
"u",
1_200,
"2026-09-11",
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.allowed).toBe(false);
expect(res.limit).toBe("owner-cap");
expect(res.operatorAfter).toBe(OPERATOR_COST_CAP);
});
});
describe("reconcileSpend cached-token subtraction (F3)", () => {
let prismaMock: { $executeRaw: ReturnType<typeof vi.fn> };
@@ -181,7 +545,10 @@ describe("reconcileSpend cached-token subtraction (F3)", () => {
it("bills total_tokens minus cached input as the signed delta", async () => {
const { reconcileSpend } = await import("../budget");
// reserved 1200, gross 20000, cached 13000 → net actual 7000 → delta 5800.
await reconcileSpend("u", 1_200, 20_000, "2026-05-10", 13_000);
await reconcileSpend("u", 1_200, 20_000, "2026-05-10", 13_000, {
servedBy: "admin-openai",
reservedOwner: "operator",
});
expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1);
// The tagged-template interpolations carry the delta; assert it is 5800.
const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1);
@@ -190,7 +557,10 @@ describe("reconcileSpend cached-token subtraction (F3)", () => {
it("clamps a cached count larger than gross to a zero charge (delta = -reserved)", async () => {
const { reconcileSpend } = await import("../budget");
await reconcileSpend("u", 1_200, 5_000, "2026-05-10", 9_999);
await reconcileSpend("u", 1_200, 5_000, "2026-05-10", 9_999, {
servedBy: "admin-openai",
reservedOwner: "operator",
});
const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1);
// net actual clamped to 0 → delta = 0 - 1200 = -1200.
expect(interpolations).toContain(-1_200);
@@ -198,7 +568,10 @@ describe("reconcileSpend cached-token subtraction (F3)", () => {
it("defaults cachedTokens to 0 (back-compat) — bills gross", async () => {
const { reconcileSpend } = await import("../budget");
await reconcileSpend("u", 1_000, 4_000, "2026-05-10");
await reconcileSpend("u", 1_000, 4_000, "2026-05-10", 0, {
servedBy: "admin-openai",
reservedOwner: "operator",
});
const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1);
expect(interpolations).toContain(3_000);
});
@@ -41,9 +41,13 @@ const budgetMocks = vi.hoisted(() => ({
allowed: true,
reserved: 300,
totalAfter: 300,
owner: "operator" as const,
operatorAfter: 300,
})),
reconcileSpend: vi.fn(async () => {}),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
@@ -87,6 +91,8 @@ beforeEach(() => {
allowed: true,
reserved: 300,
totalAfter: 300,
owner: "operator" as const,
operatorAfter: 300,
});
buildCoachSnapshot.mockResolvedValue({ snapshotJson: '{"weight":[]}' });
});
@@ -239,6 +245,8 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
300,
"2026-07-18",
200_000,
"operator",
"coach",
);
expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith(
"user-1",
@@ -246,6 +254,7 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
120,
"2026-07-18",
0,
{ servedBy: "openai", reservedOwner: "operator" },
);
});
@@ -254,6 +263,8 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
allowed: false,
reserved: 300,
totalAfter: 200_000,
owner: "operator" as const,
operatorAfter: 200_000,
});
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
@@ -273,6 +284,8 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
300,
0,
"2026-07-18",
0,
{ servedBy: null, reservedOwner: "operator" },
);
});
});
+246 -24
View File
@@ -17,6 +17,16 @@
import { prisma } from "@/lib/db";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
/**
* v1.38.19 — the two raw statements the ledger owns can
* run on the singleton client OR inside an open transaction. The arrival
* reaction reserves its tokens in the same transaction that links the
* reservation to the claimed marker, and used to carry its own copy of the
* upsert to do it; that copy drifted twice. One implementation, an executor
* argument, no second copy to drift.
*/
export type BudgetExecutor = Pick<typeof prisma, "$queryRaw" | "$executeRaw">;
/**
* Operator-cost daily ceiling — the cap that protects the OPERATOR's LLM bill.
*
@@ -63,12 +73,91 @@ export const USER_PLAN_CAP = 2_000_000;
export function resolveDailyCap(
chain: ReadonlyArray<{ providerType: ProviderChainType }>,
): number {
const primary = chain[0]?.providerType;
return primary === undefined || isOperatorFundedProvider(primary)
return resolveCostOwner(chain) === "operator"
? OPERATOR_COST_CAP
: USER_PLAN_CAP;
}
/**
* v1.38.20 — the share of a day's ceiling that stays RESERVED for whoever is
* waiting on an answer, whatever the automatic generators have already spent.
*
* Production evidence (2026-09-11): the operator's account wrote 150–330
* ledger rows a day, 172 of them `insights.metric` generations before 06:42Z.
* The automatic work had eaten the day before he opened the chat.
*
* A reserve, deliberately, and not a quota on the jobs. The two look alike at
* the boundary and mean opposite things in the middle: a quota stops useful
* background work on a day nobody was ever going to interrupt, while a reserve
* costs that work nothing until the moment it would take the last of the day.
* An instance generating 1.45 M tokens of insights on its own plan keeps every
* one of them; what it cannot do is leave the chat with nothing. At 20 % of
* `USER_PLAN_CAP` the interactive floor is 400 000 tokens — a day of questions.
*/
export const INTERACTIVE_RESERVE_SHARE = 0.2;
/**
* v1.38.19 — which kind of caller is asking. `"coach"` is every
* interactive surface (the chat, the extraction routes, the document routes,
* the connection probe): a person is waiting. `"job"` is the automatic
* generators, which run with nobody waiting and must leave the interactive
* surfaces a day of their own.
*/
export type BudgetSurface = "coach" | "job";
/**
* v1.38.20 — how far one surface may take a ceiling. An interactive caller may
* reach all of it; a background caller must stop short by the reserve.
*
* Applies to BOTH cost owners. An earlier cut exempted chains the operator
* does not fund, reasoning that a ration exists to protect the operator's
* invoice — true of a ration, and beside the point for a reserve. A
* self-hoster's own automatic generators lock him out of his own chat exactly
* as effectively as the operator's do, on hardware nobody is billed for; and
* the recommended remedy for the operator's own lockout — put his personal
* Codex account first in the chain — moves him onto precisely that arm, where
* the exemption would have left the guarantee empty on the one configuration
* it was written for.
*/
function withInteractiveReserve(surface: BudgetSurface, cap: number): number {
return surface === "job"
? cap - Math.floor(cap * INTERACTIVE_RESERVE_SHARE)
: cap;
}
/**
* v1.38.19 — the daily ceiling for one SURFACE on a chain. `"coach"`
* (every interactive surface: the chat, the extraction routes, the document
* routes, the connection probe) gets the whole ceiling; `"job"` (the automatic
* generators) must leave the interactive reserve behind. The cost owner is
* unchanged — this only says how much of the owner's ceiling a background
* surface may claim.
*/
export function resolveDailyCapFor(
surface: BudgetSurface,
chain: ReadonlyArray<{ providerType: ProviderChainType }>,
): number {
return withInteractiveReserve(surface, resolveDailyCap(chain));
}
/**
* v1.38.19 — the ABUSE ceiling on the day's mixed total, for one surface.
*
* `resolveDailyCapFor` answers "how much of the owner's ceiling may this
* surface claim", and for an operator-funded chain that ceiling is measured
* against `operator_tokens` — a counter that stays near zero whenever the
* operator's key fails and the user's own plan serves the fallback. That is
* the correct answer for the operator's invoice and a useless one for the row:
* on the 2026-09-11 evidence the operator counter never grew, so nothing
* bounded the 1.0–1.45 M tokens a day of background generation, and nothing
* bounded a runaway client loop either. `USER_PLAN_CAP` is the ceiling on the
* total whoever paid, and a background surface leaves the same reserve behind
* here as it does on the owner's ceiling.
*/
export function resolveTotalCapFor(surface: BudgetSurface): number {
return withInteractiveReserve(surface, USER_PLAN_CAP);
}
/**
* v1.37.19 (A7-2) — does this provider spend the OPERATOR's money?
* `admin-openai` (the server's own API key) and `admin-codex` (the
@@ -80,6 +169,28 @@ export function isOperatorFundedProvider(type: ProviderChainType): boolean {
return type === "admin-openai" || type === "admin-codex";
}
/**
* v1.38.19 — who pays for a turn. `"operator"` means the tokens land
* on the operator's invoice (`admin-openai` / `admin-codex`), `"user"` means
* the user's own plan, key or hardware carries them.
*/
export type BudgetCostOwner = "operator" | "user";
/**
* v1.38.19 — the cost owner a chain RESERVES under: the primary is
* the provider that will be tried first, so it is the expected payer. An empty
* chain defaults to the operator — the conservative side. `resolveDailyCap`
* and `reserveBudget` must agree on this classification, so both read it here.
*/
export function resolveCostOwner(
chain: ReadonlyArray<{ providerType: ProviderChainType }>,
): BudgetCostOwner {
const primary = chain[0]?.providerType;
return primary === undefined || isOperatorFundedProvider(primary)
? "operator"
: "user";
}
/**
* v1.37.19 (A7-2) — the day's recorded spend for one user (0 when no row).
*
@@ -90,15 +201,30 @@ export function isOperatorFundedProvider(type: ProviderChainType): boolean {
* hop must not spend past `OPERATOR_COST_CAP` just because the reservation
* was checked against the wrong owner's ceiling.
*/
export interface DailySpend {
/** Every token recorded for the day, whoever paid for it. */
total: number;
/**
* v1.38.19 — the share of `total` served by an operator-funded
* provider. The hop guard compares THIS against `OPERATOR_COST_CAP`: a day
* full of turns the user's own plan paid for must not close the operator's
* fallback hop.
*/
operator: number;
}
export async function readDailySpend(
userId: string,
dateKey: string = buildDateKey(),
): Promise<number> {
): Promise<DailySpend> {
const row = await prisma.coachUsage.findUnique({
where: { userId_dateKey: { userId, dateKey } },
select: { totalTokens: true },
select: { totalTokens: true, operatorTokens: true },
});
return row?.totalTokens ?? 0;
return {
total: row?.totalTokens ?? 0,
operator: row?.operatorTokens ?? 0,
};
}
/**
@@ -132,53 +258,115 @@ export function buildDateKey(at: Date = new Date()): string {
* reserved-but-failed turn still counts as an attempt.
*/
export interface ReserveBudgetResult {
/** True when the reservation kept the day's total within the cap. */
/** True when the reservation kept the day's spend within the cap. */
allowed: boolean;
/** Tokens reserved by this call (refunded/reconciled by the caller). */
reserved: number;
/** The day's total AFTER this reservation (for observability). */
totalAfter: number;
/**
* v1.38.19 — the owner this reservation was charged to. The
* reconcile needs it: only a reservation booked to the operator has an
* amount to move back OUT of `operator_tokens` when a user-funded hop ends
* up serving the turn.
*/
owner: BudgetCostOwner;
/** The day's operator-funded spend AFTER this reservation. */
operatorAfter: number;
/**
* v1.38.19 — which ceiling refused this reservation,
* `null` when it was admitted. `"owner-cap"` is the cost owner's daily
* ceiling (the operator's invoice, or the user's own plan); `"total-cap"` is
* the abuse ceiling on the day's mixed total. The refusal annotations carry
* it: before this, every refusal printed one number, and on an operator
* refusal that number was the day's mixed total rather than the counter that
* tripped — the same unreadable signal that made the 06:42Z diagnosis a
* production log dig.
*/
limit: "owner-cap" | "total-cap" | null;
}
export async function reserveBudget(
userId: string,
estimatedTokens: number,
dateKey: string = buildDateKey(),
cap: number = OPERATOR_COST_CAP,
dateKey: string,
cap: number,
owner: BudgetCostOwner,
surface: BudgetSurface,
db: BudgetExecutor = prisma,
): Promise<ReserveBudgetResult> {
const reserved =
Number.isFinite(estimatedTokens) && estimatedTokens > 0
? Math.floor(estimatedTokens)
: 0;
// v1.38.19 — a reservation is booked to `operator_tokens` only when
// the chain's primary is operator-funded. The counter moves inside the SAME
// statement as the total, so two concurrent requests can never observe one
// counter without the other.
const operatorReserved = owner === "operator" ? reserved : 0;
// Single atomic upsert-increment returning the new total. Two concurrent
// Single atomic upsert-increment returning the new totals. Two concurrent
// requests serialise on the row's unique (user_id, date_key) constraint, so
// each observes a distinct post-increment total — they cannot both read a
// sub-cap value and both proceed.
const rows = await prisma.$queryRaw<{ total_tokens: number }[]>`
INSERT INTO coach_usage (id, user_id, date_key, total_tokens, message_count, created_at, updated_at)
VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, 1, NOW(), NOW())
const rows = await db.$queryRaw<
{ total_tokens: number; operator_tokens: number }[]
>`
INSERT INTO coach_usage (id, user_id, date_key, total_tokens, operator_tokens, message_count, created_at, updated_at)
VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, ${operatorReserved}, 1, NOW(), NOW())
ON CONFLICT (user_id, date_key) DO UPDATE SET
total_tokens = coach_usage.total_tokens + ${reserved},
operator_tokens = coach_usage.operator_tokens + ${operatorReserved},
message_count = coach_usage.message_count + 1,
updated_at = NOW()
RETURNING total_tokens
RETURNING total_tokens, operator_tokens
`;
const totalAfter = Number(rows[0]?.total_tokens ?? reserved);
const operatorAfter = Number(rows[0]?.operator_tokens ?? operatorReserved);
// The cap is a ceiling on tokens already spent BEFORE this request, matching
// the prior `spent >= cap` semantics: a request is allowed when the spend
// PRIOR to its reservation was under the cap. So compare `totalAfter -
// reserved` (the prior total) against the cap.
// PRIOR to its reservation was under the cap. So compare the post-increment
// figure minus this reservation against the cap.
//
// v1.38.19 — and compare the counter the cap is ABOUT. An
// operator-funded chain is measured against the day's operator-funded spend,
// never against a total that the user's own plan inflated; a user-plan chain
// keeps its abuse ceiling on the total.
const priorTotal = totalAfter - reserved;
if (priorTotal >= cap) {
const priorOperator = operatorAfter - operatorReserved;
const prior = owner === "operator" ? priorOperator : priorTotal;
// v1.38.19 — BOTH ceilings, always. The owner's cap
// protects whoever pays; the total cap is the abuse ceiling that keeps a
// runaway loop — or a day of background generation the operator's key never
// served — from writing an unbounded row. Dropping the second one for
// operator chains left them with no ceiling at all, because their counter
// returns to ~0 on every reconcile the user's own plan settled.
const totalCap = resolveTotalCapFor(surface);
const limit: ReserveBudgetResult["limit"] =
prior >= cap ? "owner-cap" : priorTotal >= totalCap ? "total-cap" : null;
if (limit !== null) {
// Already over before this request — refund the reservation + the
// message-count bump and refuse.
await refundReservation(userId, reserved, dateKey);
return { allowed: false, reserved, totalAfter: priorTotal };
await refundReservation(userId, reserved, operatorReserved, dateKey, db);
return {
allowed: false,
reserved,
totalAfter: priorTotal,
owner,
operatorAfter: priorOperator,
limit,
};
}
return { allowed: true, reserved, totalAfter };
return {
allowed: true,
reserved,
totalAfter,
owner,
operatorAfter,
limit: null,
};
}
/**
@@ -194,12 +382,24 @@ export async function reserveBudget(
* served most of it cheaply / free; charging the user's daily meter for input
* they did not re-pay for is an over-charge. We bill `actual - cached`.
*/
export interface ReconcileSpendOptions {
/**
* The provider that actually SERVED the turn (`runRawWithFallback`'s
* `workingProvider.providerType`), or `null` when no hop served — a failed,
* timed-out or cancelled call whose reservation is being refunded.
*/
servedBy: ProviderChainType | null;
/** The owner the reservation was booked to (`reserveBudget(...).owner`). */
reservedOwner: BudgetCostOwner;
}
export async function reconcileSpend(
userId: string,
reserved: number,
actualTokens: number,
dateKey: string = buildDateKey(),
cachedTokens = 0,
dateKey: string,
cachedTokens: number,
opts: ReconcileSpendOptions,
): Promise<void> {
const grossActual =
Number.isFinite(actualTokens) && actualTokens > 0
@@ -213,12 +413,31 @@ export async function reconcileSpend(
// (shouldn't happen, but the wire is untrusted) can't drive a negative charge.
const actual = Math.max(0, grossActual - cached);
const delta = actual - reserved;
if (delta === 0) return;
// v1.38.19 — settle the operator's counter against the hop that
// SERVED, not the one the chain expected. Book the actual tokens when an
// operator-funded provider answered, and take back whatever this
// reservation had put there (nothing, when the reservation was the user's
// own plan — subtracting then would eat another request's legitimate
// operator balance). The four cases settle exactly:
// operator reservation + operator hop → `actual`
// operator reservation + user hop → 0 (the reservation moves out)
// user reservation + user hop → untouched
// user reservation + operator hop → `actual` (a fallback the
// operator really paid for)
const servedByOperator =
opts.servedBy !== null && isOperatorFundedProvider(opts.servedBy);
const operatorDelta =
(servedByOperator ? actual : 0) -
(opts.reservedOwner === "operator" ? reserved : 0);
if (delta === 0 && operatorDelta === 0) return;
// Clamp at zero so a smaller-than-reserved actual can't drive the row
// negative under a racing reconcile.
// negative under a racing reconcile. Both counters move in ONE statement:
// a concurrent reader can never see the total settled and the owner split
// still stale.
await prisma.$executeRaw`
UPDATE coach_usage
SET total_tokens = GREATEST(0, total_tokens + ${delta}),
operator_tokens = GREATEST(0, operator_tokens + ${operatorDelta}),
updated_at = NOW()
WHERE user_id = ${userId} AND date_key = ${dateKey}
`;
@@ -228,11 +447,14 @@ export async function reconcileSpend(
async function refundReservation(
userId: string,
reserved: number,
operatorReserved: number,
dateKey: string,
db: BudgetExecutor = prisma,
): Promise<void> {
await prisma.$executeRaw`
await db.$executeRaw`
UPDATE coach_usage
SET total_tokens = GREATEST(0, total_tokens - ${reserved}),
operator_tokens = GREATEST(0, operator_tokens - ${operatorReserved}),
message_count = GREATEST(0, message_count - 1),
updated_at = NOW()
WHERE user_id = ${userId} AND date_key = ${dateKey}
+8 -3
View File
@@ -45,6 +45,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import {
@@ -235,6 +236,8 @@ export async function deriveClarifyingQuestions(
QUESTIONS_MAX_TOKENS,
dateKey,
resolveDailyCap(chain),
resolveCostOwner(chain),
"coach",
);
if (!reservation.allowed) return fallback();
@@ -266,9 +269,10 @@ export async function deriveClarifyingQuestions(
} catch (err) {
// Reconcile the reservation down to zero actual spend before the
// failure lands on the deterministic fallback.
await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch(
() => {},
);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
}).catch(() => {});
throw err;
}
@@ -278,6 +282,7 @@ export async function deriveClarifyingQuestions(
result.tokensUsed ?? 0,
dateKey,
result.cachedInputTokens ?? 0,
{ servedBy: chain[0].providerType, reservedOwner: reservation.owner },
).catch(() => {});
const questions = parseQuestionsReply(result.content);
+2
View File
@@ -141,6 +141,8 @@ export async function runCoachToolLoop(args: {
const fallback = await runRawCompletionWithFallback({
userId,
providers,
// The tool loop IS the Coach chat: a person is waiting on every round.
surface: "coach",
ledger,
params: {
system,
+38 -5
View File
@@ -8,8 +8,10 @@ import {
import { annotate } from "@/lib/logging/context";
import {
isOperatorFundedProvider,
OPERATOR_COST_CAP,
readDailySpend,
resolveDailyCapFor,
resolveTotalCapFor,
type BudgetSurface,
} from "@/lib/ai/coach/budget";
/**
@@ -399,6 +401,7 @@ async function runRawChain(
userId: string;
providers: ProviderChainResolved[];
params: CompletionParams;
surface: BudgetSurface;
ledger?: ProviderHealthLedger;
},
invoke: (candidate: ProviderChainResolved) => Promise<CompletionResult>,
@@ -406,7 +409,7 @@ async function runRawChain(
candidate: ProviderChainResolved,
) => Record<string, string | number | boolean | null>,
): Promise<RunRawWithFallbackResult> {
const { userId, providers } = args;
const { userId, providers, surface } = args;
const ledger = args.ledger ?? postgresProviderHealthLedger;
if (providers.length === 0) {
@@ -431,8 +434,28 @@ async function runRawChain(
// Fail OPEN on a ledger read error: the reservation layer still
// meters every call against the same ledger, so a transient read
// failure here must not take the whole fallback chain down with it.
const spent = await readDailySpend(userId).catch(() => 0);
if (spent >= OPERATOR_COST_CAP) {
const spent = await readDailySpend(userId).catch(() => ({
total: 0,
operator: 0,
}));
// v1.38.19 — the OPERATOR-funded share, not the day's total: a
// day filled by the user's own plan must not close the operator's
// fallback hop on money the operator never spent.
//
// v1.38.19 — and at the SURFACE's ceiling, not the
// full one. A background reservation is rationed at the job share when it
// is taken; without this the hop it reaches by fallback could still spend
// against the whole operator cap, which is how the original defect stayed
// reachable. Chain `[codex, admin-openai]` reserves under the user-plan
// ceiling, `codex` 429s all morning, every job falls back onto the shared
// key, and background work walks `operator_tokens` to 200 000 before the
// user opens the chat. The abuse ceiling on the day's mixed total is
// checked here for the same reason it is checked at reservation time.
const hopCap = resolveDailyCapFor(surface, [
{ providerType: candidate.providerType },
]);
const hopTotalCap = resolveTotalCapFor(surface);
if (spent.operator >= hopCap || spent.total >= hopTotalCap) {
const hop: FallbackHop = {
providerType: candidate.providerType,
attempt: i + 1,
@@ -445,7 +468,9 @@ async function runRawChain(
meta: {
[`ai_chain_hop_${i + 1}_provider`]: candidate.providerType,
[`ai_chain_hop_${i + 1}_reason`]: "operator-cost-cap-exhausted",
operator_cap_spent: spent,
operator_cap_spent: spent.operator,
operator_cap_limit: hopCap,
ai_chain_surface: surface,
},
});
continue;
@@ -514,6 +539,12 @@ export async function runStreamingRawCompletionWithFallback(args: {
userId: string;
providers: ProviderChainResolved[];
params: CompletionParams;
/**
* v1.38.19 — which ceiling an operator-funded FALLBACK
* hop is rationed against. `"job"` gets the background share, exactly as the
* reservation did; `"coach"` gets the whole ceiling.
*/
surface: BudgetSurface;
/** Called once per streamed token/chunk for providers that stream. */
onDelta: (delta: string) => void;
/** See `RunWithFallbackParams.ledger`. */
@@ -560,6 +591,8 @@ export async function runRawCompletionWithFallback(args: {
userId: string;
providers: ProviderChainResolved[];
params: CompletionParams;
/** See {@link runStreamingRawCompletionWithFallback}'s `surface`. */
surface: BudgetSurface;
/** See `RunWithFallbackParams.ledger`. */
ledger?: ProviderHealthLedger;
}): Promise<RunRawWithFallbackResult> {
@@ -32,6 +32,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-16"),
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCapFor: vi.fn(() => 100_000),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { ocrExtractText: { temperature: 0, maxTokens: 2000 } },
@@ -99,6 +100,8 @@ beforeEach(() => {
allowed: true,
reserved: 10,
totalAfter: 10,
owner: "operator",
operatorAfter: 10,
} as Awaited<ReturnType<typeof reserveBudget>>);
mockExtract.mockResolvedValue({
reportDate: "2026-07-10",
@@ -29,6 +29,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 600 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 100000),
resolveDailyCapFor: vi.fn(() => 100000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
runStreamingRawCompletionWithFallback: vi.fn(),
@@ -45,6 +45,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { documentTranscribe: { temperature: 0, maxTokens: 4000 } },
@@ -106,6 +108,7 @@ beforeEach(() => {
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
reserved: 1,
owner: "operator",
} as never);
vi.mocked(localExtractText).mockResolvedValue({
ok: true,
@@ -132,7 +135,14 @@ describe("indexDocumentContent — provider-first path", () => {
expect.objectContaining({ source: "vision", providerType: "anthropic" }),
);
// Budget reserved then reconciled at the full reserved amount (charged).
expect(reconcileSpend).toHaveBeenCalledWith("user-1", 1, 1, "2026-07-07");
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
1,
1,
"2026-07-07",
0,
{ servedBy: expect.any(String), reservedOwner: "operator" },
);
// The local path is never touched when the provider succeeds.
expect(localExtractText).not.toHaveBeenCalled();
});
@@ -222,7 +232,14 @@ describe("indexDocumentContent — local fallback path", () => {
const outcome = await indexDocumentContent("user-1", "doc-1");
expect(outcome).toMatchObject({ indexed: true, source: "local-pdf" });
// Reservation refunded to zero spend.
expect(reconcileSpend).toHaveBeenCalledWith("user-1", 1, 0, "2026-07-07");
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
1,
0,
"2026-07-07",
0,
{ servedBy: null, reservedOwner: "operator" },
);
expect(localExtractText).toHaveBeenCalledTimes(1);
});
@@ -56,6 +56,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { documentTranscribe: { temperature: 0, maxTokens: 4000 } },
+18 -3
View File
@@ -32,6 +32,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import { resolveIndexProvider } from "@/lib/documents/index-document";
@@ -276,13 +277,19 @@ export async function maybeAutoStageLabFacts(
}
// A text-structuring pass, not a vision call — reserve the proportionate
// ceiling under the same daily cap the auto-index resolved.
// 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 reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
provider.dailyCap,
resolveDailyCapFor("job", [
{ providerType: provider.pick.entry.providerType },
]),
provider.costOwner,
"job",
);
if (!reservation.allowed) return { staged: false, reason: "budget" };
@@ -298,9 +305,17 @@ export async function maybeAutoStageLabFacts(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{
servedBy: provider.pick.entry.providerType,
reservedOwner: reservation.owner,
},
);
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
if (!(err instanceof InboundExtractError)) {
annotate({
action: { name: "documents.autoStage.failed" },
+9 -3
View File
@@ -43,6 +43,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { detectRefusal, type CoachRefusalReason } from "@/lib/ai/coach/refusal";
@@ -307,6 +308,8 @@ export async function streamFencedReply(
AI_BUDGETS.documentChat.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
annotate({ action: { name: "documents.chat.budget.exceeded" } });
@@ -369,6 +372,7 @@ Reply now as the assistant, grounded ONLY in the documents above, in ${
let result;
try {
const fallback = await runStreamingRawCompletionWithFallback({
surface: "coach",
userId,
// Single-provider — the document-ordered pick, no cascade.
providers: [pick!.entry],
@@ -383,9 +387,10 @@ Reply now as the assistant, grounded ONLY in the documents above, in ${
});
result = fallback.result;
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch(
() => {},
);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
}).catch(() => {});
if (err instanceof AllProvidersFailedError) {
const allRateLimited =
err.attempts.length > 0 &&
@@ -425,6 +430,7 @@ Reply now as the assistant, grounded ONLY in the documents above, in ${
totalTokens,
dateKey,
cachedTokens,
{ servedBy: pick!.entry.providerType, reservedOwner: reservation.owner },
).catch(() => {});
let replyText = (result.content ?? "").trim();
+23 -2
View File
@@ -36,7 +36,9 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
type BudgetCostOwner,
} from "@/lib/ai/coach/budget";
import {
loadOwnedDocument,
@@ -128,6 +130,12 @@ export interface ResolvedIndexProvider {
pick: Awaited<ReturnType<typeof resolveDocumentVisionProvider>>["pick"];
consentOk: boolean;
dailyCap: number;
/**
* v1.38.19 — whose budget `dailyCap` rations. The reservation books
* `operator_tokens` only for an operator-funded pick, so the cap and the
* counter it is compared against are resolved from one place.
*/
costOwner: BudgetCostOwner;
}
/**
@@ -160,7 +168,13 @@ export async function resolveIndexProvider(
pick && consentOk
? resolveDailyCap([{ providerType: pick.entry.providerType }])
: 0;
return { chain, pick, consentOk, dailyCap };
// v1.38.19 — the cap and its cost owner are resolved from the SAME
// pick, so the reservation books the counter the cap is enforced against.
const costOwner: BudgetCostOwner =
pick && consentOk
? resolveCostOwner([{ providerType: pick.entry.providerType }])
: "operator";
return { chain, pick, consentOk, dailyCap, costOwner };
}
/**
@@ -221,6 +235,8 @@ async function tryProviderIndex(
AI_BUDGETS.documentTranscribe.maxTokens,
dateKey,
provider.dailyCap,
provider.costOwner,
"coach",
);
// Budget exhausted → fall through to the free local path rather than stall;
// a text-layer PDF stays searchable even once the AI allowance is spent.
@@ -238,6 +254,8 @@ async function tryProviderIndex(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
// Refs #776 — the empty-transcription guard: a provider answer with no
// text must NEVER become a "successful" empty index (it would mark the
@@ -261,7 +279,10 @@ async function tryProviderIndex(
} catch {
// Refund the reservation and let the caller fall through to local — a
// transient provider miss must never leave a text-layer PDF unsearchable.
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return { outcome: null, note: "provider-error" };
}
}
@@ -21,6 +21,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
/** In-memory stand-in for the day's `coach_usage.total_tokens`. */
let ledgerTotal = 0;
/** …and of `coach_usage.operator_tokens`. */
let ledgerOperator = 0;
vi.mock("@/lib/db", () => ({
prisma: {
@@ -28,8 +30,10 @@ vi.mock("@/lib/db", () => ({
// (strings, userId, dateKey, reserved, reserved).
$queryRaw: vi.fn(
async (_strings: TemplateStringsArray, ...v: unknown[]) => {
// (userId, dateKey, reserved, operatorReserved, …).
ledgerTotal += Number(v[2] ?? 0);
return [{ total_tokens: ledgerTotal }];
ledgerOperator += Number(v[3] ?? 0);
return [{ total_tokens: ledgerTotal, operator_tokens: ledgerOperator }];
},
),
// `reconcileSpend` (+delta) and `refundReservation` (-reserved).
@@ -37,9 +41,17 @@ vi.mock("@/lib/db", () => ({
async (strings: TemplateStringsArray, ...v: unknown[]) => {
const sql = strings.join("?");
const amount = Number(v[0] ?? 0);
if (sql.includes("total_tokens + ")) ledgerTotal += amount;
else if (sql.includes("total_tokens - ")) ledgerTotal -= amount;
// The operator-funded share moves in the same statement.
const operatorAmount = Number(v[1] ?? 0);
if (sql.includes("total_tokens + ")) {
ledgerTotal += amount;
ledgerOperator += operatorAmount;
} else if (sql.includes("total_tokens - ")) {
ledgerTotal -= amount;
ledgerOperator -= operatorAmount;
}
ledgerTotal = Math.max(0, ledgerTotal);
ledgerOperator = Math.max(0, ledgerOperator);
return 1;
},
),
@@ -104,6 +116,7 @@ function mockProviderReply(
beforeEach(() => {
vi.clearAllMocks();
ledgerTotal = 0;
ledgerOperator = 0;
});
describe("runBriefingCompletion — ledger accounting", () => {
@@ -142,6 +155,7 @@ describe("runBriefingCompletion — ledger accounting", () => {
it("refuses a generation once the day's cap is already spent", async () => {
ledgerTotal = OPERATOR_COST_CAP;
ledgerOperator = OPERATOR_COST_CAP;
mockProviderReply(500);
await expect(
@@ -201,6 +215,7 @@ describe("runBriefingCompletion — ledger accounting", () => {
describe("runBriefingCompletion — cost owner decides the ceiling", () => {
it("measures an operator-key chain against the operator ceiling", async () => {
ledgerTotal = OPERATOR_COST_CAP;
ledgerOperator = OPERATOR_COST_CAP;
mockProviderReply(100);
await expect(
@@ -214,6 +229,7 @@ describe("runBriefingCompletion — cost owner decides the ceiling", () => {
// their own key pays their own bill, so the operator's ceiling is a
// category error for them — they must still be served.
ledgerTotal = OPERATOR_COST_CAP;
ledgerOperator = OPERATOR_COST_CAP;
mockProviderReply(100);
const outcome = await runBriefingCompletion(
@@ -226,6 +242,9 @@ describe("runBriefingCompletion — cost owner decides the ceiling", () => {
it("still bounds a BYOK chain at the user-plan ceiling", async () => {
ledgerTotal = USER_PLAN_CAP;
// Not one of those tokens was the operator's: the abuse ceiling on the
// total is what must still refuse this generation.
ledgerOperator = 0;
mockProviderReply(100);
await expect(
@@ -24,7 +24,7 @@ const extractFeatures = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$queryRaw: vi.fn(async () => [{ total_tokens: 0, operator_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
@@ -24,7 +24,7 @@ vi.mock("@/lib/db", () => ({
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$queryRaw: vi.fn(async () => [{ total_tokens: 0, operator_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
@@ -15,6 +15,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
let ledgerTotal = 0;
/** …and of `coach_usage.operator_tokens`. */
let ledgerOperator = 0;
const findUnique = vi.fn();
const userUpdate = vi.fn();
@@ -29,17 +31,27 @@ vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(
async (_strings: TemplateStringsArray, ...v: unknown[]) => {
// (userId, dateKey, reserved, operatorReserved, …).
ledgerTotal += Number(v[2] ?? 0);
return [{ total_tokens: ledgerTotal }];
ledgerOperator += Number(v[3] ?? 0);
return [{ total_tokens: ledgerTotal, operator_tokens: ledgerOperator }];
},
),
$executeRaw: vi.fn(
async (strings: TemplateStringsArray, ...v: unknown[]) => {
const sql = strings.join("?");
const amount = Number(v[0] ?? 0);
if (sql.includes("total_tokens + ")) ledgerTotal += amount;
else if (sql.includes("total_tokens - ")) ledgerTotal -= amount;
// The operator-funded share moves in the same statement.
const operatorAmount = Number(v[1] ?? 0);
if (sql.includes("total_tokens + ")) {
ledgerTotal += amount;
ledgerOperator += operatorAmount;
} else if (sql.includes("total_tokens - ")) {
ledgerTotal -= amount;
ledgerOperator -= operatorAmount;
}
ledgerTotal = Math.max(0, ledgerTotal);
ledgerOperator = Math.max(0, ledgerOperator);
return 1;
},
),
@@ -110,6 +122,7 @@ const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } });
beforeEach(() => {
vi.clearAllMocks();
ledgerTotal = 0;
ledgerOperator = 0;
resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: {} },
]);
@@ -145,6 +158,7 @@ describe("generateComprehensiveInsight — daily token ceiling", () => {
it("refuses over-cap as `skipped: budget` without contacting a provider", async () => {
ledgerTotal = OPERATOR_COST_CAP;
ledgerOperator = OPERATOR_COST_CAP;
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
@@ -166,6 +180,7 @@ describe("generateComprehensiveInsight — daily token ceiling", () => {
{ providerType: "anthropic", instance: {} },
]);
ledgerTotal = OPERATOR_COST_CAP;
ledgerOperator = OPERATOR_COST_CAP;
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
@@ -178,6 +193,7 @@ describe("generateComprehensiveInsight — daily token ceiling", () => {
{ providerType: "anthropic", instance: {} },
]);
ledgerTotal = USER_PLAN_CAP;
ledgerOperator = USER_PLAN_CAP;
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
@@ -31,7 +31,7 @@ vi.mock("@/lib/db", () => ({
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$queryRaw: vi.fn(async () => [{ total_tokens: 0, operator_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
@@ -22,7 +22,7 @@ vi.mock("@/lib/db", () => ({
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$queryRaw: vi.fn(async () => [{ total_tokens: 0, operator_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
@@ -25,7 +25,7 @@ const getSelfContextTextForUser = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$queryRaw: vi.fn(async () => [{ total_tokens: 0, operator_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
@@ -26,7 +26,7 @@ vi.mock("@/lib/db", () => ({
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$queryRaw: vi.fn(async () => [{ total_tokens: 0, operator_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
@@ -28,7 +28,7 @@ vi.mock("@/lib/db", () => ({
// egress and reconciles after (`reserveBudget` / `reconcileSpend`), both
// over raw SQL. A zero prior total keeps every generation under the cap,
// so these suites keep testing what they were written to test.
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$queryRaw: vi.fn(async () => [{ total_tokens: 0, operator_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
@@ -18,6 +18,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
/** In-memory stand-in for the day's `coach_usage.total_tokens`. */
let ledgerTotal = 0;
/** …and of `coach_usage.operator_tokens`. */
let ledgerOperator = 0;
vi.mock("@/lib/db", () => ({
prisma: {
@@ -25,8 +27,10 @@ vi.mock("@/lib/db", () => ({
// (strings, userId, dateKey, reserved, reserved).
$queryRaw: vi.fn(
async (_strings: TemplateStringsArray, ...v: unknown[]) => {
// (userId, dateKey, reserved, operatorReserved, …).
ledgerTotal += Number(v[2] ?? 0);
return [{ total_tokens: ledgerTotal }];
ledgerOperator += Number(v[3] ?? 0);
return [{ total_tokens: ledgerTotal, operator_tokens: ledgerOperator }];
},
),
// `reconcileSpend` (+delta) and `refundReservation` (-reserved).
@@ -34,9 +38,17 @@ vi.mock("@/lib/db", () => ({
async (strings: TemplateStringsArray, ...v: unknown[]) => {
const sql = strings.join("?");
const amount = Number(v[0] ?? 0);
if (sql.includes("total_tokens + ")) ledgerTotal += amount;
else if (sql.includes("total_tokens - ")) ledgerTotal -= amount;
// The operator-funded share moves in the same statement.
const operatorAmount = Number(v[1] ?? 0);
if (sql.includes("total_tokens + ")) {
ledgerTotal += amount;
ledgerOperator += operatorAmount;
} else if (sql.includes("total_tokens - ")) {
ledgerTotal -= amount;
ledgerOperator -= operatorAmount;
}
ledgerTotal = Math.max(0, ledgerTotal);
ledgerOperator = Math.max(0, ledgerOperator);
return 1;
},
),
@@ -98,6 +110,7 @@ function mockProviderReply(tokensUsed: number | null) {
beforeEach(() => {
vi.clearAllMocks();
ledgerTotal = 0;
ledgerOperator = 0;
resolveProviderChain.mockResolvedValue(OPERATOR_CHAIN);
resolveProvider.mockResolvedValue({ type: "none" });
});
@@ -115,6 +128,34 @@ describe("runStatusCompletion — ledger accounting", () => {
expect(ledgerTotal).toBe(1234);
});
it("refuses a background generation that would eat the reserve while the chat still runs", async () => {
// The automatic status/reference generators must stop while the interactive
// reserve is still whole, so the chat has a day left when the jobs have had
// theirs. One token past the point where only the reserve remains:
ledgerTotal = 160_001;
ledgerOperator = 160_001;
mockProviderReply(500);
const result = await runStatusCompletion(completionArgs());
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
expect(result.kind).toBe("error");
// The same spend, reserved by the interactive coach ceiling, is admitted.
const { reserveBudget, resolveDailyCap, resolveCostOwner } =
await import("@/lib/ai/coach/budget");
const chatChain = [{ providerType: "admin-openai" as const }];
const chat = await reserveBudget(
"u1",
3_000,
"2026-09-11",
resolveDailyCap(chatChain),
resolveCostOwner(chatChain),
"coach",
);
expect(chat.allowed).toBe(true);
});
it("charges the reservation when the provider reports no token count", async () => {
mockProviderReply(null);
@@ -126,6 +167,7 @@ describe("runStatusCompletion — ledger accounting", () => {
it("refuses a generation once the day's cap is already spent", async () => {
ledgerTotal = OPERATOR_COST_CAP;
ledgerOperator = OPERATOR_COST_CAP;
mockProviderReply(500);
const result = await runStatusCompletion(completionArgs());
@@ -135,9 +177,21 @@ describe("runStatusCompletion — ledger accounting", () => {
// "no provider configured" assessment, which a budget refusal is not.
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
expect(result.kind).toBe("error");
// v1.38.19 — the refusal has to SAY which ceiling it
// hit and whose. `{ totalAfter }` alone was the day's mixed total, which on
// an operator refusal is not the counter that tripped; and the shared
// `error` outcome cannot distinguish an exhausted background share from a
// dead provider, so the annotation is the only place that can.
expect(annotate).toHaveBeenCalledWith(
expect.objectContaining({
action: { name: "insights.status.budget_exceeded" },
meta: expect.objectContaining({
owner: "operator",
surface: "job",
limit: "owner-cap",
cap: 160_000,
operatorAfter: OPERATOR_COST_CAP,
}),
}),
);
// The refused reservation was refunded, not left on the row.
@@ -158,6 +212,7 @@ describe("runStatusCompletion — cost owner decides the ceiling", () => {
it("measures an operator-key chain against the operator ceiling", async () => {
// Just under the operator ceiling: an operator-key user is refused here.
ledgerTotal = OPERATOR_COST_CAP;
ledgerOperator = OPERATOR_COST_CAP;
mockProviderReply(100);
const result = await runStatusCompletion(completionArgs());
@@ -172,6 +227,7 @@ describe("runStatusCompletion — cost owner decides the ceiling", () => {
// category error for them — they must still be served.
resolveProviderChain.mockResolvedValue(BYOK_CHAIN);
ledgerTotal = OPERATOR_COST_CAP;
ledgerOperator = OPERATOR_COST_CAP;
runRawCompletionWithFallback.mockResolvedValue({
result: { content: '{"summary":"ok"}', model: "m", tokensUsed: 100 },
workingProvider: { providerType: "anthropic" },
@@ -186,6 +242,9 @@ describe("runStatusCompletion — cost owner decides the ceiling", () => {
it("still bounds a BYOK chain at the user-plan ceiling", async () => {
resolveProviderChain.mockResolvedValue(BYOK_CHAIN);
ledgerTotal = USER_PLAN_CAP;
// Not one of those tokens was the operator's: the abuse ceiling on the
// total is what must still refuse this generation.
ledgerOperator = 0;
mockProviderReply(100);
const result = await runStatusCompletion(completionArgs());
+33 -11
View File
@@ -14,12 +14,12 @@
*
* This is the same accounting the status tier uses, not a second mechanism:
* `reserveBudget` (one atomic upsert-increment, no read-then-write window) +
* `resolveDailyCap(chain)` for the cost owner + `reconcileSpend` against the
* `resolveDailyCapFor("job", chain)` for the cost owner + `reconcileSpend` against the
* provider's reported count. Every provider call on the briefing path routes
* through here, so a retry is reserved and charged like any other call — a
* user at the ceiling does not get a free correction pass.
*
* The cap follows the COST OWNER, not the surface. `resolveDailyCap` charges
* The cap follows the COST OWNER. `resolveDailyCapFor` charges
* the operator ceiling only when the chain's primary is the operator's own
* credential (`admin-openai` / `admin-codex`); a self-hoster on their own key,
* their own ChatGPT plan, or a local model is measured against the generous
@@ -39,7 +39,8 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveDailyCap,
resolveCostOwner,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import { singleUserTurn } from "@/lib/ai/types";
import { annotate } from "@/lib/logging/context";
@@ -117,16 +118,29 @@ export async function runBriefingCompletion(
args.maxTokens +
Math.ceil((args.systemPrompt.length + args.userPrompt.length) / 4);
// v1.38.19 — a background surface: half the day's ceiling when the
// operator funds the chain.
const jobCap = resolveDailyCapFor("job", args.chain);
const reservation = await reserveBudget(
args.userId,
estimatedTokens,
dateKey,
resolveDailyCap(args.chain),
jobCap,
resolveCostOwner(args.chain),
"job",
);
if (!reservation.allowed) {
annotate({
action: { name: "insights.briefing.budget_exceeded" },
meta: { stage: args.stage, totalAfter: reservation.totalAfter },
meta: {
stage: args.stage,
owner: reservation.owner,
surface: "job",
limit: reservation.limit,
cap: jobCap,
totalAfter: reservation.totalAfter,
operatorAfter: reservation.operatorAfter,
},
});
throw new BriefingBudgetExceededError(args.stage, reservation.totalAfter);
}
@@ -135,6 +149,9 @@ export async function runBriefingCompletion(
try {
outcome = await runRawCompletionWithFallback({
userId: args.userId,
// A background generator: an operator-funded fallback hop is rationed at
// the job share, exactly as the reservation above was.
surface: "job",
providers: args.chain,
params: singleUserTurn({
system: args.systemPrompt,
@@ -153,12 +170,13 @@ export async function runBriefingCompletion(
// invented figure. A partially-burned upstream call is possible here, but
// we have no reported count to charge, and over-charging a failed
// generation would ration the retry the user is entitled to.
await reconcileSpend(args.userId, reservation.reserved, 0, dateKey).catch(
() => {
// Best-effort: a failed refund leaves the conservative reservation in
// place (never an undercount) and must not mask the provider error.
},
);
await reconcileSpend(args.userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
}).catch(() => {
// Best-effort: a failed refund leaves the conservative reservation in
// place (never an undercount) and must not mask the provider error.
});
throw err;
}
@@ -169,6 +187,10 @@ export async function runBriefingCompletion(
actualTokens,
dateKey,
outcome.result.cachedInputTokens ?? 0,
{
servedBy: outcome.workingProvider.providerType,
reservedOwner: reservation.owner,
},
).catch(() => {
// Ledger reconcile is best-effort; a failure leaves the reservation in
// place and never breaks a generation that already succeeded.
+38 -7
View File
@@ -13,7 +13,8 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveDailyCap,
resolveCostOwner,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
import { singleUserTurn } from "@/lib/ai/types";
@@ -210,7 +211,7 @@ export async function runStatusCompletion(
// prompt we are about to send — and reconcile against the provider's reported
// count afterwards, refunding in full when nothing was generated.
//
// The cap follows the COST OWNER, not the surface: `resolveDailyCap` charges
// The cap follows the COST OWNER: `resolveDailyCapFor` charges
// the operator ceiling only when the chain's primary is the operator's own
// 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
@@ -218,11 +219,16 @@ export async function runStatusCompletion(
const dateKey = buildDateKey();
const estimatedTokens =
maxTokens + Math.ceil((systemPrompt.length + userPrompt.length) / 4);
// v1.38.19 — a background surface: half the day's ceiling when the
// operator funds the chain.
const jobCap = resolveDailyCapFor("job", chain);
const reservation = await reserveBudget(
userId,
estimatedTokens,
dateKey,
resolveDailyCap(chain),
jobCap,
resolveCostOwner(chain),
"job",
);
if (!reservation.allowed) {
// Over the day's ceiling. Reported as `error` — a TRANSIENT miss the caller
@@ -230,9 +236,22 @@ export async function runStatusCompletion(
// which callers cache as the settled "no provider configured" assessment.
// The distinct annotation keeps the refusal observable even though the
// result shape is shared.
// v1.38.19 — the shared `error` outcome cannot say
// WHY the generation stopped, so the annotation has to. `surface: "job"`
// plus `cap` separates "this job used up the background share" from "the
// whole day is spent", and `limit` says which counter tripped. The operator
// sees the same split on the admin provider-health card.
annotate({
action: { name: "insights.status.budget_exceeded" },
meta: { cacheAction, totalAfter: reservation.totalAfter },
meta: {
cacheAction,
owner: reservation.owner,
surface: "job",
limit: reservation.limit,
cap: jobCap,
totalAfter: reservation.totalAfter,
operatorAfter: reservation.operatorAfter,
},
});
return { kind: "error" };
}
@@ -242,6 +261,9 @@ export async function runStatusCompletion(
runRawCompletionWithFallback({
userId,
providers: chain,
// A background generator: an operator-funded fallback hop is rationed
// at the job share, exactly as the reservation above was.
surface: "job",
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
@@ -264,11 +286,17 @@ export async function runStatusCompletion(
// A timed-out generation may still have burned upstream tokens, but we have
// no reported count to charge — refund the reservation rather than bill an
// invented figure.
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return { kind: "timeout" };
}
if (raced.errored || raced.value === null) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
return { kind: "error" };
}
@@ -279,7 +307,10 @@ export async function runStatusCompletion(
// into a free retry loop. Falls back to the reservation when the provider
// reports no count, so an unreported generation is never billed as zero.
const actualTokens = result.tokensUsed ?? reservation.reserved;
await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey);
await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey, 0, {
servedBy: workingProvider.providerType,
reservedOwner: reservation.owner,
});
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
@@ -76,6 +76,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: {
@@ -35,6 +35,8 @@ const budgetMocks = vi.hoisted(() => ({
})),
reconcileSpend: vi.fn(async () => {}),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
@@ -49,6 +49,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
@@ -50,6 +50,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { documentSummary: { temperature: 0.3, maxTokens: 600 } },
@@ -124,6 +126,7 @@ beforeEach(() => {
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
reserved: 5,
owner: "operator",
} as never);
});
@@ -151,7 +154,14 @@ describe("runDocumentSummaryJob — gating", () => {
"A lab report from a clinic listing routine blood values.",
);
// Budget reserved then reconciled at the full reserved amount (charged).
expect(reconcileSpend).toHaveBeenCalledWith("user-1", 5, 5, "2026-07-17");
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
5,
5,
"2026-07-17",
0,
{ servedBy: expect.any(String), reservedOwner: "operator" },
);
// Write is scoped to the still-null column so a re-run cannot clobber it.
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
@@ -216,7 +226,14 @@ describe("runDocumentSummaryJob — gating", () => {
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
// Reservation refunded to zero spend; no summary written, state recorded.
expect(reconcileSpend).toHaveBeenCalledWith("user-1", 5, 0, "2026-07-17");
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
5,
0,
"2026-07-17",
0,
{ servedBy: null, reservedOwner: "operator" },
);
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ data: { summaryState: "UNAVAILABLE" } }),
);
@@ -88,6 +88,9 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: (...a: unknown[]) => reserveBudget(...a),
reconcileSpend: (...a: unknown[]) => reconcileSpend(...a),
resolveDailyCap: () => 200_000,
// A background surface on an operator-funded chain: half the ceiling.
resolveDailyCapFor: () => 100_000,
resolveCostOwner: () => "operator" as const,
}));
const loadDailyDigest = vi.fn();
@@ -173,7 +176,7 @@ function expectSurfaceStillWorks() {
expect(digest.line.length).toBeGreaterThan(0);
}
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
findUnique.mockResolvedValue({
id: "r1",
@@ -191,16 +194,22 @@ beforeEach(() => {
measurementFindMany.mockResolvedValue([]);
workoutFindFirst.mockResolvedValue(null);
labResultFindMany.mockResolvedValue([]);
queryRaw.mockResolvedValue([{ total_tokens: 1_400 }]);
queryRaw.mockResolvedValue([{ total_tokens: 1_400, operator_tokens: 1_400 }]);
executeRaw.mockResolvedValue(1);
isModuleEnabled.mockResolvedValue(true);
chainRequiresServerManagedConsent.mockReturnValue(false);
hasActiveConsentForSurface.mockResolvedValue(true);
reserveBudget.mockResolvedValue({
allowed: true,
reserved: 1_400,
totalAfter: 1_400,
});
// v1.38.19 — the claim reservation is no longer a
// second copy of the upsert; it calls the shared `reserveBudget` with the
// transaction client. The spy therefore runs the REAL gate against the
// mocked `$queryRaw`, so the rows a test hands back are the ledger the gate
// reads — which is the only way this file can pin which counter it compares.
const actualBudget = await vi.importActual<
typeof import("@/lib/ai/coach/budget")
>("@/lib/ai/coach/budget");
reserveBudget.mockImplementation(
actualBudget.reserveBudget as unknown as (...a: unknown[]) => unknown,
);
reconcileSpend.mockResolvedValue(undefined);
loadDailyDigest.mockResolvedValue({
score: { value: 82, band: "good", delta: 3 },
@@ -467,6 +476,7 @@ describe("reaction line — degradation", () => {
1_400,
"2026-07-16",
0,
{ servedBy: expect.any(String), reservedOwner: "operator" },
);
});
@@ -501,7 +511,11 @@ describe("reaction line — degradation", () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: {} },
]);
queryRaw.mockResolvedValue([{ total_tokens: 201_400 }]);
// The OPERATOR-funded share of the day is spent — the counter the job's
// ceiling is about.
queryRaw.mockResolvedValue([
{ total_tokens: 101_400, operator_tokens: 101_400 },
]);
const outcome = await runReactionLine(JOB);
@@ -510,6 +524,44 @@ describe("reaction line — degradation", () => {
expectSurfaceStillWorks();
});
it("a day the user's own plan paid for still admits the reaction", async () => {
// The 2026-09-11 shape: the operator's shared key answered 500 all
// morning and `codex` — his own ChatGPT plan — served everything, so the
// day's total is large and the operator's counter is empty. The
// reservation used to compare that total against the operator's ceiling
// and refuse, locking the surface out over money the operator never spent
// — and at HALF the old ceiling, because this is a background surface.
resolveProviderChain.mockResolvedValue([
{
providerType: "openai",
instance: {
generateCompletion: vi.fn().mockResolvedValue({
content: "Logged and steady.",
tokensUsed: 90,
}),
},
},
]);
queryRaw.mockResolvedValue([
{ total_tokens: 901_400, operator_tokens: 1_400 },
]);
const outcome = await runReactionLine(JOB);
expect(outcome).not.toMatchObject({ reason: "budget_exceeded" });
// One implementation, not two: the claim transaction reserves through the
// shared gate, with the job ceiling and the transaction client.
expect(reserveBudget).toHaveBeenCalledWith(
"u1",
1_400,
"2026-07-16",
100_000,
"operator",
"job",
expect.anything(),
);
});
it("provider invocation is terminal even when the provider throws", async () => {
const generateCompletion = vi.fn().mockRejectedValue(new Error("timeout"));
resolveProviderChain.mockResolvedValue([
@@ -528,6 +580,8 @@ describe("reaction line — degradation", () => {
1_400,
1_400,
"2026-07-16",
0,
{ servedBy: expect.any(String), reservedOwner: "operator" },
);
expect(updateMany).toHaveBeenCalledWith(
expect.objectContaining({
@@ -623,6 +677,7 @@ describe("reaction line — degradation", () => {
900,
"2026-07-16",
0,
{ servedBy: expect.any(String), reservedOwner: "operator" },
);
expect(update).not.toHaveBeenCalled();
expectSurfaceStillWorks();
@@ -683,6 +738,108 @@ describe("reaction line — degradation", () => {
1_100,
"2026-07-16",
100,
{ servedBy: expect.any(String), reservedOwner: "operator" },
);
});
});
/**
* v1.38.19 — a reservation is never stranded.
*
* Every path that crosses the provider boundary has to settle the ledger, and
* settle it against the owner the reservation was actually booked to. Nothing
* outside `data-arrival.ts` reverses an arrival reservation, and there is no
* retention job on `coach_usage`, so a path that forgets leaves those tokens
* counted until the UTC day rolls — and under an operator-funded chain it
* leaves them counted against the operator's ceiling.
*
* The supersede path (a newer arrival replacing a pre-provider reservation) is
* the fourth exit and lives in the database, so it is pinned in
* `tests/integration/arrival-reaction-claim.test.ts` instead.
*/
describe("reaction line — every exit settles the reservation", () => {
const settled = {
servedBy: expect.any(String),
reservedOwner: "operator",
};
it("settles on a provider error", async () => {
resolveProviderChain.mockResolvedValue([
{
providerType: "openai",
instance: {
generateCompletion: vi.fn().mockRejectedValue(new Error("boom")),
},
},
]);
const outcome = await runReactionLine(JOB);
expect(outcome).toEqual({ status: "skipped", reason: "provider_failed" });
expect(reconcileSpend).toHaveBeenCalledTimes(1);
expect(reconcileSpend).toHaveBeenCalledWith(
"u1",
1_400,
1_400,
"2026-07-16",
0,
settled,
);
});
it("settles on a timeout", async () => {
const timeout = Object.assign(new Error("The operation was aborted"), {
name: "TimeoutError",
});
resolveProviderChain.mockResolvedValue([
{
providerType: "openai",
instance: {
generateCompletion: vi.fn().mockRejectedValue(timeout),
},
},
]);
const outcome = await runReactionLine(JOB);
expect(outcome).toEqual({ status: "skipped", reason: "provider_failed" });
// A timed-out call may still have burned the tokens upstream, so the
// conservative reservation is CHARGED rather than refunded — but it is
// settled either way, and against the right owner.
expect(reconcileSpend).toHaveBeenCalledWith(
"u1",
1_400,
1_400,
"2026-07-16",
0,
settled,
);
});
it("settles on an empty reply", async () => {
resolveProviderChain.mockResolvedValue([
{
providerType: "openai",
instance: {
generateCompletion: vi
.fn()
.mockResolvedValue({ content: " ", tokensUsed: 640 }),
},
},
]);
const outcome = await runReactionLine(JOB);
expect(outcome).toEqual({ status: "skipped", reason: "unusable_output" });
// Burned upstream even though the reply was unusable: recorded, not
// refunded into a free retry loop.
expect(reconcileSpend).toHaveBeenCalledWith(
"u1",
1_400,
640,
"2026-07-16",
0,
settled,
);
});
});
+11 -8
View File
@@ -43,7 +43,8 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveDailyCap,
resolveCostOwner,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { singleUserTurn } from "@/lib/ai/types";
@@ -190,7 +191,8 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
const budget = AI_BUDGETS.coachNudge;
const maxTokens = budget.maxTokens ?? 160;
const dailyCap = resolveDailyCap(chain);
// v1.38.19 — a background surface: half the day's ceiling.
const dailyCap = resolveDailyCapFor("job", chain);
const dateKey = buildDateKey();
// Per-user budget gate (atomic reserve; refunds itself on refusal).
@@ -199,6 +201,8 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
maxTokens,
dateKey,
dailyCap,
resolveCostOwner(chain),
"job",
);
if (!reservation.allowed) {
annotate({ action: { name: "coach.nudge.ai.budget_exceeded" } });
@@ -224,12 +228,10 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
} catch {
// Timeout / network / provider error → refund what wasn't spent and
// fall back to the template.
await reconcileSpend(
params.userId,
reservation.reserved,
0,
dateKey,
).catch(() => {});
await reconcileSpend(params.userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
}).catch(() => {});
annotate({ action: { name: "coach.nudge.ai.fallback" } });
return null;
}
@@ -240,6 +242,7 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
result.tokensUsed ?? 0,
dateKey,
result.cachedInputTokens ?? 0,
{ servedBy: chain[0].providerType, reservedOwner: reservation.owner },
).catch(() => {});
const body = sanitiseAiBody(result.content, params.locale);
+22
View File
@@ -61,6 +61,13 @@ interface LockedReaction {
occurredAt: Date;
generationReservedTokens: number | null;
generationBudgetDateKey: string | null;
/**
* v1.38.19 — `"operator"` when the reservation below was booked to
* the operator's counter, `"user"` otherwise, null on a row reserved before
* the column existed. The refund has to reverse the SAME counters the
* reservation moved, and there is no chain here to ask.
*/
generationCostOwner: string | null;
generationProviderInvokedAt: Date | null;
}
@@ -100,6 +107,7 @@ async function claimReaction(
occurred_at AS "occurredAt",
generation_reserved_tokens AS "generationReservedTokens",
generation_budget_date_key AS "generationBudgetDateKey",
generation_cost_owner AS "generationCostOwner",
generation_provider_invoked_at AS "generationProviderInvokedAt"
FROM arrival_reactions
WHERE user_id = ${arrival.userId}
@@ -117,12 +125,25 @@ async function claimReaction(
current.generationBudgetDateKey != null &&
current.generationProviderInvokedAt == null
) {
// v1.38.19 — reverse BOTH counters, or neither.
//
// The reservation booked `operator_tokens` too whenever the chain's
// primary was operator-funded. Refunding only the total left that share
// stranded: nothing else in the codebase ever subtracts it, so every
// supersede — a routine event, any second measurement of the same kind on
// the same local date before the worker runs — walked the operator's
// ceiling upward against spend that never happened.
const operatorReserved =
current.generationCostOwner === "operator"
? current.generationReservedTokens
: 0;
await tx.$executeRaw`
UPDATE coach_usage
SET total_tokens = GREATEST(
0,
total_tokens - ${current.generationReservedTokens}
),
operator_tokens = GREATEST(0, operator_tokens - ${operatorReserved}),
message_count = GREATEST(0, message_count - 1),
updated_at = NOW()
WHERE user_id = ${arrival.userId}
@@ -142,6 +163,7 @@ async function claimReaction(
generationClaimedAt: null,
generationReservedTokens: null,
generationBudgetDateKey: null,
generationCostOwner: null,
generationProviderInvokedAt: null,
},
});
@@ -38,7 +38,8 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveDailyCap,
resolveCostOwner,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import {
@@ -199,7 +200,10 @@ export async function runContentIndexBackfillForUser(
// leaves every PDF out of "index all documents".
const candidateMimes = [...IMAGE_MIMES, "application/pdf"];
const dailyCap = resolveDailyCap([{ providerType: pick.entry.providerType }]);
// v1.38.19 — a background surface: half the day's ceiling.
const dailyCap = resolveDailyCapFor("job", [
{ providerType: pick.entry.providerType },
]);
let indexed = 0;
let skipped = 0;
let failed = stale.failed;
@@ -231,6 +235,8 @@ export async function runContentIndexBackfillForUser(
AI_BUDGETS.documentTranscribe.maxTokens,
dateKey,
dailyCap,
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"job",
);
if (!reservation.allowed) {
reason = "budget-reached";
@@ -239,7 +245,10 @@ export async function runContentIndexBackfillForUser(
const document = await loadOwnedDocument(userId, id);
if (!document) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
skipped += 1;
continue;
}
@@ -249,7 +258,10 @@ export async function runContentIndexBackfillForUser(
// or a PDF the rasteriser could not render) — refund and move on so
// the walk keeps converging. Counted as skipped, not failed: no
// provider call happened.
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
skipped += 1;
continue;
}
@@ -266,6 +278,11 @@ export async function runContentIndexBackfillForUser(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{
servedBy: pick.entry.providerType,
reservedOwner: reservation.owner,
},
);
await upsertContentIndex({
userId,
@@ -278,7 +295,10 @@ export async function runContentIndexBackfillForUser(
} catch {
// A provider miss on one document must not abort the batch — refund and
// leave it un-indexed (a later run retries it).
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
failed += 1;
}
}
+12 -3
View File
@@ -30,7 +30,8 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveDailyCap,
resolveCostOwner,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import {
loadOwnedDocument,
@@ -190,7 +191,10 @@ export async function runDocumentSummaryJob(
userId,
AI_BUDGETS.documentSummary.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
// v1.38.19 — a background surface: half the day's ceiling.
resolveDailyCapFor("job", [{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"job",
);
// Budget exhausted → skip (no local fallback for a summary).
if (!reservation.allowed) {
@@ -224,6 +228,8 @@ export async function runDocumentSummaryJob(
reservation.reserved,
reservation.reserved,
dateKey,
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
// WITHHOLD policy, v1.30.31. The model's prose still never lands — a
// summary that tripped the outbound screen must never be shown as if it
@@ -273,7 +279,10 @@ export async function runDocumentSummaryJob(
// Refund the reservation on a provider miss; the document keeps no summary
// (the on-demand route remains the manual fallback). Never rethrow — a
// transient provider error must not retry-loop or fail the queue.
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
});
await markSummaryState(userId, documentId, "UNAVAILABLE");
annotate({
action: { name: "documents.summary.autoFailed" },
+55 -23
View File
@@ -41,7 +41,10 @@ import { isModuleEnabled } from "@/lib/modules/gate";
import {
buildDateKey,
reconcileSpend,
resolveDailyCap,
reserveBudget,
resolveCostOwner,
resolveDailyCapFor,
type BudgetCostOwner,
} from "@/lib/ai/coach/budget";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { screenCoachReply } from "@/lib/ai/coach/outbound-guard";
@@ -314,6 +317,14 @@ type ReactionReservation = {
allowed: boolean;
reserved: number;
dateKey: string;
/**
* v1.38.19 — the cost owner the reservation was booked to. It is
* PERSISTED on the row (`generationCostOwner`), not recomputed: the chain is
* re-resolved per run and the health ledger reorders it, so a resumed
* reservation that asked the chain again could reverse a counter the
* reservation never touched — or leave one it did.
*/
owner: BudgetCostOwner;
};
class ReactionClaimLostError extends Error {
@@ -334,6 +345,7 @@ async function reserveClaimBudget(
revision: Date,
dateKey: string,
cap: number,
owner: BudgetCostOwner,
): Promise<ReactionReservation> {
return prisma.$transaction(async (tx) => {
const stillOwned = await tx.arrivalReaction.updateMany({
@@ -351,30 +363,33 @@ async function reserveClaimBudget(
});
if (stillOwned.count !== 1) throw new ReactionClaimLostError();
const reserved = ARRIVAL_REACTION_RESERVE_TOKENS;
const rows = await tx.$queryRaw<{ total_tokens: number }[]>`
INSERT INTO coach_usage (id, user_id, date_key, total_tokens, message_count, created_at, updated_at)
VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, 1, NOW(), NOW())
ON CONFLICT (user_id, date_key) DO UPDATE SET
total_tokens = coach_usage.total_tokens + ${reserved},
message_count = coach_usage.message_count + 1,
updated_at = NOW()
RETURNING total_tokens
`;
const totalAfter = Number(rows[0]?.total_tokens ?? reserved);
if (totalAfter - reserved >= cap) {
await tx.$executeRaw`
UPDATE coach_usage
SET total_tokens = GREATEST(0, total_tokens - ${reserved}),
message_count = GREATEST(0, message_count - 1),
updated_at = NOW()
WHERE user_id = ${userId} AND date_key = ${dateKey}
`;
// v1.38.19 — ONE reservation implementation.
//
// This surface used to carry its own copy of the upsert so the write could
// ride the claim transaction. The copy drifted: it learned to WRITE
// `operator_tokens` and kept gating on the day's mixed total, at the
// halved job ceiling — so on a row holding 1.2 M tokens of which almost
// none were the operator's (2026-09-11, the shared key answering 500 while
// the user's own plan served), the arrival reaction was refused all day
// over money the operator never spent. `reserveBudget` takes the
// transaction client instead, so both writes still roll back together and
// there is no second copy left to drift.
const reservation = await reserveBudget(
userId,
ARRIVAL_REACTION_RESERVE_TOKENS,
dateKey,
cap,
owner,
"job",
tx,
);
const reserved = reservation.reserved;
if (!reservation.allowed) {
await tx.arrivalReaction.updateMany({
where: { id: rowId, userId, occurredAt: revision, generationClaimId },
data: { generationClaimId: null, generationClaimedAt: null },
});
return { allowed: false, reserved, dateKey };
return { allowed: false, reserved, dateKey, owner };
}
const linked = await tx.arrivalReaction.updateMany({
@@ -391,11 +406,16 @@ async function reserveClaimBudget(
data: {
generationReservedTokens: reserved,
generationBudgetDateKey: dateKey,
// v1.38.19 — persist the cost owner with the
// amount. Two readers need it and neither can derive it: the resume
// path below (the chain may have been reordered since) and the
// supersede refund in `data-arrival.ts` (which has no chain at all).
generationCostOwner: owner,
},
});
if (linked.count !== 1) throw new ReactionClaimLostError();
return { allowed: true, reserved, dateKey };
return { allowed: true, reserved, dateKey, owner };
});
}
@@ -430,6 +450,7 @@ export async function runReactionLine(
generationClaimedAt: true,
generationReservedTokens: true,
generationBudgetDateKey: true,
generationCostOwner: true,
generationProviderInvokedAt: true,
occurredAt: true,
refId: true,
@@ -526,6 +547,12 @@ export async function runReactionLine(
allowed: true,
reserved: row.generationReservedTokens!,
dateKey: row.generationBudgetDateKey!,
// The owner the reservation was actually booked to. Rows written before
// the column existed have none; the chain is the only answer left for
// those, and it is the answer the whole resume path used to give.
owner:
(row.generationCostOwner as BudgetCostOwner | null) ??
resolveCostOwner(chain),
};
} else {
try {
@@ -535,7 +562,9 @@ export async function runReactionLine(
job.userId,
revision,
buildDateKey(),
resolveDailyCap(chain),
// v1.38.19 — a background surface: half the day's ceiling.
resolveDailyCapFor("job", chain),
resolveCostOwner(chain),
);
} catch (err) {
if (err instanceof ReactionClaimLostError) {
@@ -625,6 +654,8 @@ export async function runReactionLine(
reservation.reserved,
reservation.reserved,
reservation.dateKey,
0,
{ servedBy: chain[0].providerType, reservedOwner: reservation.owner },
).catch(() => {});
await finishTerminalAttempt().catch(() => {});
workerLog("error", "[reaction-line] generation failed", err);
@@ -638,6 +669,7 @@ export async function runReactionLine(
result.tokensUsed ?? reservation.reserved,
reservation.dateKey,
result.cachedInputTokens ?? 0,
{ servedBy: chain[0].providerType, reservedOwner: reservation.owner },
);
} catch (err) {
// Do not publish a line whose spend was not durably reconciled. The
@@ -266,6 +266,110 @@ describe("ArrivalReaction — the day claim", () => {
expect(usage).toMatchObject({ totalTokens: 280, messageCount: 1 });
});
// v1.38.19 — the refund reverses BOTH counters.
//
// The reservation booked `operator_tokens` whenever the chain's primary was
// operator-funded. The supersede refund reversed only the total, and nothing
// else in the codebase ever subtracts that share — so every superseded
// reaction (any second measurement of the same kind on the same local date
// before the worker runs) walked the operator's ceiling upward against spend
// that never happened. Twenty of them is 28 000 tokens of a 100 000 job share
// gone for nothing, until the UTC day rolls.
it("refunds the operator share of a superseded reservation too", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-operator-refund");
const early = new Date("2026-07-14T06:00:00Z");
const late = new Date("2026-07-14T18:00:00Z");
await prisma.arrivalReaction.create({
data: {
...marker(user.id, "weight", early),
generationClaimId: "pre-provider-claim",
generationClaimedAt: new Date("2026-07-14T06:01:00Z"),
generationReservedTokens: 220,
generationBudgetDateKey: "2026-07-14",
generationCostOwner: "operator",
},
});
await prisma.coachUsage.create({
data: {
userId: user.id,
dateKey: "2026-07-14",
totalTokens: 500,
operatorTokens: 300,
messageCount: 2,
},
});
const { runDataArrival } = await import("@/lib/jobs/data-arrival");
await runDataArrival(prisma as never, {
userId: user.id,
kind: "weight",
salience: "salient",
localDate: LOCAL_DATE,
occurredAt: late.toISOString(),
count: 1,
source: "withings",
});
const usage = await prisma.coachUsage.findUniqueOrThrow({
where: { userId_dateKey: { userId: user.id, dateKey: "2026-07-14" } },
});
expect(usage).toMatchObject({
totalTokens: 280,
operatorTokens: 80,
messageCount: 1,
});
// The advanced marker carries no stale owner into the next attempt.
const advanced = await prisma.arrivalReaction.findFirstOrThrow({
where: { userId: user.id },
});
expect(advanced.generationCostOwner).toBeNull();
});
it("leaves the operator counter alone when the user's own plan reserved", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-user-refund");
const early = new Date("2026-07-14T06:00:00Z");
const late = new Date("2026-07-14T18:00:00Z");
await prisma.arrivalReaction.create({
data: {
...marker(user.id, "weight", early),
generationClaimId: "pre-provider-claim",
generationClaimedAt: new Date("2026-07-14T06:01:00Z"),
generationReservedTokens: 220,
generationBudgetDateKey: "2026-07-14",
generationCostOwner: "user",
},
});
await prisma.coachUsage.create({
data: {
userId: user.id,
dateKey: "2026-07-14",
totalTokens: 500,
operatorTokens: 300,
messageCount: 2,
},
});
const { runDataArrival } = await import("@/lib/jobs/data-arrival");
await runDataArrival(prisma as never, {
userId: user.id,
kind: "weight",
salience: "salient",
localDate: LOCAL_DATE,
occurredAt: late.toISOString(),
count: 1,
source: "withings",
});
const usage = await prisma.coachUsage.findUniqueOrThrow({
where: { userId_dateKey: { userId: user.id, dateKey: "2026-07-14" } },
});
// Another request's operator balance is not this reservation's to spend.
expect(usage).toMatchObject({ totalTokens: 280, operatorTokens: 300 });
});
it("stores the line as encrypted bytes, and tolerates its absence", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-crypto");
@@ -0,0 +1,438 @@
/**
* v1.38.19 — the daily coach ledger attributes tokens to the COST
* OWNER of the hop that actually served them.
*
* Production evidence (operator instance, 2026-09-11): the chat refused with
* `coach.budget.exceeded` at `totalAfter: 513539` although almost every token
* of that day had been served by `codex` — the operator's own ChatGPT plan —
* after the operator-funded `admin-openai` primary answered HTTP 500. The
* ledger had no provider dimension, so tokens the USER's plan paid for were
* counted against the operator-key cap and the interactive chat was locked out
* by background jobs every morning.
*
* These contracts need real Postgres: the attribution lives inside the same
* atomic statements as the total (`INSERT … ON CONFLICT DO UPDATE … RETURNING`
* and the reconcile `UPDATE`), and a mocked client cannot prove that the two
* counters move together under the row lock.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
async function seedUser(): Promise<string> {
const user = await getPrismaClient().user.create({
data: {
username: "budget-owner-user",
email: "budget-owner@example.test",
role: "USER",
},
});
return user.id;
}
async function readRow(userId: string, dateKey: string) {
return getPrismaClient().coachUsage.findUnique({
where: { userId_dateKey: { userId, dateKey } },
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("coach_usage.operator_tokens (real Postgres)", () => {
it("bills an operator-served turn to both counters", async () => {
const { reserveBudget, reconcileSpend, OPERATOR_COST_CAP } =
await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-09-11";
const res = await reserveBudget(
userId,
1_000,
dateKey,
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.allowed).toBe(true);
await reconcileSpend(userId, res.reserved, 3_000, dateKey, 0, {
servedBy: "admin-openai",
reservedOwner: "operator",
});
const row = await readRow(userId, dateKey);
expect(row?.totalTokens).toBe(3_000);
expect(row?.operatorTokens).toBe(3_000);
});
it("moves the reservation out of operator_tokens when the user's own plan served", async () => {
const { reserveBudget, reconcileSpend, OPERATOR_COST_CAP } =
await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-09-11";
// The chain's primary is operator-funded, so the reservation is charged to
// the operator — but the `admin-openai` hop failed and `codex` (the user's
// own ChatGPT plan) answered. The operator pays nothing for that turn.
const res = await reserveBudget(
userId,
1_000,
dateKey,
OPERATOR_COST_CAP,
"operator",
"coach",
);
await reconcileSpend(userId, res.reserved, 3_000, dateKey, 0, {
servedBy: "codex",
reservedOwner: "operator",
});
const row = await readRow(userId, dateKey);
expect(row?.totalTokens).toBe(3_000);
expect(row?.operatorTokens).toBe(0);
});
it("a user-plan turn never touches an existing operator balance", async () => {
const { reserveBudget, reconcileSpend, USER_PLAN_CAP } =
await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-09-11";
// An operator-funded job already spent 150k earlier that day.
await getPrismaClient().coachUsage.create({
data: {
userId,
dateKey,
totalTokens: 150_000,
operatorTokens: 150_000,
messageCount: 12,
},
});
const res = await reserveBudget(
userId,
1_000,
dateKey,
USER_PLAN_CAP,
"user",
"coach",
);
await reconcileSpend(userId, res.reserved, 4_000, dateKey, 0, {
servedBy: "codex",
reservedOwner: "user",
});
const row = await readRow(userId, dateKey);
expect(row?.totalTokens).toBe(154_000);
// The operator's balance is untouched: a user-plan turn must neither add
// to it nor deplete it.
expect(row?.operatorTokens).toBe(150_000);
});
it("bills an operator-funded FALLBACK hop under a user-plan reservation", async () => {
const { reserveBudget, reconcileSpend, USER_PLAN_CAP } =
await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-09-11";
// Primary is the user's own key (user-plan reservation), but the chain
// fell back onto the operator's shared key — those tokens land on the
// operator's invoice and must be counted there.
const res = await reserveBudget(
userId,
1_000,
dateKey,
USER_PLAN_CAP,
"user",
"coach",
);
await reconcileSpend(userId, res.reserved, 2_500, dateKey, 0, {
servedBy: "admin-openai",
reservedOwner: "user",
});
const row = await readRow(userId, dateKey);
expect(row?.totalTokens).toBe(2_500);
expect(row?.operatorTokens).toBe(2_500);
});
it("admits the operator's chat on a day his own plan filled", async () => {
const { reserveBudget, OPERATOR_COST_CAP } =
await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-09-11";
// The operator's 06:42Z lockout, reproduced: 1.2 M tokens on the day, of
// which only 150 k were served by the operator-funded key — the rest by
// `codex` on his own ChatGPT plan.
await getPrismaClient().coachUsage.create({
data: {
userId,
dateKey,
totalTokens: 1_200_000,
operatorTokens: 150_000,
messageCount: 240,
},
});
const res = await reserveBudget(
userId,
3_000,
dateKey,
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.allowed).toBe(true);
expect(res.operatorAfter).toBe(153_000);
});
it("refuses once the operator-funded share reaches the operator cap", async () => {
const { reserveBudget, OPERATOR_COST_CAP } =
await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-09-11";
await getPrismaClient().coachUsage.create({
data: {
userId,
dateKey,
totalTokens: 1_200_000,
operatorTokens: OPERATOR_COST_CAP,
messageCount: 240,
},
});
const res = await reserveBudget(
userId,
3_000,
dateKey,
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.allowed).toBe(false);
const row = await readRow(userId, dateKey);
expect(row?.totalTokens).toBe(1_200_000);
expect(row?.operatorTokens).toBe(OPERATOR_COST_CAP);
});
// v1.38.19 — the incident, end to end.
//
// The 06:42Z row verbatim: 1.2 M tokens on the day, none of them the
// operator's, on the chain `[admin-openai, codex, openai-compatible]`. The
// chat must open. It was refused while the ledger had no owner dimension and
// counted every token against the operator's ceiling; this pins that the
// refusal cannot come back.
it("does not refuse the chat for a day of background spend on the user's own plan", async () => {
const { reserveBudget, resolveDailyCap, resolveCostOwner } =
await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-09-11";
const chain = [
{ providerType: "admin-openai" as const },
{ providerType: "codex" as const },
{ providerType: "openai-compatible" as const },
];
await getPrismaClient().coachUsage.create({
data: {
userId,
dateKey,
totalTokens: 1_200_000,
operatorTokens: 0,
messageCount: 240,
},
});
const chat = await reserveBudget(
userId,
3_000,
dateKey,
resolveDailyCap(chain),
resolveCostOwner(chain),
"coach",
);
expect(chat.allowed).toBe(true);
expect(chat.limit).toBeNull();
});
it("refuses the BACKGROUND generation on that same day", async () => {
// The other half of the promise: the interactive day is protected, and the
// automatic work that filled the row is the thing that stops. The operator
// counter is empty, so only the abuse ceiling on the total can say no —
// which is the ceiling the first cut dropped for operator chains.
const { reserveBudget, resolveDailyCapFor, resolveCostOwner } =
await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-09-11";
const chain = [
{ providerType: "admin-openai" as const },
{ providerType: "codex" as const },
];
await getPrismaClient().coachUsage.create({
data: {
userId,
dateKey,
totalTokens: 1_900_000,
operatorTokens: 0,
messageCount: 380,
},
});
const job = await reserveBudget(
userId,
3_000,
dateKey,
resolveDailyCapFor("job", chain),
resolveCostOwner(chain),
"job",
);
expect(job.allowed).toBe(false);
expect(job.limit).toBe("total-cap");
// Refused means refunded: the row is exactly as it was.
const row = await readRow(userId, dateKey);
expect(row?.totalTokens).toBe(1_900_000);
expect(row?.operatorTokens).toBe(0);
expect(row?.messageCount).toBe(380);
// ...and the person waiting still gets their turn.
const chat = await reserveBudget(
userId,
3_000,
dateKey,
200_000,
"operator",
"coach",
);
expect(chat.allowed).toBe(true);
});
// v1.38.20 — the reserve holds on a chain nobody is billed for.
//
// This is the configuration the remedy for the original lockout produces:
// put the personal Codex account first and the operator stops paying for his
// own egress. The day then runs on `USER_PLAN_CAP` with no invoice behind it,
// so nothing rations the automatic generators — and without a reserve the
// chat is locked out by background work all over again, on the very chain
// that was supposed to fix it.
it("admits the chat on a user-funded chain whose background work filled the day", async () => {
const {
reserveBudget,
resolveDailyCapFor,
resolveDailyCap,
resolveCostOwner,
} = await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-09-11";
const chain = [
{ providerType: "codex" as const },
{ providerType: "admin-openai" as const },
];
// 1.6 M tokens of insights on the user's own ChatGPT plan — everything the
// background surfaces are allowed to reach, and not a token of it billed.
await getPrismaClient().coachUsage.create({
data: {
userId,
dateKey,
totalTokens: 1_600_000,
operatorTokens: 0,
messageCount: 320,
},
});
const job = await reserveBudget(
userId,
3_000,
dateKey,
resolveDailyCapFor("job", chain),
resolveCostOwner(chain),
"job",
);
expect(job.allowed).toBe(false);
// Refused means refunded: the reserve is untouched by the attempt.
const afterJob = await readRow(userId, dateKey);
expect(afterJob?.totalTokens).toBe(1_600_000);
expect(afterJob?.messageCount).toBe(320);
const chat = await reserveBudget(
userId,
3_000,
dateKey,
resolveDailyCap(chain),
resolveCostOwner(chain),
"coach",
);
expect(chat.allowed).toBe(true);
expect(chat.limit).toBeNull();
});
it("lets that same background work run right up to the reserve", async () => {
// The reserve is a floor under the chat, not a quota over the jobs: a day
// of automatic generation on the user's own plan costs nobody anything and
// is not cut short until it would take the last of the day.
const { reserveBudget, resolveDailyCapFor, resolveCostOwner } =
await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-09-11";
const chain = [{ providerType: "codex" as const }];
await getPrismaClient().coachUsage.create({
data: {
userId,
dateKey,
totalTokens: 1_599_999,
operatorTokens: 0,
messageCount: 319,
},
});
const job = await reserveBudget(
userId,
3_000,
dateKey,
resolveDailyCapFor("job", chain),
resolveCostOwner(chain),
"job",
);
expect(job.allowed).toBe(true);
});
it("refunds both counters when a refused reservation is rolled back", async () => {
const { reserveBudget, OPERATOR_COST_CAP } =
await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-09-11";
await getPrismaClient().coachUsage.create({
data: {
userId,
dateKey,
totalTokens: OPERATOR_COST_CAP,
operatorTokens: OPERATOR_COST_CAP,
messageCount: 3,
},
});
const res = await reserveBudget(
userId,
1_000,
dateKey,
OPERATOR_COST_CAP,
"operator",
"coach",
);
expect(res.allowed).toBe(false);
const row = await readRow(userId, dateKey);
expect(row?.totalTokens).toBe(OPERATOR_COST_CAP);
expect(row?.operatorTokens).toBe(OPERATOR_COST_CAP);
expect(row?.messageCount).toBe(3);
});
});
@@ -37,13 +37,27 @@ describe("reserveBudget (real Postgres)", () => {
// Pre-spend 900 so a single 600-token reservation stays under the cap
// but two concurrent ones cannot both be admitted.
//
// v1.38.19 — the 900 sits on BOTH counters. The ledger grew a cost
// dimension: an operator-owned reservation is measured against
// `operator_tokens`, so a fixture that pre-spends only the mixed total no
// longer states its own premise ("900 already spent under this cap") and
// would prove nothing about concurrency. The contract this test exists for
// — two concurrent reservations cannot both pass one ceiling — is
// unchanged, and so is every assertion below.
await getPrismaClient().coachUsage.create({
data: { userId, dateKey, totalTokens: 900, messageCount: 1 },
data: {
userId,
dateKey,
totalTokens: 900,
operatorTokens: 900,
messageCount: 1,
},
});
const [a, b] = await Promise.all([
reserveBudget(userId, 600, dateKey, cap),
reserveBudget(userId, 600, dateKey, cap),
reserveBudget(userId, 600, dateKey, cap, "operator", "coach"),
reserveBudget(userId, 600, dateKey, cap, "operator", "coach"),
]);
// Exactly one is admitted: the first to commit sees prior 900 (< cap) and
@@ -58,6 +72,36 @@ describe("reserveBudget (real Postgres)", () => {
where: { userId_dateKey: { userId, dateKey } },
});
expect(row?.totalTokens).toBe(1500);
expect(row?.operatorTokens).toBe(1500);
});
it("lets at most one of two concurrent requests push past the TOTAL ceiling", async () => {
// The same guarantee on the other arm. An operator-funded chain whose key
// keeps failing leaves `operator_tokens` near zero all day, so the abuse
// ceiling on the mixed total is the only ceiling left — and it has to
// serialise exactly like the owner's does.
const { reserveBudget, USER_PLAN_CAP } =
await import("@/lib/ai/coach/budget");
const userId = await seedUser();
const dateKey = "2026-06-23";
await getPrismaClient().coachUsage.create({
data: {
userId,
dateKey,
totalTokens: USER_PLAN_CAP - 100,
operatorTokens: 0,
messageCount: 1,
},
});
const [a, b] = await Promise.all([
reserveBudget(userId, 600, dateKey, 200_000, "operator", "coach"),
reserveBudget(userId, 600, dateKey, 200_000, "operator", "coach"),
]);
expect([a, b].filter((r) => r.allowed).length).toBe(1);
expect([a, b].find((r) => !r.allowed)?.limit).toBe("total-cap");
});
it("admits the first request of a fresh day via the upsert create branch", async () => {
@@ -65,7 +109,14 @@ describe("reserveBudget (real Postgres)", () => {
const userId = await seedUser();
const dateKey = "2026-06-20";
const res = await reserveBudget(userId, 600, dateKey, 25_000);
const res = await reserveBudget(
userId,
600,
dateKey,
25_000,
"operator",
"coach",
);
expect(res.allowed).toBe(true);
const row = await getPrismaClient().coachUsage.findUnique({
@@ -81,8 +132,18 @@ describe("reserveBudget (real Postgres)", () => {
const userId = await seedUser();
const dateKey = "2026-06-21";
const res = await reserveBudget(userId, 600, dateKey, 25_000);
await reconcileSpend(userId, res.reserved, 120, dateKey);
const res = await reserveBudget(
userId,
600,
dateKey,
25_000,
"operator",
"coach",
);
await reconcileSpend(userId, res.reserved, 120, dateKey, 0, {
servedBy: "admin-openai",
reservedOwner: res.owner,
});
const row = await getPrismaClient().coachUsage.findUnique({
where: { userId_dateKey: { userId, dateKey } },
@@ -99,8 +160,18 @@ describe("reserveBudget (real Postgres)", () => {
// Provider returned 450 tokens but the reply was empty/sentinel: the
// reconcile must still record the 450 burned, not zero.
const res = await reserveBudget(userId, 600, dateKey, 25_000);
await reconcileSpend(userId, res.reserved, 450, dateKey);
const res = await reserveBudget(
userId,
600,
dateKey,
25_000,
"operator",
"coach",
);
await reconcileSpend(userId, res.reserved, 450, dateKey, 0, {
servedBy: "admin-openai",
reservedOwner: res.owner,
});
const row = await getPrismaClient().coachUsage.findUnique({
where: { userId_dateKey: { userId, dateKey } },
@@ -59,9 +59,28 @@ async function dispatch() {
});
}
/** The ledger write is fire-and-forget; let its microtask land. */
async function settleLedger(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 50));
/**
* The ledger write is fire-and-forget, so the row appears some time after the
* dispatch returns. This used to wait a flat 50 ms, which is enough on a warm
* machine and not enough on a cold container: the suite failed here once with
* an empty ledger while nothing about the dispatch had changed. It waits for
* the rows instead, and gives up loudly rather than letting the assertion
* below report the absence as a product defect.
*/
async function settleLedger(expected: number): Promise<void> {
const deadline = Date.now() + 5_000;
for (;;) {
const seen = await getPrismaClient().pushAttempt.count({
where: { userId: TEST_USER_ID },
});
if (seen >= expected) return;
if (Date.now() > deadline) {
throw new Error(
`the delivery ledger still holds ${seen} of ${expected} rows after five seconds`,
);
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
beforeEach(async () => {
@@ -82,7 +101,7 @@ describe("instance-wide notification channel switch", () => {
await setNtfyGlobal(false);
const outcome = await dispatch();
await settleLedger();
await settleLedger(1);
expect(ntfySendMock).not.toHaveBeenCalled();
expect(outcome).toEqual({