Merge the coach cap counting only the tokens the operator paid for

Background insights can no longer use up the day before somebody asks a question; a fifth of the daily limit is held back for what a person does by hand.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-11 14:18:21 +02:00
commit 4460047910
82 files changed
+2741 -200

No files matched your search

+2 -1
View File
@@ -6683,7 +6683,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
@@ -6683,7 +6683,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
@@ -6683,7 +6683,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
@@ -6683,7 +6683,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
@@ -6683,7 +6683,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
@@ -7290,7 +7290,8 @@
"typeAnthropic": "Anthropic 키 (개인)",
"typeCodex": "ChatGPT 로그인 (개인)",
"typeLocal": "로컬 모델 (개인)",
"typeGateway": "OpenAI 호환 게이트웨이 (개인)"
"typeGateway": "OpenAI 호환 게이트웨이 (개인)",
"spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다."
}
},
"achievements": {
+2 -1
View File
@@ -6683,7 +6683,8 @@
"typeAnthropic": "Klucz Anthropic (osobisty)",
"typeCodex": "Logowanie ChatGPT (osobiste)",
"typeLocal": "Model lokalny (osobisty)",
"typeGateway": "Brama zgodna z OpenAI (osobista)"
"typeGateway": "Brama zgodna z OpenAI (osobista)",
"spendToday": "Dzisiaj ({date}): {total} tokenów, w tym {operator} na kluczu operatora."
},
"aiQuality": {
"title": "Jakość analiz",
@@ -0,0 +1,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")
@@ -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 } },