diff --git a/messages/de.json b/messages/de.json index 0248279e8..d139727ec 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index c2914500e..cda491a00 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 6333bee7e..fbe4b3570 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index f1ac4a8f8..fc7ef2799 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 39910e728..99a3df2bd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index a6a81dc1c..ca5b3deb6 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7290,7 +7290,8 @@ "typeAnthropic": "Anthropic 키 (개인)", "typeCodex": "ChatGPT 로그인 (개인)", "typeLocal": "로컬 모델 (개인)", - "typeGateway": "OpenAI 호환 게이트웨이 (개인)" + "typeGateway": "OpenAI 호환 게이트웨이 (개인)", + "spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다." } }, "achievements": { diff --git a/messages/pl.json b/messages/pl.json index 405ec5665..f436bcffb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0340_coach_usage_operator_tokens/migration.sql b/prisma/migrations/0340_coach_usage_operator_tokens/migration.sql new file mode 100644 index 000000000..2ad77f48f --- /dev/null +++ b/prisma/migrations/0340_coach_usage_operator_tokens/migration.sql @@ -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; diff --git a/prisma/migrations/0341_arrival_reaction_cost_owner/migration.sql b/prisma/migrations/0341_arrival_reaction_cost_owner/migration.sql new file mode 100644 index 000000000..7cc71a13b --- /dev/null +++ b/prisma/migrations/0341_arrival_reaction_cost_owner/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 71bcfeb6d..09d54ef02 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/__tests__/coach-usage-owner-columns-guard.test.ts b/src/__tests__/coach-usage-owner-columns-guard.test.ts new file mode 100644 index 000000000..7b12a1a95 --- /dev/null +++ b/src/__tests__/coach-usage-owner-columns-guard.test.ts @@ -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()); + }); +}); diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts index 03e0fb8de..408ba63ea 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index c0163dece..78e90ca01 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -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, + }, + }); }); diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts index 6369d1b68..840c64368 100644 --- a/src/app/api/ai/test/__tests__/route.test.ts +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -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" }, ); }); }); diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 56440ce29..27265df91 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -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), diff --git a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts index 1fa158e40..913b29da0 100644 --- a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts @@ -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(), diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts index 6acb80659..4975c6f65 100644 --- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -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(), diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 757c012a7..118717268 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -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", diff --git a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts index aa5b6c7d5..671c68bd5 100644 --- a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts @@ -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 }), diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 9a3cd6756..04530af0c 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -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", diff --git a/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts index 3db7bbd6a..30bf21b6d 100644 --- a/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts @@ -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 }), diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index b7feb1f51..0fe5d57a7 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -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", diff --git a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts index b445164cf..a1926710d 100644 --- a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts @@ -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 }), diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 186e842d7..f207faf12 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -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"); } } diff --git a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts index 593a9fb7a..7849a4827 100644 --- a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts +++ b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts @@ -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); diff --git a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts index e187cc5b4..b3476a8af 100644 --- a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts @@ -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 })), diff --git a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts index b394f43b9..3923f76b3 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts @@ -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(() => ({ diff --git a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts index 913a32602..14adc66aa 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts @@ -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(() => ({ diff --git a/src/app/api/insights/chat/__tests__/route-guard.test.ts b/src/app/api/insights/chat/__tests__/route-guard.test.ts index abed117d0..2803076b7 100644 --- a/src/app/api/insights/chat/__tests__/route-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard.test.ts @@ -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(() => ({ diff --git a/src/app/api/insights/chat/__tests__/route-list-query.test.ts b/src/app/api/insights/chat/__tests__/route-list-query.test.ts index c5165fdb7..4fc5d3488 100644 --- a/src/app/api/insights/chat/__tests__/route-list-query.test.ts +++ b/src/app/api/insights/chat/__tests__/route-list-query.test.ts @@ -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", () => ({ diff --git a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts index 21b9fde3c..07e52d0c9 100644 --- a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts +++ b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts @@ -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", () => ({ diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index 4f9b399a7..bcb917964 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -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 })), diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index 827007729..2dac8489d 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -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): 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 = ( diff --git a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts index 9604d200a..37567bbef 100644 --- a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts +++ b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts @@ -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 })), diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 9dc19d79d..c2e6315e4 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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 { : 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 { async function produceReply(): Promise { 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 { 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 { // 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 { } 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 { 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. diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 5289fa0d1..fe320041c 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index 936f1901f..97b655c31 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -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( @@ -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" }, ); }); }); diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 125af81dd..901b5b0f5 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -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; } } diff --git a/src/app/api/medications/extract/__tests__/route.test.ts b/src/app/api/medications/extract/__tests__/route.test.ts index c01d578ee..d80ee7565 100644 --- a/src/app/api/medications/extract/__tests__/route.test.ts +++ b/src/app/api/medications/extract/__tests__/route.test.ts @@ -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" }, + ); }); }); diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index 0bc66762e..4fe35ca8a 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -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 { estimatedTokens, dateKey, resolveDailyCap(chain), + resolveCostOwner(chain), + "coach", ); if (!reservation.allowed) { annotate({ @@ -222,6 +225,7 @@ async function handleExtract(request: NextRequest): Promise { let completion; try { completion = await runRawCompletionWithFallback({ + surface: "coach", userId, providers: chain, params: singleUserTurn({ @@ -241,9 +245,10 @@ async function handleExtract(request: NextRequest): Promise { } 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 { // 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) { diff --git a/src/components/admin/__tests__/provider-health-spend-today.test.tsx b/src/components/admin/__tests__/provider-health-spend-today.test.tsx new file mode 100644 index 000000000..bfe056560 --- /dev/null +++ b/src/components/admin/__tests__/provider-health-spend-today.test.tsx @@ -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( + + + , + ); +} + +describe(" — 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"', + ); + }); +}); diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index d4568c20f..273c3559b 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -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 ( @@ -75,6 +96,15 @@ export function ProviderHealthSection() { title={t("admin.providerHealth.title")} description={t("admin.providerHealth.description")} /> + {spendToday ? ( +

+ {t("admin.providerHealth.spendToday", { + date: spendToday.dateKey, + total: groupDigits(spendToday.totalTokens), + operator: groupDigits(spendToday.operatorTokens), + })} +

+ ) : null} {isPending ? (

{t("admin.providerHealth.loading")} diff --git a/src/lib/ai/__tests__/gateway-embedded-error.test.ts b/src/lib/ai/__tests__/gateway-embedded-error.test.ts index 2c7180c45..391710c13 100644 --- a/src/lib/ai/__tests__/gateway-embedded-error.test.ts +++ b/src/lib/ai/__tests__/gateway-embedded-error.test.ts @@ -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", diff --git a/src/lib/ai/__tests__/provider-runner.test.ts b/src/lib/ai/__tests__/provider-runner.test.ts index 51affa883..58a077a6d 100644 --- a/src/lib/ai/__tests__/provider-runner.test.ts +++ b/src/lib/ai/__tests__/provider-runner.test.ts @@ -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("../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, }); diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index 45697c6b8..3757daa61 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -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; + $executeRaw: ReturnType; + }; + + 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 }; @@ -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); }); diff --git a/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts index 7319c0d24..5bc67d264 100644 --- a/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts +++ b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts @@ -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" }, ); }); }); diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index d559a8e82..b96f6d7e1 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -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; + /** * 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 { +): Promise { 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 { 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 { 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 { - 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} diff --git a/src/lib/ai/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index 0d0a41a0e..34cd3b0e2 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -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); diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index 61076777f..cd671c0b6 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -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, diff --git a/src/lib/ai/provider-runner.ts b/src/lib/ai/provider-runner.ts index e2bd1cef5..37270e54f 100644 --- a/src/lib/ai/provider-runner.ts +++ b/src/lib/ai/provider-runner.ts @@ -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, @@ -406,7 +409,7 @@ async function runRawChain( candidate: ProviderChainResolved, ) => Record, ): Promise { - 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 { diff --git a/src/lib/documents/__tests__/auto-stage-labs.test.ts b/src/lib/documents/__tests__/auto-stage-labs.test.ts index aca4ac148..c4fe7bca3 100644 --- a/src/lib/documents/__tests__/auto-stage-labs.test.ts +++ b/src/lib/documents/__tests__/auto-stage-labs.test.ts @@ -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>); mockExtract.mockResolvedValue({ reportDate: "2026-07-10", diff --git a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts index c9f6caf9d..95dda88f1 100644 --- a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts +++ b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts @@ -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(), diff --git a/src/lib/documents/__tests__/index-document.test.ts b/src/lib/documents/__tests__/index-document.test.ts index d9a145bc6..c6de6c93b 100644 --- a/src/lib/documents/__tests__/index-document.test.ts +++ b/src/lib/documents/__tests__/index-document.test.ts @@ -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); }); diff --git a/src/lib/documents/__tests__/index-outcome-record.test.ts b/src/lib/documents/__tests__/index-outcome-record.test.ts index c11d98e2b..2b3844697 100644 --- a/src/lib/documents/__tests__/index-outcome-record.test.ts +++ b/src/lib/documents/__tests__/index-outcome-record.test.ts @@ -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 } }, diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index 52ccaca2c..23478147f 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -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" }, diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index a09dd161d..9b8e63e04 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -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(); diff --git a/src/lib/documents/index-document.ts b/src/lib/documents/index-document.ts index 16f012f6a..e512aee6e 100644 --- a/src/lib/documents/index-document.ts +++ b/src/lib/documents/index-document.ts @@ -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>["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" }; } } diff --git a/src/lib/insights/__tests__/briefing-provider-budget.test.ts b/src/lib/insights/__tests__/briefing-provider-budget.test.ts index b79a4a7ff..9f578cb4d 100644 --- a/src/lib/insights/__tests__/briefing-provider-budget.test.ts +++ b/src/lib/insights/__tests__/briefing-provider-budget.test.ts @@ -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( diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts index 1ad03b0e3..53a38a769 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -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), diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 9b8e680b7..7517e6f66 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -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), diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index 7b439de55..b000afac4 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -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" }); diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 612d1d265..8ab32fb81 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -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), diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index 7f4a3a8f2..f64cfdc86 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -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), diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 5b30f25e1..4ee3bc274 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -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), diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index 2c8692e50..73bc1fc6c 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -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), diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index 138aff034..24ee58f6d 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -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), diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index 9e5bd760d..1ef502f0e 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -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()); diff --git a/src/lib/insights/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index bb013840f..1b8d15079 100644 --- a/src/lib/insights/briefing-provider.ts +++ b/src/lib/insights/briefing-provider.ts @@ -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. diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index f30a7b36a..bb187ab5d 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -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) { diff --git a/src/lib/jobs/__tests__/async-state-contract.test.ts b/src/lib/jobs/__tests__/async-state-contract.test.ts index 4aa5fdf3f..5f70f8a95 100644 --- a/src/lib/jobs/__tests__/async-state-contract.test.ts +++ b/src/lib/jobs/__tests__/async-state-contract.test.ts @@ -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: { diff --git a/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts index b267ff776..8923120a3 100644 --- a/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts @@ -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); diff --git a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts index 33049dd2e..e048cd61f 100644 --- a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts +++ b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts @@ -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() })); diff --git a/src/lib/jobs/__tests__/document-summary.test.ts b/src/lib/jobs/__tests__/document-summary.test.ts index a88010654..9a4c84a3f 100644 --- a/src/lib/jobs/__tests__/document-summary.test.ts +++ b/src/lib/jobs/__tests__/document-summary.test.ts @@ -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" } }), ); diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index 59c08e10d..ac9403751 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -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, ); }); }); diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index 756ca1e02..a2916d907 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -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); diff --git a/src/lib/jobs/data-arrival.ts b/src/lib/jobs/data-arrival.ts index cb4318ac6..1fa671331 100644 --- a/src/lib/jobs/data-arrival.ts +++ b/src/lib/jobs/data-arrival.ts @@ -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, }, }); diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index d2b8a8051..53dec0f23 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -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; } } diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 21cbc9469..257586a01 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -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" }, diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index cd2601c4b..35e994009 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -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 { 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 diff --git a/tests/integration/arrival-reaction-claim.test.ts b/tests/integration/arrival-reaction-claim.test.ts index 0db295536..97398cb71 100644 --- a/tests/integration/arrival-reaction-claim.test.ts +++ b/tests/integration/arrival-reaction-claim.test.ts @@ -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"); diff --git a/tests/integration/coach-budget-owner.test.ts b/tests/integration/coach-budget-owner.test.ts new file mode 100644 index 000000000..8420d50c5 --- /dev/null +++ b/tests/integration/coach-budget-owner.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/integration/coach-budget-reservation.test.ts b/tests/integration/coach-budget-reservation.test.ts index b500a3dab..2b4f28443 100644 --- a/tests/integration/coach-budget-reservation.test.ts +++ b/tests/integration/coach-budget-reservation.test.ts @@ -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 } },