From badef28e965384d75c0a6c635f920d9dd2919f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:42:14 +0200 Subject: [PATCH 01/10] feat(ai): the daily ledger records which tokens the operator paid for `coach_usage` counted one number per (user, day). A turn served by the user's own ChatGPT plan after the operator's shared key answered 500 was therefore charged to the operator-cost cap, and the operator's chat was locked out every morning by background work he pays for himself. The ledger gains `operator_tokens`: the share of `total_tokens` served by an operator-funded provider. `reserveBudget` books a reservation to the owner of the chain's primary and `reconcileSpend` settles it against the provider that actually served, both inside the same statements as the total so concurrent requests can never see one counter without the other. Every call site names its cost owner and its serving hop; the argument is required, so a new surface cannot inherit the old blind accounting. --- .../migration.sql | 15 ++ prisma/schema.prisma | 20 +- src/app/api/ai/test/__tests__/route.test.ts | 5 + src/app/api/ai/test/route.ts | 16 +- .../inbound/[id]/chat/__tests__/route.test.ts | 1 + .../[id]/extract/__tests__/route.test.ts | 1 + .../documents/inbound/[id]/extract/route.ts | 25 ++- .../[id]/index/__tests__/route.test.ts | 1 + .../api/documents/inbound/[id]/index/route.ts | 9 +- .../[id]/suggest/__tests__/route.test.ts | 1 + .../documents/inbound/[id]/suggest/route.ts | 17 +- .../[id]/summary/__tests__/route.test.ts | 1 + .../documents/inbound/[id]/summary/route.ts | 17 +- .../__tests__/route-cancelled-turn.test.ts | 24 ++- .../chat/__tests__/route-fence-guard.test.ts | 1 + .../__tests__/route-guard-activation.test.ts | 1 + .../chat/__tests__/route-guard-ledger.test.ts | 1 + .../chat/__tests__/route-guard.test.ts | 1 + .../chat/__tests__/route-list-query.test.ts | 1 + .../chat/__tests__/route-module-gate.test.ts | 1 + .../__tests__/route-snapshot-once.test.ts | 1 + .../chat/__tests__/route-tool-mode.test.ts | 14 +- .../__tests__/route-workout-scope.test.ts | 1 + src/app/api/insights/chat/route.ts | 20 +- .../labs/ocr/extract/__tests__/route.test.ts | 11 ++ src/app/api/labs/ocr/extract/route.ts | 65 ++++-- .../extract/__tests__/route.test.ts | 19 +- src/app/api/medications/extract/route.ts | 14 +- src/lib/ai/__tests__/provider-runner.test.ts | 7 +- src/lib/ai/coach/__tests__/budget.test.ts | 42 +++- .../self-context-questions-consent.test.ts | 11 ++ src/lib/ai/coach/budget.ts | 135 +++++++++++-- src/lib/ai/coach/self-context-questions.ts | 10 +- src/lib/ai/provider-runner.ts | 9 +- .../__tests__/fenced-chat-pipeline.test.ts | 1 + .../__tests__/index-document.test.ts | 20 +- .../__tests__/index-outcome-record.test.ts | 1 + src/lib/documents/auto-stage-labs.ts | 11 +- src/lib/documents/fenced-chat.ts | 10 +- src/lib/documents/index-document.ts | 24 ++- src/lib/insights/briefing-provider.ts | 19 +- src/lib/insights/status-provider.ts | 17 +- .../__tests__/async-state-contract.test.ts | 1 + .../__tests__/coach-nudge-ai-consent.test.ts | 1 + .../document-content-index-backfill.test.ts | 1 + .../jobs/__tests__/document-summary.test.ts | 20 +- .../reaction-line-degradation.test.ts | 6 + src/lib/jobs/coach-nudge-ai.ts | 13 +- .../jobs/document-content-index-backfill.ts | 22 ++- src/lib/jobs/document-summary.ts | 9 +- src/lib/jobs/reaction-line.ts | 30 ++- tests/integration/coach-budget-owner.test.ts | 186 ++++++++++++++++++ .../coach-budget-reservation.test.ts | 20 +- 53 files changed, 813 insertions(+), 117 deletions(-) create mode 100644 prisma/migrations/0341_coach_usage_operator_tokens/migration.sql create mode 100644 tests/integration/coach-budget-owner.test.ts diff --git a/prisma/migrations/0341_coach_usage_operator_tokens/migration.sql b/prisma/migrations/0341_coach_usage_operator_tokens/migration.sql new file mode 100644 index 000000000..8414906ba --- /dev/null +++ b/prisma/migrations/0341_coach_usage_operator_tokens/migration.sql @@ -0,0 +1,15 @@ +-- v1.38.19 (Wave E) — 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/schema.prisma b/prisma/schema.prisma index 71bcfeb6d..688951392 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 (Wave E) — 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) diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts index 6369d1b68..a902128bc 100644 --- a/src/app/api/ai/test/__tests__/route.test.ts +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -30,9 +30,12 @@ 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, + resolveCostOwner: () => "operator" as const, })); vi.mock("@/lib/logging/context", () => ({ @@ -376,6 +379,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..389938c71 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,11 @@ export const POST = apiHandler(async (request: NextRequest) => { providerType: provider.type === "admin-key" ? "admin-openai" : "local", }, ]), + resolveCostOwner([ + { + providerType: provider.type === "admin-key" ? "admin-openai" : "local", + }, + ]), ); if (!reservation.allowed) { annotate({ action: { name: "ai.test.budget_exceeded" } }); @@ -137,6 +143,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 +166,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..7d832c732 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 600 }), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: 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..75aa91905 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: 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..75b43c825 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,7 @@ async function handleTextExtract( AI_BUDGETS.ocrExtractText.maxTokens, dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -273,6 +275,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 +293,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 +358,7 @@ async function handleStoredExtract( AI_BUDGETS.ocrExtractText.maxTokens, dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -370,6 +378,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 +396,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 +478,7 @@ async function handleVisionExtract( AI_BUDGETS.ocrExtract.maxTokens, dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -493,6 +507,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 +525,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..544005665 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: 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..87f37415a 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,7 @@ async function handleVisionIndex( AI_BUDGETS.documentTranscribe.maxTokens, dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -269,6 +271,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 +299,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..40f2e9603 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: 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..61bd2499d 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,7 @@ async function handleTextSuggest( AI_BUDGETS.documentAssist.maxTokens, dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -185,10 +187,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 +262,7 @@ async function handleVisionSuggest( AI_BUDGETS.documentAssist.maxTokens, dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -275,10 +283,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..2bdd26141 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: 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..f0903b787 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,7 @@ async function handleTextSummary( budgetFor(mode), dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -369,10 +371,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 +437,7 @@ async function handleVisionSummary( budgetFor(mode), dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -455,10 +463,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..9640c3871 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget, reconcileSpend, resolveDailyCap: vi.fn(() => 2_000_000), + resolveCostOwner: vi.fn(() => "user" as const), })); vi.mock("@/lib/ai/coach/refusal", () => ({ @@ -205,7 +210,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 +245,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..61a0dd543 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,7 @@ 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), + 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..078fb58ac 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget, reconcileSpend, resolveDailyCap: 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..4eb694265 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget, reconcileSpend, resolveDailyCap: 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..d2bee8745 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget, reconcileSpend, resolveDailyCap: 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..96a2ef5cd 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn(), reconcileSpend: vi.fn(), resolveDailyCap: 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..fd0add566 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ })), reconcileSpend: vi.fn(), resolveDailyCap: 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..7ba30cf4b 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,7 @@ 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), + 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..d88dab268 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget, reconcileSpend, resolveDailyCap: vi.fn(() => 2_000_000), + resolveCostOwner: vi.fn(() => "user" as const), })); const { detectRefusal } = vi.hoisted(() => ({ @@ -266,7 +268,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 +324,7 @@ describe("coach chat — tool-mode routing (F1)", () => { 4500, "2026-06-21", 2_000_000, + "user", ); }); @@ -347,6 +354,8 @@ describe("coach chat — tool-mode routing (F1)", () => { 3000, 0, expect.anything(), + 0, + { servedBy: null, reservedOwner: "user" }, ); }); @@ -558,6 +567,7 @@ describe("coach chat — tool-mode routing (F1)", () => { 1500, "2026-06-21", 2_000_000, + "user", ); // 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..16d9b72b2 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,7 @@ 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), + 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..5f6c036ce 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,6 +605,7 @@ async function handleChatRequest(request: NextRequest): Promise { : AI_BUDGETS.coach.maxTokens, reqDateKey, dailyCap, + resolveCostOwner(chain), ); if (!reservation.allowed) { annotate({ @@ -637,7 +640,10 @@ async function handleChatRequest(request: NextRequest): Promise { async function produceReply(): Promise { let result: CompletionResult; - let workingProviderType: string; + // v1.38.19 (Wave E) — 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 +708,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 @@ -784,9 +792,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 +896,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/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index 936f1901f..c926aecc0 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn(), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: vi.fn(() => 200_000), + resolveCostOwner: vi.fn(() => "operator" as const), })); vi.mock("@/lib/labs/ocr-extract", async () => { const actual = await vi.importActual( @@ -95,6 +96,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 +119,8 @@ 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, + // Wave E — and the cost owner that cap is enforced against as the 5th. + "operator", ); }); @@ -134,6 +139,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 +189,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 +231,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..7dfd230f6 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,7 @@ async function handleTextExtract( AI_BUDGETS.ocrExtractText.maxTokens, dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { annotate({ @@ -208,12 +210,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 +288,7 @@ async function handleVisionExtract( AI_BUDGETS.ocrExtract.maxTokens, dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { annotate({ @@ -303,7 +311,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 +327,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 +345,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 +356,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 +370,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 +411,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 +442,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 +479,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..3c43ec73b 100644 --- a/src/app/api/medications/extract/__tests__/route.test.ts +++ b/src/app/api/medications/extract/__tests__/route.test.ts @@ -44,6 +44,7 @@ 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), + resolveCostOwner: vi.fn(() => "user" as const), })); vi.mock("@/lib/ai/provider", () => ({ @@ -118,6 +119,8 @@ beforeEach(() => { allowed: true, reserved: 700, totalAfter: 700, + owner: "user", + operatorAfter: 0, }); vi.mocked(resolveDailyCap).mockReturnValue(1_234_567); vi.mocked(resolveProviderChain).mockResolvedValue([ @@ -274,6 +277,7 @@ describe("POST /api/medications/extract — daily budget", () => { expect.any(Number), "2026-05-28", 1_234_567, + "user", ); }); @@ -283,6 +287,8 @@ describe("POST /api/medications/extract — daily budget", () => { allowed: false, reserved: 700, totalAfter: 9_999_999, + owner: "user", + operatorAfter: 0, }); const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never); @@ -307,6 +313,8 @@ describe("POST /api/medications/extract — daily budget", () => { 700, 120, "2026-05-28", + 0, + { servedBy: "openai", reservedOwner: "user" }, ); }); @@ -323,6 +331,8 @@ describe("POST /api/medications/extract — daily budget", () => { 700, 700, "2026-05-28", + 0, + { servedBy: "openai", reservedOwner: "user" }, ); }); @@ -335,7 +345,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..883de5d55 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,7 @@ async function handleExtract(request: NextRequest): Promise { estimatedTokens, dateKey, resolveDailyCap(chain), + resolveCostOwner(chain), ); if (!reservation.allowed) { annotate({ @@ -241,9 +243,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 +263,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/lib/ai/__tests__/provider-runner.test.ts b/src/lib/ai/__tests__/provider-runner.test.ts index 51affa883..9514243db 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, + })), }; }); diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index 45697c6b8..cb6fbe304 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -4,6 +4,7 @@ import { buildDateKey, OPERATOR_COST_CAP, USER_PLAN_CAP, + resolveCostOwner, resolveDailyCap, } from "../budget"; @@ -60,19 +61,37 @@ 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", + ); 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", + ); 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", + ); expect(res.reserved).toBe(12); }); }); @@ -143,6 +162,7 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => { 1_200, "2026-05-10", resolveDailyCap([{ providerType: "codex" }]), + resolveCostOwner([{ providerType: "codex" }]), ); expect(res.allowed).toBe(true); // The reservation upsert ran; no refund executeRaw fired. @@ -161,6 +181,7 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => { 1_200, "2026-05-10", resolveDailyCap([{ providerType: "admin-openai" }]), + resolveCostOwner([{ providerType: "admin-openai" }]), ); expect(res.allowed).toBe(false); // Refund of the reservation fired on refusal. @@ -181,7 +202,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 +214,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 +225,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..9276adb5a 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,12 @@ 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), + resolveCostOwner: vi.fn(() => "operator" as const), })); vi.mock("@/lib/ai/coach/budget", () => budgetMocks); @@ -87,6 +90,8 @@ beforeEach(() => { allowed: true, reserved: 300, totalAfter: 300, + owner: "operator" as const, + operatorAfter: 300, }); buildCoachSnapshot.mockResolvedValue({ snapshotJson: '{"weight":[]}' }); }); @@ -239,6 +244,7 @@ describe("deriveClarifyingQuestions — atomic budget", () => { 300, "2026-07-18", 200_000, + "operator", ); expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith( "user-1", @@ -246,6 +252,7 @@ describe("deriveClarifyingQuestions — atomic budget", () => { 120, "2026-07-18", 0, + { servedBy: "openai", reservedOwner: "operator" }, ); }); @@ -254,6 +261,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 +282,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..44f6eab61 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -63,8 +63,7 @@ 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; } @@ -80,6 +79,28 @@ export function isOperatorFundedProvider(type: ProviderChainType): boolean { return type === "admin-openai" || type === "admin-codex"; } +/** + * v1.38.19 (Wave E) — 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 (Wave E) — 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 +111,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 (Wave E) — 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 +168,79 @@ 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 (Wave E) — 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; } export async function reserveBudget( userId: string, estimatedTokens: number, - dateKey: string = buildDateKey(), - cap: number = OPERATOR_COST_CAP, + dateKey: string, + cap: number, + owner: BudgetCostOwner, ): Promise { const reserved = Number.isFinite(estimatedTokens) && estimatedTokens > 0 ? Math.floor(estimatedTokens) : 0; + // v1.38.19 (Wave E) — 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 prisma.$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. const priorTotal = totalAfter - reserved; + const priorOperator = operatorAfter - operatorReserved; if (priorTotal >= cap) { // 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); + return { + allowed: false, + reserved, + totalAfter: priorTotal, + owner, + operatorAfter: priorOperator, + }; } - return { allowed: true, reserved, totalAfter }; + return { allowed: true, reserved, totalAfter, owner, operatorAfter }; } /** @@ -194,12 +256,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 +287,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 (Wave E) — 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 +321,13 @@ export async function reconcileSpend( async function refundReservation( userId: string, reserved: number, + operatorReserved: number, dateKey: string, ): Promise { await prisma.$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..e9b3022ea 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,7 @@ export async function deriveClarifyingQuestions( QUESTIONS_MAX_TOKENS, dateKey, resolveDailyCap(chain), + resolveCostOwner(chain), ); if (!reservation.allowed) return fallback(); @@ -266,9 +268,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 +281,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/provider-runner.ts b/src/lib/ai/provider-runner.ts index e2bd1cef5..9b9344f73 100644 --- a/src/lib/ai/provider-runner.ts +++ b/src/lib/ai/provider-runner.ts @@ -431,8 +431,11 @@ 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, + })); + if (spent.total >= OPERATOR_COST_CAP) { const hop: FallbackHop = { providerType: candidate.providerType, attempt: i + 1, @@ -445,7 +448,7 @@ 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.total, }, }); continue; diff --git a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts index c9f6caf9d..f2d3e7abb 100644 --- a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts +++ b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts @@ -29,6 +29,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 600 }), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: 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..124e72c5e 100644 --- a/src/lib/documents/__tests__/index-document.test.ts +++ b/src/lib/documents/__tests__/index-document.test.ts @@ -45,6 +45,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn(), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: vi.fn(() => 1000), + resolveCostOwner: vi.fn(() => "operator" as const), })); vi.mock("@/lib/ai/ai-budgets", () => ({ AI_BUDGETS: { documentTranscribe: { temperature: 0, maxTokens: 4000 } }, @@ -106,6 +107,7 @@ beforeEach(() => { vi.mocked(reserveBudget).mockResolvedValue({ allowed: true, reserved: 1, + owner: "operator", } as never); vi.mocked(localExtractText).mockResolvedValue({ ok: true, @@ -132,7 +134,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 +231,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..955005b1d 100644 --- a/src/lib/documents/__tests__/index-outcome-record.test.ts +++ b/src/lib/documents/__tests__/index-outcome-record.test.ts @@ -56,6 +56,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn(), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: 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..2182991b2 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -283,6 +283,7 @@ export async function maybeAutoStageLabFacts( AI_BUDGETS.ocrExtractText.maxTokens, dateKey, provider.dailyCap, + provider.costOwner, ); if (!reservation.allowed) return { staged: false, reason: "budget" }; @@ -298,9 +299,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..9e70b7863 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,7 @@ export async function streamFencedReply( AI_BUDGETS.documentChat.maxTokens, dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { annotate({ action: { name: "documents.chat.budget.exceeded" } }); @@ -383,9 +385,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 +428,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..1c222c7c8 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 (Wave E) — 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 (Wave E) — 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,7 @@ async function tryProviderIndex( AI_BUDGETS.documentTranscribe.maxTokens, dateKey, provider.dailyCap, + provider.costOwner, ); // 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 +253,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 +278,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/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index bb013840f..a98749ed1 100644 --- a/src/lib/insights/briefing-provider.ts +++ b/src/lib/insights/briefing-provider.ts @@ -39,6 +39,7 @@ import { buildDateKey, reconcileSpend, reserveBudget, + resolveCostOwner, resolveDailyCap, } from "@/lib/ai/coach/budget"; import { singleUserTurn } from "@/lib/ai/types"; @@ -122,6 +123,7 @@ export async function runBriefingCompletion( estimatedTokens, dateKey, resolveDailyCap(args.chain), + resolveCostOwner(args.chain), ); if (!reservation.allowed) { annotate({ @@ -153,12 +155,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 +172,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..9c65481e3 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -13,6 +13,7 @@ import { buildDateKey, reconcileSpend, reserveBudget, + resolveCostOwner, resolveDailyCap, } from "@/lib/ai/coach/budget"; import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets"; @@ -223,6 +224,7 @@ export async function runStatusCompletion( estimatedTokens, dateKey, resolveDailyCap(chain), + resolveCostOwner(chain), ); if (!reservation.allowed) { // Over the day's ceiling. Reported as `error` — a TRANSIENT miss the caller @@ -264,11 +266,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 +287,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..0392ca979 100644 --- a/src/lib/jobs/__tests__/async-state-contract.test.ts +++ b/src/lib/jobs/__tests__/async-state-contract.test.ts @@ -76,6 +76,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn(), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: 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..35aff0813 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,7 @@ const budgetMocks = vi.hoisted(() => ({ })), reconcileSpend: vi.fn(async () => {}), resolveDailyCap: 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..36a14cd9e 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,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn(), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: 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..af419b6c7 100644 --- a/src/lib/jobs/__tests__/document-summary.test.ts +++ b/src/lib/jobs/__tests__/document-summary.test.ts @@ -50,6 +50,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: vi.fn(), reconcileSpend: vi.fn().mockResolvedValue(undefined), resolveDailyCap: 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 +125,7 @@ beforeEach(() => { vi.mocked(reserveBudget).mockResolvedValue({ allowed: true, reserved: 5, + owner: "operator", } as never); }); @@ -151,7 +153,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 +225,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..9883df194 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -88,6 +88,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: (...a: unknown[]) => reserveBudget(...a), reconcileSpend: (...a: unknown[]) => reconcileSpend(...a), resolveDailyCap: () => 200_000, + resolveCostOwner: () => "operator" as const, })); const loadDailyDigest = vi.fn(); @@ -467,6 +468,7 @@ describe("reaction line — degradation", () => { 1_400, "2026-07-16", 0, + { servedBy: expect.any(String), reservedOwner: "operator" }, ); }); @@ -528,6 +530,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 +627,7 @@ describe("reaction line — degradation", () => { 900, "2026-07-16", 0, + { servedBy: expect.any(String), reservedOwner: "operator" }, ); expect(update).not.toHaveBeenCalled(); expectSurfaceStillWorks(); @@ -683,6 +688,7 @@ describe("reaction line — degradation", () => { 1_100, "2026-07-16", 100, + { servedBy: expect.any(String), reservedOwner: "operator" }, ); }); }); diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index 756ca1e02..b4127c5d5 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -43,6 +43,7 @@ import { buildDateKey, reconcileSpend, reserveBudget, + resolveCostOwner, resolveDailyCap, } from "@/lib/ai/coach/budget"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; @@ -199,6 +200,7 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { maxTokens, dateKey, dailyCap, + resolveCostOwner(chain), ); if (!reservation.allowed) { annotate({ action: { name: "coach.nudge.ai.budget_exceeded" } }); @@ -224,12 +226,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 +240,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/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index d2b8a8051..dc6906a93 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -38,6 +38,7 @@ import { buildDateKey, reconcileSpend, reserveBudget, + resolveCostOwner, resolveDailyCap, } from "@/lib/ai/coach/budget"; import { prisma } from "@/lib/db"; @@ -231,6 +232,7 @@ export async function runContentIndexBackfillForUser( AI_BUDGETS.documentTranscribe.maxTokens, dateKey, dailyCap, + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { reason = "budget-reached"; @@ -239,7 +241,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 +254,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 +274,11 @@ export async function runContentIndexBackfillForUser( reservation.reserved, reservation.reserved, dateKey, + 0, + { + servedBy: pick.entry.providerType, + reservedOwner: reservation.owner, + }, ); await upsertContentIndex({ userId, @@ -278,7 +291,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..86308b5c9 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -30,6 +30,7 @@ import { buildDateKey, reconcileSpend, reserveBudget, + resolveCostOwner, resolveDailyCap, } from "@/lib/ai/coach/budget"; import { @@ -191,6 +192,7 @@ export async function runDocumentSummaryJob( AI_BUDGETS.documentSummary.maxTokens, dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), + resolveCostOwner([{ providerType: pick.entry.providerType }]), ); // Budget exhausted → skip (no local fallback for a summary). if (!reservation.allowed) { @@ -224,6 +226,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 +277,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..a3678576c 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -41,7 +41,9 @@ import { isModuleEnabled } from "@/lib/modules/gate"; import { buildDateKey, reconcileSpend, + resolveCostOwner, resolveDailyCap, + 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 +316,13 @@ type ReactionReservation = { allowed: boolean; reserved: number; dateKey: string; + /** + * v1.38.19 (Wave E) — the cost owner the reservation was booked to. A + * RESUMED reservation (the claim survived a crash, its tokens are on the + * row) recomputes it from the same chain the reservation was made under, so + * the reconcile reverses exactly what the reservation added. + */ + owner: BudgetCostOwner; }; class ReactionClaimLostError extends Error { @@ -334,6 +343,7 @@ async function reserveClaimBudget( revision: Date, dateKey: string, cap: number, + owner: BudgetCostOwner, ): Promise { return prisma.$transaction(async (tx) => { const stillOwned = await tx.arrivalReaction.updateMany({ @@ -352,11 +362,17 @@ async function reserveClaimBudget( if (stillOwned.count !== 1) throw new ReactionClaimLostError(); const reserved = ARRIVAL_REACTION_RESERVE_TOKENS; + // v1.38.19 (Wave E) — this reservation is the claim-linked twin of + // `reserveBudget`, so it books the owner split the same way: an + // operator-funded chain increments `operator_tokens` inside the SAME + // statement as the total, and the refusal path reverses both. + const operatorReserved = owner === "operator" ? reserved : 0; 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()) + 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 @@ -366,6 +382,7 @@ async function reserveClaimBudget( await tx.$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} @@ -374,7 +391,7 @@ async function reserveClaimBudget( 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({ @@ -395,7 +412,7 @@ async function reserveClaimBudget( }); if (linked.count !== 1) throw new ReactionClaimLostError(); - return { allowed: true, reserved, dateKey }; + return { allowed: true, reserved, dateKey, owner }; }); } @@ -526,6 +543,7 @@ export async function runReactionLine( allowed: true, reserved: row.generationReservedTokens!, dateKey: row.generationBudgetDateKey!, + owner: resolveCostOwner(chain), }; } else { try { @@ -536,6 +554,7 @@ export async function runReactionLine( revision, buildDateKey(), resolveDailyCap(chain), + resolveCostOwner(chain), ); } catch (err) { if (err instanceof ReactionClaimLostError) { @@ -625,6 +644,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 +659,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/coach-budget-owner.test.ts b/tests/integration/coach-budget-owner.test.ts new file mode 100644 index 000000000..ab610b06d --- /dev/null +++ b/tests/integration/coach-budget-owner.test.ts @@ -0,0 +1,186 @@ +/** + * v1.38.19 (Wave E) — 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", + ); + 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", + ); + 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", + ); + 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", + ); + 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("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", + ); + 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..1ff211fc0 100644 --- a/tests/integration/coach-budget-reservation.test.ts +++ b/tests/integration/coach-budget-reservation.test.ts @@ -42,8 +42,8 @@ describe("reserveBudget (real Postgres)", () => { }); const [a, b] = await Promise.all([ - reserveBudget(userId, 600, dateKey, cap), - reserveBudget(userId, 600, dateKey, cap), + reserveBudget(userId, 600, dateKey, cap, "operator"), + reserveBudget(userId, 600, dateKey, cap, "operator"), ]); // Exactly one is admitted: the first to commit sees prior 900 (< cap) and @@ -65,7 +65,7 @@ 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"); expect(res.allowed).toBe(true); const row = await getPrismaClient().coachUsage.findUnique({ @@ -81,8 +81,11 @@ 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"); + 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 +102,11 @@ 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"); + 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 } }, From 2c46712e13f342efa35a818ade03b805b13be573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:56:44 +0200 Subject: [PATCH 02/10] fix(ai): the operator cap counts only tokens the operator paid for The reservation compared the day's TOTAL against whichever ceiling the chain resolved, and the chain walker's hop guard did the same. On an account whose background jobs run on the user's own ChatGPT plan, both saw a total the operator had not paid for: the chat refused at 06:42Z with 513 539 tokens against the 200 000 operator ceiling, and the fallback hop to the shared key was closed on the same arithmetic. An operator-funded chain is now measured against `operator_tokens` and a user-plan chain keeps its abuse ceiling on the total; the hop guard reads the operator share too. A day of turns the user's own plan paid for can no longer lock anyone out of the operator's ceiling. --- .../insights/generate/__tests__/route.test.ts | 8 +- src/lib/ai/__tests__/provider-runner.test.ts | 60 ++++++++++++++ src/lib/ai/coach/__tests__/budget.test.ts | 78 ++++++++++++++++++- src/lib/ai/coach/budget.ts | 12 ++- src/lib/ai/provider-runner.ts | 7 +- .../briefing-provider-budget.test.ts | 25 +++++- ...ensive-briefing-grounding-disposal.test.ts | 2 +- .../comprehensive-briefing-reroll.test.ts | 2 +- .../comprehensive-budget-refusal.test.ts | 22 +++++- .../comprehensive-generate-force.test.ts | 2 +- .../comprehensive-json-retry.test.ts | 2 +- .../comprehensive-privacy-flip.test.ts | 2 +- .../comprehensive-timeout-honored.test.ts | 2 +- .../comprehensive-warm-timeout.test.ts | 2 +- .../__tests__/status-provider-budget.test.ts | 25 +++++- tests/integration/coach-budget-owner.test.ts | 60 ++++++++++++++ 16 files changed, 285 insertions(+), 26 deletions(-) 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/lib/ai/__tests__/provider-runner.test.ts b/src/lib/ai/__tests__/provider-runner.test.ts index 9514243db..bb5509aed 100644 --- a/src/lib/ai/__tests__/provider-runner.test.ts +++ b/src/lib/ai/__tests__/provider-runner.test.ts @@ -114,6 +114,7 @@ function err(status: number, msg = "boom"): Error & { httpStatus: number } { beforeEach(() => { clearLastWorkingProviderCache(); budgetState.spent = 0; + budgetState.operatorSpent = 0; }); afterEach(() => { @@ -700,6 +701,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) }], @@ -730,6 +732,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) }], @@ -748,6 +751,63 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () => expect(adminOpenai.callCount).toBe(1); }); + // v1.38.19 (Wave E) — 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", + 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", + 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 }] }); diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index cb6fbe304..5b8f012c7 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -172,7 +172,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"); @@ -189,6 +189,82 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => { }); }); +/** + * v1.38.19 (Wave E) — 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. + */ +describe("reserveBudget cap by cost owner (Wave E)", () => { + 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", + ); + 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", + ); + 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", + ); + expect(res.allowed).toBe(false); + }); +}); + describe("reconcileSpend cached-token subtraction (F3)", () => { let prismaMock: { $executeRaw: ReturnType }; diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 44f6eab61..ab4c3f506 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -223,11 +223,17 @@ export async function reserveBudget( // 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 (Wave E) — 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; const priorOperator = operatorAfter - operatorReserved; - if (priorTotal >= cap) { + const prior = owner === "operator" ? priorOperator : priorTotal; + if (prior >= cap) { // Already over before this request — refund the reservation + the // message-count bump and refuse. await refundReservation(userId, reserved, operatorReserved, dateKey); diff --git a/src/lib/ai/provider-runner.ts b/src/lib/ai/provider-runner.ts index 9b9344f73..e414423fd 100644 --- a/src/lib/ai/provider-runner.ts +++ b/src/lib/ai/provider-runner.ts @@ -435,7 +435,10 @@ async function runRawChain( total: 0, operator: 0, })); - if (spent.total >= OPERATOR_COST_CAP) { + // v1.38.19 (Wave E) — 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. + if (spent.operator >= OPERATOR_COST_CAP) { const hop: FallbackHop = { providerType: candidate.providerType, attempt: i + 1, @@ -448,7 +451,7 @@ 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.total, + operator_cap_spent: spent.operator, }, }); continue; diff --git a/src/lib/insights/__tests__/briefing-provider-budget.test.ts b/src/lib/insights/__tests__/briefing-provider-budget.test.ts index b79a4a7ff..b51df1541 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` (Wave E). */ +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[]) => { + // Wave E — (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; + // Wave E — 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..8d214b0b6 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` (Wave E). */ +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[]) => { + // Wave E — (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; + // Wave E — 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..0593bdf7e 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` (Wave E). */ +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[]) => { + // Wave E — (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; + // Wave E — 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" }); }); @@ -126,6 +139,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()); @@ -158,6 +172,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 +187,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 +202,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/tests/integration/coach-budget-owner.test.ts b/tests/integration/coach-budget-owner.test.ts index ab610b06d..95f81f8a5 100644 --- a/tests/integration/coach-budget-owner.test.ts +++ b/tests/integration/coach-budget-owner.test.ts @@ -153,6 +153,66 @@ describe("coach_usage.operator_tokens (real Postgres)", () => { 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", + ); + 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", + ); + 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); + }); + it("refunds both counters when a refused reservation is rolled back", async () => { const { reserveBudget, OPERATOR_COST_CAP } = await import("@/lib/ai/coach/budget"); From d3196ee69ee7c0efc80a513872cd455bf0979725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 10:09:34 +0200 Subject: [PATCH 03/10] fix(ai): background generation cannot spend the interactive day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automatic generators — status and reference cards, the briefing, the arrival reaction, the nudge, document summaries and the index backfill — reserved against the same ceiling as the chat. On the operator's account that meant 150-330 ledger rows a day, 172 metric generations before 06:42Z, and a ceiling already spent when he opened the chat. Every background surface now reserves through `resolveDailyCapFor("job", chain)`: half the cost owner's ceiling, all job surfaces together. The interactive routes keep the whole ceiling, so a day of background work can no longer take the user's own away. --- src/app/api/ai/test/__tests__/route.test.ts | 1 + .../inbound/[id]/chat/__tests__/route.test.ts | 1 + .../[id]/extract/__tests__/route.test.ts | 1 + .../[id]/index/__tests__/route.test.ts | 1 + .../[id]/suggest/__tests__/route.test.ts | 1 + .../[id]/summary/__tests__/route.test.ts | 1 + .../__tests__/route-cancelled-turn.test.ts | 1 + .../chat/__tests__/route-fence-guard.test.ts | 1 + .../__tests__/route-guard-activation.test.ts | 1 + .../chat/__tests__/route-guard-ledger.test.ts | 1 + .../chat/__tests__/route-guard.test.ts | 1 + .../chat/__tests__/route-list-query.test.ts | 1 + .../chat/__tests__/route-module-gate.test.ts | 1 + .../__tests__/route-snapshot-once.test.ts | 1 + .../chat/__tests__/route-tool-mode.test.ts | 1 + .../__tests__/route-workout-scope.test.ts | 1 + .../labs/ocr/extract/__tests__/route.test.ts | 1 + .../extract/__tests__/route.test.ts | 1 + src/lib/ai/coach/__tests__/budget.test.ts | 36 +++++++++++++++++++ .../self-context-questions-consent.test.ts | 1 + src/lib/ai/coach/budget.ts | 28 +++++++++++++++ .../__tests__/auto-stage-labs.test.ts | 3 ++ .../__tests__/fenced-chat-pipeline.test.ts | 1 + .../__tests__/index-document.test.ts | 1 + .../__tests__/index-outcome-record.test.ts | 1 + src/lib/documents/auto-stage-labs.ts | 9 +++-- .../__tests__/status-provider-budget.test.ts | 27 ++++++++++++++ src/lib/insights/briefing-provider.ts | 9 ++--- src/lib/insights/status-provider.ts | 7 ++-- .../__tests__/async-state-contract.test.ts | 1 + .../__tests__/coach-nudge-ai-consent.test.ts | 1 + .../document-content-index-backfill.test.ts | 1 + .../jobs/__tests__/document-summary.test.ts | 1 + .../reaction-line-degradation.test.ts | 1 + src/lib/jobs/coach-nudge-ai.ts | 5 +-- .../jobs/document-content-index-backfill.ts | 7 ++-- src/lib/jobs/document-summary.ts | 5 +-- src/lib/jobs/reaction-line.ts | 5 +-- 38 files changed, 151 insertions(+), 17 deletions(-) diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts index a902128bc..840c64368 100644 --- a/src/app/api/ai/test/__tests__/route.test.ts +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -35,6 +35,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ })), reconcileSpend: vi.fn(async () => undefined), resolveDailyCap: () => 200_000, + resolveDailyCapFor: () => 200_000, resolveCostOwner: () => "operator" as const, })); 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 7d832c732..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,7 @@ 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", () => ({ 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 75aa91905..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,7 @@ 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", () => ({ 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 544005665..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,7 @@ 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", () => ({ 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 40f2e9603..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,7 @@ 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", () => ({ 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 2bdd26141..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,7 @@ 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", () => ({ 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 9640c3871..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 @@ -105,6 +105,7 @@ 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), })); 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 61a0dd543..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,7 @@ 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", () => ({ 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 078fb58ac..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,7 @@ 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), })); 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 4eb694265..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,7 @@ 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), })); 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 d2bee8745..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,7 @@ 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), })); 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 96a2ef5cd..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,7 @@ 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() })); 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 fd0add566..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,7 @@ 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() })); 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 7ba30cf4b..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,7 @@ 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", () => ({ 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 d88dab268..f0b475076 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 @@ -133,6 +133,7 @@ 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), })); 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 16d9b72b2..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,7 @@ 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", () => ({ 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 c926aecc0..d5b1dca60 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,7 @@ 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 () => { diff --git a/src/app/api/medications/extract/__tests__/route.test.ts b/src/app/api/medications/extract/__tests__/route.test.ts index 3c43ec73b..b0d454ef5 100644 --- a/src/app/api/medications/extract/__tests__/route.test.ts +++ b/src/app/api/medications/extract/__tests__/route.test.ts @@ -44,6 +44,7 @@ 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), })); diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index 5b8f012c7..62bb3ad76 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -4,8 +4,10 @@ import { buildDateKey, OPERATOR_COST_CAP, USER_PLAN_CAP, + JOB_SURFACE_SHARE, resolveCostOwner, resolveDailyCap, + resolveDailyCapFor, } from "../budget"; vi.mock("@/lib/db", () => ({ @@ -198,6 +200,40 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => { * `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.19 (Wave E) — background generation gets a bounded slice of the day. + * + * 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. A + * background surface may reserve at most half of the interactive ceiling. + */ +describe("resolveDailyCapFor (Wave E — the job share)", () => { + it("gives a background surface half the operator ceiling", () => { + expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe( + OPERATOR_COST_CAP * JOB_SURFACE_SHARE, + ); + expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe( + 100_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("shares the user-plan ceiling the same way", () => { + expect(resolveDailyCapFor("job", [{ providerType: "codex" }])).toBe( + USER_PLAN_CAP * JOB_SURFACE_SHARE, + ); + }); +}); + describe("reserveBudget cap by cost owner (Wave E)", () => { let prismaMock: { $queryRaw: ReturnType; 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 9276adb5a..f3910f854 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 @@ -46,6 +46,7 @@ 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/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index ab4c3f506..09e80ab59 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -68,6 +68,34 @@ export function resolveDailyCap( : USER_PLAN_CAP; } +/** + * v1.38.19 (Wave E) — the share of a day's ceiling that BACKGROUND generation + * may reserve, all job surfaces together. + * + * 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. Half the + * ceiling keeps the interactive surfaces a day of their own no matter how much + * background generation the instance schedules. + */ +export const JOB_SURFACE_SHARE = 0.5; + +/** + * v1.38.19 (Wave E) — 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) gets `JOB_SURFACE_SHARE` of it. The cost owner is unchanged — + * this only rations how much of the owner's ceiling a background surface may + * claim. + */ +export function resolveDailyCapFor( + surface: "coach" | "job", + chain: ReadonlyArray<{ providerType: ProviderChainType }>, +): number { + const cap = resolveDailyCap(chain); + return surface === "job" ? Math.floor(cap * JOB_SURFACE_SHARE) : 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 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 f2d3e7abb..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,7 @@ 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", () => ({ diff --git a/src/lib/documents/__tests__/index-document.test.ts b/src/lib/documents/__tests__/index-document.test.ts index 124e72c5e..c6de6c93b 100644 --- a/src/lib/documents/__tests__/index-document.test.ts +++ b/src/lib/documents/__tests__/index-document.test.ts @@ -45,6 +45,7 @@ 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", () => ({ diff --git a/src/lib/documents/__tests__/index-outcome-record.test.ts b/src/lib/documents/__tests__/index-outcome-record.test.ts index 955005b1d..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,7 @@ 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", () => ({ diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index 2182991b2..2188d4795 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,17 @@ 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 (Wave E) — 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, ); if (!reservation.allowed) return { staged: false, reason: "budget" }; diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index 0593bdf7e..fa3568716 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -128,6 +128,33 @@ describe("runStatusCompletion — ledger accounting", () => { expect(ledgerTotal).toBe(1234); }); + it("refuses a background generation at the job share while the chat still runs", async () => { + // Wave E — the automatic status/reference generators may spend at most + // half the operator ceiling, so the interactive chat still has a day left + // when the jobs have had theirs. One token past the job share: + ledgerTotal = 100_001; + ledgerOperator = 100_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), + ); + expect(chat.allowed).toBe(true); + }); + it("charges the reservation when the provider reports no token count", async () => { mockProviderReply(null); diff --git a/src/lib/insights/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index a98749ed1..fc248e038 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 @@ -40,7 +40,7 @@ import { reconcileSpend, reserveBudget, resolveCostOwner, - resolveDailyCap, + resolveDailyCapFor, } from "@/lib/ai/coach/budget"; import { singleUserTurn } from "@/lib/ai/types"; import { annotate } from "@/lib/logging/context"; @@ -122,7 +122,8 @@ export async function runBriefingCompletion( args.userId, estimatedTokens, dateKey, - resolveDailyCap(args.chain), + // v1.38.19 (Wave E) — a background surface: half the day's ceiling. + resolveDailyCapFor("job", args.chain), resolveCostOwner(args.chain), ); if (!reservation.allowed) { diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 9c65481e3..109f7145d 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -14,7 +14,7 @@ import { reconcileSpend, reserveBudget, resolveCostOwner, - resolveDailyCap, + resolveDailyCapFor, } from "@/lib/ai/coach/budget"; import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets"; import { singleUserTurn } from "@/lib/ai/types"; @@ -211,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 @@ -223,7 +223,8 @@ export async function runStatusCompletion( userId, estimatedTokens, dateKey, - resolveDailyCap(chain), + // v1.38.19 (Wave E) — a background surface: half the day's ceiling. + resolveDailyCapFor("job", chain), resolveCostOwner(chain), ); if (!reservation.allowed) { diff --git a/src/lib/jobs/__tests__/async-state-contract.test.ts b/src/lib/jobs/__tests__/async-state-contract.test.ts index 0392ca979..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,7 @@ 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", () => ({ 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 35aff0813..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,7 @@ 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 36a14cd9e..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,7 @@ 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 af419b6c7..9a4c84a3f 100644 --- a/src/lib/jobs/__tests__/document-summary.test.ts +++ b/src/lib/jobs/__tests__/document-summary.test.ts @@ -50,6 +50,7 @@ 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", () => ({ diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index 9883df194..8e2ff0ac5 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -88,6 +88,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: (...a: unknown[]) => reserveBudget(...a), reconcileSpend: (...a: unknown[]) => reconcileSpend(...a), resolveDailyCap: () => 200_000, + resolveDailyCapFor: () => 200_000, resolveCostOwner: () => "operator" as const, })); diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index b4127c5d5..d9d8b7b1e 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -44,7 +44,7 @@ import { reconcileSpend, reserveBudget, resolveCostOwner, - resolveDailyCap, + resolveDailyCapFor, } from "@/lib/ai/coach/budget"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { singleUserTurn } from "@/lib/ai/types"; @@ -191,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 (Wave E) — 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). diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index dc6906a93..bdec599f1 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -39,7 +39,7 @@ import { reconcileSpend, reserveBudget, resolveCostOwner, - resolveDailyCap, + resolveDailyCapFor, } from "@/lib/ai/coach/budget"; import { prisma } from "@/lib/db"; import { @@ -200,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 (Wave E) — 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; diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 86308b5c9..f4c1307d9 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -31,7 +31,7 @@ import { reconcileSpend, reserveBudget, resolveCostOwner, - resolveDailyCap, + resolveDailyCapFor, } from "@/lib/ai/coach/budget"; import { loadOwnedDocument, @@ -191,7 +191,8 @@ export async function runDocumentSummaryJob( userId, AI_BUDGETS.documentSummary.maxTokens, dateKey, - resolveDailyCap([{ providerType: pick.entry.providerType }]), + // v1.38.19 (Wave E) — a background surface: half the day's ceiling. + resolveDailyCapFor("job", [{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), ); // Budget exhausted → skip (no local fallback for a summary). diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index a3678576c..2d49c25e2 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -42,7 +42,7 @@ import { buildDateKey, reconcileSpend, resolveCostOwner, - resolveDailyCap, + resolveDailyCapFor, type BudgetCostOwner, } from "@/lib/ai/coach/budget"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; @@ -553,7 +553,8 @@ export async function runReactionLine( job.userId, revision, buildDateKey(), - resolveDailyCap(chain), + // v1.38.19 (Wave E) — a background surface: half the day's ceiling. + resolveDailyCapFor("job", chain), resolveCostOwner(chain), ); } catch (err) { From b750e0bc7dd57900358a15560d6935d1ecb6be47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 10:18:12 +0200 Subject: [PATCH 04/10] feat(admin): the AI readout separates operator-paid tokens The provider-health endpoint showed delivery health only. When a surface refuses on budget the operator's next question is which ceiling he hit, and one mixed token figure cannot answer it: a day at 1.24 M tokens looks alarming until you see that 151 200 of them ran on the instance's own key and the rest on the users' plans. `spendToday` carries the day's key and both counters, aggregated across accounts. The operator ceiling is enforced against `operatorTokens`, so that is the figure the readout puts beside the total. --- .../provider-health/__tests__/route.test.ts | 50 +++++++++++++++++++ src/app/api/admin/provider-health/route.ts | 25 +++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) 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..f0f819c3f 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 (Wave E) — 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..0e8b42e13 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 (Wave E) — 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, + }, + }); }); From ff51b72cd1b863b3ce7c7851c9731fa65f168a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 10:50:21 +0200 Subject: [PATCH 05/10] chore(db): renumber the operator-token migration into the series The branch numbered the coach_usage column migration 0341 over a 0340 that exists on no branch. Prisma orders lexicographically, so the gap is inert for migrate deploy, but a later 0340 merged after this ships applies out of order and makes prisma migrate dev complain on every developer machine. The series now runs 0339, 0340 with no hole. --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename prisma/migrations/{0341_coach_usage_operator_tokens => 0340_coach_usage_operator_tokens}/migration.sql (100%) diff --git a/prisma/migrations/0341_coach_usage_operator_tokens/migration.sql b/prisma/migrations/0340_coach_usage_operator_tokens/migration.sql similarity index 100% rename from prisma/migrations/0341_coach_usage_operator_tokens/migration.sql rename to prisma/migrations/0340_coach_usage_operator_tokens/migration.sql From 18ed57b8671ca1d49f44aafbbb543056f66c968f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 11:11:01 +0200 Subject: [PATCH 06/10] fix(ai): every surface keeps a ceiling on the day's mixed total The owner split left an operator-funded chain with no ceiling at all. Its arm compared operator_tokens, and that counter returns to roughly zero on every reconcile the user's own plan settled: on the 2026-09-11 evidence the shared key answered 500 and codex served, so the counter never grew, every job reservation was admitted forever, and a runaway client loop could write an unbounded row. A pre-existing concurrency contract caught it from the other side, two reservations that must not both pass one cap both passing. reserveBudget now refuses when the owner's counter is over its cap OR the day's mixed total is over the abuse ceiling, and a background surface gets its share of that ceiling too, so background spend is bounded on the total and not only under a counter the operator's key never fills. The surface is a required argument, so the type checker enumerated every reservation site. The hop guard learns the same lesson: an operator-funded fallback hop is now rationed at the calling surface's ceiling rather than the full operator cap. A job admitted under the job share could previously spend the whole 200 000 once its chain fell back onto the shared key, which is the original defect reached by the fallback path. The job share applies only where the operator pays for the chain. Multiplying whichever ceiling the chain resolved halved background generation for a self-hoster on a local model, who costs the operator nothing. Refusals now name the ceiling they hit and whose it was. A refusal used to emit the day's mixed total and nothing else, which on an operator refusal is not the counter that tripped. --- src/app/api/ai/test/route.ts | 1 + .../documents/inbound/[id]/extract/route.ts | 3 + .../api/documents/inbound/[id]/index/route.ts | 1 + .../documents/inbound/[id]/suggest/route.ts | 2 + .../documents/inbound/[id]/summary/route.ts | 2 + .../chat/__tests__/route-tool-mode.test.ts | 2 + src/app/api/insights/chat/route.ts | 17 ++- .../labs/ocr/extract/__tests__/route.test.ts | 2 + src/app/api/labs/ocr/extract/route.ts | 2 + .../extract/__tests__/route.test.ts | 3 + src/app/api/medications/extract/route.ts | 2 + .../__tests__/gateway-embedded-error.test.ts | 3 + src/lib/ai/__tests__/provider-runner.test.ts | 125 ++++++++++++++++++ src/lib/ai/coach/__tests__/budget.test.ts | 119 ++++++++++++++++- .../self-context-questions-consent.test.ts | 1 + src/lib/ai/coach/budget.ts | 108 +++++++++++++-- src/lib/ai/coach/self-context-questions.ts | 1 + src/lib/ai/coach/tools/loop.ts | 2 + src/lib/ai/provider-runner.ts | 33 ++++- src/lib/documents/auto-stage-labs.ts | 1 + src/lib/documents/fenced-chat.ts | 2 + src/lib/documents/index-document.ts | 1 + .../__tests__/status-provider-budget.test.ts | 13 ++ src/lib/insights/briefing-provider.ts | 20 ++- src/lib/insights/status-provider.ts | 25 +++- src/lib/jobs/coach-nudge-ai.ts | 1 + .../jobs/document-content-index-backfill.ts | 1 + src/lib/jobs/document-summary.ts | 1 + tests/integration/coach-budget-owner.test.ts | 99 ++++++++++++++ .../coach-budget-reservation.test.ts | 77 ++++++++++- 30 files changed, 645 insertions(+), 25 deletions(-) diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 389938c71..27265df91 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -119,6 +119,7 @@ export const POST = apiHandler(async (request: NextRequest) => { providerType: provider.type === "admin-key" ? "admin-openai" : "local", }, ]), + "coach", ); if (!reservation.allowed) { annotate({ action: { name: "ai.test.budget_exceeded" } }); diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 75b43c825..118717268 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -256,6 +256,7 @@ async function handleTextExtract( dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), + "coach", ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -359,6 +360,7 @@ async function handleStoredExtract( dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), + "coach", ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -479,6 +481,7 @@ async function handleVisionExtract( dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), + "coach", ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 87f37415a..04530af0c 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -251,6 +251,7 @@ async function handleVisionIndex( dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), + "coach", ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 61bd2499d..0fe5d57a7 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -168,6 +168,7 @@ async function handleTextSuggest( dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), + "coach", ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -263,6 +264,7 @@ async function handleVisionSuggest( dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), + "coach", ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index f0903b787..f207faf12 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -347,6 +347,7 @@ async function handleTextSummary( dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), + "coach", ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); @@ -438,6 +439,7 @@ async function handleVisionSummary( dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), + "coach", ); if (!reservation.allowed) { await refundDocumentAiSlot(userId); 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 f0b475076..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 @@ -326,6 +326,7 @@ describe("coach chat — tool-mode routing (F1)", () => { "2026-06-21", 2_000_000, "user", + "coach", ); }); @@ -569,6 +570,7 @@ describe("coach chat — tool-mode routing (F1)", () => { "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/route.ts b/src/app/api/insights/chat/route.ts index 5f6c036ce..c0880ce9d 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -606,11 +606,25 @@ async function handleChatRequest(request: NextRequest): Promise { reqDateKey, dailyCap, resolveCostOwner(chain), + "coach", ); if (!reservation.allowed) { + // v1.38.19 (Wave E, fix round 1) — 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" }); } @@ -748,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: () => { 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 d5b1dca60..9471f6ac1 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -122,6 +122,8 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => { 200_000, // Wave E — and the cost owner that cap is enforced against as the 5th. "operator", + // Wave E fix round 1 — the surface the ceiling is rationed for, 6th. + "coach", ); }); diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 7dfd230f6..901b5b0f5 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -185,6 +185,7 @@ async function handleTextExtract( dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), + "coach", ); if (!reservation.allowed) { annotate({ @@ -289,6 +290,7 @@ async function handleVisionExtract( dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), + "coach", ); if (!reservation.allowed) { annotate({ diff --git a/src/app/api/medications/extract/__tests__/route.test.ts b/src/app/api/medications/extract/__tests__/route.test.ts index b0d454ef5..d80ee7565 100644 --- a/src/app/api/medications/extract/__tests__/route.test.ts +++ b/src/app/api/medications/extract/__tests__/route.test.ts @@ -122,6 +122,7 @@ beforeEach(() => { totalAfter: 700, owner: "user", operatorAfter: 0, + limit: null, }); vi.mocked(resolveDailyCap).mockReturnValue(1_234_567); vi.mocked(resolveProviderChain).mockResolvedValue([ @@ -279,6 +280,7 @@ describe("POST /api/medications/extract — daily budget", () => { "2026-05-28", 1_234_567, "user", + "coach", ); }); @@ -290,6 +292,7 @@ describe("POST /api/medications/extract — daily budget", () => { totalAfter: 9_999_999, owner: "user", operatorAfter: 0, + limit: "owner-cap", }); const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never); diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index 883de5d55..4fe35ca8a 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -212,6 +212,7 @@ async function handleExtract(request: NextRequest): Promise { dateKey, resolveDailyCap(chain), resolveCostOwner(chain), + "coach", ); if (!reservation.allowed) { annotate({ @@ -224,6 +225,7 @@ async function handleExtract(request: NextRequest): Promise { let completion; try { completion = await runRawCompletionWithFallback({ + surface: "coach", userId, providers: chain, params: singleUserTurn({ 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 bb5509aed..3ff5d781a 100644 --- a/src/lib/ai/__tests__/provider-runner.test.ts +++ b/src/lib/ai/__tests__/provider-runner.test.ts @@ -173,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 }, @@ -186,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 }, @@ -216,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 }, @@ -232,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 }, @@ -248,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" }), }); @@ -269,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 }, @@ -296,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 }, @@ -324,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 }, @@ -361,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" }), }); @@ -383,6 +392,7 @@ describe("runRawCompletionWithFallback — empty input", () => { try { await runRawCompletionWithFallback({ userId: "u-empty", + surface: "coach", providers: [], params: singleUserTurn({ system: "s", user: "u" }), }); @@ -417,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, @@ -434,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, @@ -453,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, @@ -488,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") }, @@ -517,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, @@ -531,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, @@ -554,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 }, @@ -582,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 }, @@ -637,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), @@ -659,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), @@ -677,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 }, @@ -711,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 }, @@ -741,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 }, @@ -768,6 +791,7 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () => const outcome = await runRawCompletionWithFallback({ userId: "u-owner-split", + surface: "coach", providers: [ { providerType: "codex", instance: codex }, { providerType: "admin-openai", instance: adminOpenai }, @@ -790,6 +814,106 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () => 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 (Wave E, fix round 1) — 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 at the job share", async () => { + budgetState.spent = 150_000; + budgetState.operatorSpent = 150_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 share: at 150 000 operator tokens the background + // work is done for the day and the person waiting is not. + budgetState.spent = 150_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-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 }, @@ -813,6 +937,7 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () => 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 62bb3ad76..2a7f764b4 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -8,6 +8,7 @@ import { resolveCostOwner, resolveDailyCap, resolveDailyCapFor, + resolveTotalCapFor, } from "../budget"; vi.mock("@/lib/db", () => ({ @@ -69,6 +70,7 @@ describe("reserveBudget — ledger clamps", () => { "2026-05-10", OPERATOR_COST_CAP, "operator", + "coach", ); expect(res.reserved).toBe(0); }); @@ -81,6 +83,7 @@ describe("reserveBudget — ledger clamps", () => { "2026-05-10", OPERATOR_COST_CAP, "operator", + "coach", ); expect(res.reserved).toBe(0); }); @@ -93,6 +96,7 @@ describe("reserveBudget — ledger clamps", () => { "2026-05-10", OPERATOR_COST_CAP, "operator", + "coach", ); expect(res.reserved).toBe(12); }); @@ -165,6 +169,7 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => { "2026-05-10", resolveDailyCap([{ providerType: "codex" }]), resolveCostOwner([{ providerType: "codex" }]), + "coach", ); expect(res.allowed).toBe(true); // The reservation upsert ran; no refund executeRaw fired. @@ -184,6 +189,7 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => { "2026-05-10", resolveDailyCap([{ providerType: "admin-openai" }]), resolveCostOwner([{ providerType: "admin-openai" }]), + "coach", ); expect(res.allowed).toBe(false); // Refund of the reservation fired on refusal. @@ -227,10 +233,37 @@ describe("resolveDailyCapFor (Wave E — the job share)", () => { ); }); - it("shares the user-plan ceiling the same way", () => { + it("does not ration a chain the operator does not pay for", () => { + // The share exists to protect the OPERATOR's invoice. A self-hoster on a + // local model, or a user on their own ChatGPT plan, costs the operator + // nothing — halving their background generation is a silent downgrade with + // no bill behind it. expect(resolveDailyCapFor("job", [{ providerType: "codex" }])).toBe( + USER_PLAN_CAP, + ); + expect(resolveDailyCapFor("job", [{ providerType: "local" }])).toBe( + USER_PLAN_CAP, + ); + }); +}); + +describe("resolveTotalCapFor (Wave E, fix round 1 — the abuse ceiling)", () => { + it("keeps the user-plan ceiling on the day's mixed total for both owners", () => { + // 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", "operator")).toBe(USER_PLAN_CAP); + expect(resolveTotalCapFor("coach", "user")).toBe(USER_PLAN_CAP); + }); + + it("gives a background surface its share of the total ceiling too", () => { + expect(resolveTotalCapFor("job", "operator")).toBe( USER_PLAN_CAP * JOB_SURFACE_SHARE, ); + // Nothing to ration when the operator is not paying. + expect(resolveTotalCapFor("job", "user")).toBe(USER_PLAN_CAP); }); }); @@ -260,6 +293,7 @@ describe("reserveBudget cap by cost owner (Wave E)", () => { "2026-09-11", OPERATOR_COST_CAP, "operator", + "coach", ); expect(res.allowed).toBe(true); expect(res.operatorAfter).toBe(151_200); @@ -280,6 +314,7 @@ describe("reserveBudget cap by cost owner (Wave E)", () => { "2026-09-11", OPERATOR_COST_CAP, "operator", + "coach", ); expect(res.allowed).toBe(false); expect(prismaMock.$executeRaw).toHaveBeenCalled(); @@ -296,9 +331,91 @@ describe("reserveBudget cap by cost owner (Wave E)", () => { "2026-09-11", USER_PLAN_CAP, "user", + "coach", ); expect(res.allowed).toBe(false); }); + + // v1.38.19 (Wave E, fix round 1) — 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"); + }); + + 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)", () => { 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 f3910f854..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 @@ -246,6 +246,7 @@ describe("deriveClarifyingQuestions — atomic budget", () => { "2026-07-18", 200_000, "operator", + "coach", ); expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith( "user-1", diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 09e80ab59..e2b77969c 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 (Wave E, fix round 1) — 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. * @@ -80,6 +90,35 @@ export function resolveDailyCap( */ export const JOB_SURFACE_SHARE = 0.5; +/** + * v1.38.19 (Wave E) — 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.19 (Wave E, fix round 1) — the job share rations the OPERATOR's + * invoice, so it applies only when the operator is paying. + * + * The first cut multiplied whichever ceiling `resolveDailyCap` returned, which + * halved background generation for a self-hoster on a local model — zero + * marginal cost to anyone, and an unannounced downgrade for exactly the + * audience this project is built for. The share's whole justification is the + * operator's bill; it does not transfer to a chain the operator does not fund. + */ +function applyJobShare( + surface: BudgetSurface, + chain: ReadonlyArray<{ providerType: ProviderChainType }>, + cap: number, +): number { + return surface === "job" && resolveCostOwner(chain) === "operator" + ? Math.floor(cap * JOB_SURFACE_SHARE) + : cap; +} + /** * v1.38.19 (Wave E) — the daily ceiling for one SURFACE on a chain. `"coach"` * (every interactive surface: the chat, the extraction routes, the document @@ -89,11 +128,33 @@ export const JOB_SURFACE_SHARE = 0.5; * claim. */ export function resolveDailyCapFor( - surface: "coach" | "job", + surface: BudgetSurface, chain: ReadonlyArray<{ providerType: ProviderChainType }>, ): number { - const cap = resolveDailyCap(chain); - return surface === "job" ? Math.floor(cap * JOB_SURFACE_SHARE) : cap; + return applyJobShare(surface, chain, resolveDailyCap(chain)); +} + +/** + * v1.38.19 (Wave E, fix round 1) — the ABUSE ceiling on the day's mixed total, + * for one surface and one cost owner. + * + * `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` stays the ceiling on + * the total in BOTH arms, and a background surface gets its share of that too. + */ +export function resolveTotalCapFor( + surface: BudgetSurface, + owner: BudgetCostOwner, +): number { + return surface === "job" && owner === "operator" + ? Math.floor(USER_PLAN_CAP * JOB_SURFACE_SHARE) + : USER_PLAN_CAP; } /** @@ -211,6 +272,17 @@ export interface ReserveBudgetResult { owner: BudgetCostOwner; /** The day's operator-funded spend AFTER this reservation. */ operatorAfter: number; + /** + * v1.38.19 (Wave E, fix round 1) — 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( @@ -219,6 +291,8 @@ export async function reserveBudget( dateKey: string, cap: number, owner: BudgetCostOwner, + surface: BudgetSurface, + db: BudgetExecutor = prisma, ): Promise { const reserved = Number.isFinite(estimatedTokens) && estimatedTokens > 0 @@ -234,7 +308,7 @@ export async function reserveBudget( // 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< + 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) @@ -261,20 +335,37 @@ export async function reserveBudget( const priorTotal = totalAfter - reserved; const priorOperator = operatorAfter - operatorReserved; const prior = owner === "operator" ? priorOperator : priorTotal; - if (prior >= cap) { + // v1.38.19 (Wave E, fix round 1) — 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, owner); + 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, operatorReserved, dateKey); + await refundReservation(userId, reserved, operatorReserved, dateKey, db); return { allowed: false, reserved, totalAfter: priorTotal, owner, operatorAfter: priorOperator, + limit, }; } - return { allowed: true, reserved, totalAfter, owner, operatorAfter }; + return { + allowed: true, + reserved, + totalAfter, + owner, + operatorAfter, + limit: null, + }; } /** @@ -357,8 +448,9 @@ async function refundReservation( 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}), diff --git a/src/lib/ai/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index e9b3022ea..34cd3b0e2 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -237,6 +237,7 @@ export async function deriveClarifyingQuestions( dateKey, resolveDailyCap(chain), resolveCostOwner(chain), + "coach", ); if (!reservation.allowed) return fallback(); 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 e414423fd..dd2dfec4b 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) { @@ -438,7 +441,21 @@ async function runRawChain( // v1.38.19 (Wave E) — 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. - if (spent.operator >= OPERATOR_COST_CAP) { + // + // v1.38.19 (Wave E, fix round 1) — 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, "operator"); + if (spent.operator >= hopCap || spent.total >= hopTotalCap) { const hop: FallbackHop = { providerType: candidate.providerType, attempt: i + 1, @@ -452,6 +469,8 @@ async function runRawChain( [`ai_chain_hop_${i + 1}_provider`]: candidate.providerType, [`ai_chain_hop_${i + 1}_reason`]: "operator-cost-cap-exhausted", operator_cap_spent: spent.operator, + operator_cap_limit: hopCap, + ai_chain_surface: surface, }, }); continue; @@ -520,6 +539,12 @@ export async function runStreamingRawCompletionWithFallback(args: { userId: string; providers: ProviderChainResolved[]; params: CompletionParams; + /** + * v1.38.19 (Wave E, fix round 1) — 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`. */ @@ -566,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/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index 2188d4795..d77c9391f 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -289,6 +289,7 @@ export async function maybeAutoStageLabFacts( { providerType: provider.pick.entry.providerType }, ]), provider.costOwner, + "job", ); if (!reservation.allowed) return { staged: false, reason: "budget" }; diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index 9e70b7863..9b8e63e04 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -309,6 +309,7 @@ export async function streamFencedReply( dateKey, resolveDailyCap([{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), + "coach", ); if (!reservation.allowed) { annotate({ action: { name: "documents.chat.budget.exceeded" } }); @@ -371,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], diff --git a/src/lib/documents/index-document.ts b/src/lib/documents/index-document.ts index 1c222c7c8..76b0a28a3 100644 --- a/src/lib/documents/index-document.ts +++ b/src/lib/documents/index-document.ts @@ -236,6 +236,7 @@ async function tryProviderIndex( 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. diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index fa3568716..c756c3dac 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -151,6 +151,7 @@ describe("runStatusCompletion — ledger accounting", () => { "2026-09-11", resolveDailyCap(chatChain), resolveCostOwner(chatChain), + "coach", ); expect(chat.allowed).toBe(true); }); @@ -176,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 (Wave E, fix round 1) — 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: OPERATOR_COST_CAP * 0.5, + operatorAfter: OPERATOR_COST_CAP, + }), }), ); // The refused reservation was refunded, not left on the row. diff --git a/src/lib/insights/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index fc248e038..16c2a2c42 100644 --- a/src/lib/insights/briefing-provider.ts +++ b/src/lib/insights/briefing-provider.ts @@ -118,18 +118,29 @@ export async function runBriefingCompletion( args.maxTokens + Math.ceil((args.systemPrompt.length + args.userPrompt.length) / 4); + // v1.38.19 (Wave E) — 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, - // v1.38.19 (Wave E) — a background surface: half the day's ceiling. - resolveDailyCapFor("job", 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); } @@ -138,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, diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 109f7145d..6f63fba14 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -219,13 +219,16 @@ export async function runStatusCompletion( const dateKey = buildDateKey(); const estimatedTokens = maxTokens + Math.ceil((systemPrompt.length + userPrompt.length) / 4); + // v1.38.19 (Wave E) — 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, - // v1.38.19 (Wave E) — a background surface: half the day's ceiling. - resolveDailyCapFor("job", chain), + jobCap, resolveCostOwner(chain), + "job", ); if (!reservation.allowed) { // Over the day's ceiling. Reported as `error` — a TRANSIENT miss the caller @@ -233,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 (Wave E, fix round 1) — 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" }; } @@ -245,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, diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index d9d8b7b1e..b991fb4ed 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -202,6 +202,7 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { dateKey, dailyCap, resolveCostOwner(chain), + "job", ); if (!reservation.allowed) { annotate({ action: { name: "coach.nudge.ai.budget_exceeded" } }); diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index bdec599f1..d8e4ad718 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -236,6 +236,7 @@ export async function runContentIndexBackfillForUser( dateKey, dailyCap, resolveCostOwner([{ providerType: pick.entry.providerType }]), + "job", ); if (!reservation.allowed) { reason = "budget-reached"; diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index f4c1307d9..647115ee5 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -194,6 +194,7 @@ export async function runDocumentSummaryJob( // v1.38.19 (Wave E) — 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) { diff --git a/tests/integration/coach-budget-owner.test.ts b/tests/integration/coach-budget-owner.test.ts index 95f81f8a5..08344af12 100644 --- a/tests/integration/coach-budget-owner.test.ts +++ b/tests/integration/coach-budget-owner.test.ts @@ -53,6 +53,7 @@ describe("coach_usage.operator_tokens (real Postgres)", () => { dateKey, OPERATOR_COST_CAP, "operator", + "coach", ); expect(res.allowed).toBe(true); await reconcileSpend(userId, res.reserved, 3_000, dateKey, 0, { @@ -80,6 +81,7 @@ describe("coach_usage.operator_tokens (real Postgres)", () => { dateKey, OPERATOR_COST_CAP, "operator", + "coach", ); await reconcileSpend(userId, res.reserved, 3_000, dateKey, 0, { servedBy: "codex", @@ -114,6 +116,7 @@ describe("coach_usage.operator_tokens (real Postgres)", () => { dateKey, USER_PLAN_CAP, "user", + "coach", ); await reconcileSpend(userId, res.reserved, 4_000, dateKey, 0, { servedBy: "codex", @@ -142,6 +145,7 @@ describe("coach_usage.operator_tokens (real Postgres)", () => { dateKey, USER_PLAN_CAP, "user", + "coach", ); await reconcileSpend(userId, res.reserved, 2_500, dateKey, 0, { servedBy: "admin-openai", @@ -178,6 +182,7 @@ describe("coach_usage.operator_tokens (real Postgres)", () => { dateKey, OPERATOR_COST_CAP, "operator", + "coach", ); expect(res.allowed).toBe(true); expect(res.operatorAfter).toBe(153_000); @@ -205,6 +210,7 @@ describe("coach_usage.operator_tokens (real Postgres)", () => { dateKey, OPERATOR_COST_CAP, "operator", + "coach", ); expect(res.allowed).toBe(false); @@ -213,6 +219,98 @@ describe("coach_usage.operator_tokens (real Postgres)", () => { expect(row?.operatorTokens).toBe(OPERATOR_COST_CAP); }); + // v1.38.19 (Wave E, fix round 1) — 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. Before the wave it was refused because the ledger had no + // owner dimension; 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); + }); + it("refunds both counters when a refused reservation is rolled back", async () => { const { reserveBudget, OPERATOR_COST_CAP } = await import("@/lib/ai/coach/budget"); @@ -235,6 +333,7 @@ describe("coach_usage.operator_tokens (real Postgres)", () => { dateKey, OPERATOR_COST_CAP, "operator", + "coach", ); expect(res.allowed).toBe(false); diff --git a/tests/integration/coach-budget-reservation.test.ts b/tests/integration/coach-budget-reservation.test.ts index 1ff211fc0..1f0bec052 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 (Wave E) — 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, "operator"), - reserveBudget(userId, 600, dateKey, cap, "operator"), + 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, "operator"); + const res = await reserveBudget( + userId, + 600, + dateKey, + 25_000, + "operator", + "coach", + ); expect(res.allowed).toBe(true); const row = await getPrismaClient().coachUsage.findUnique({ @@ -81,7 +132,14 @@ describe("reserveBudget (real Postgres)", () => { const userId = await seedUser(); const dateKey = "2026-06-21"; - const res = await reserveBudget(userId, 600, dateKey, 25_000, "operator"); + 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, @@ -102,7 +160,14 @@ 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, "operator"); + 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, From 7b218406304d6a8dac6a1067145f8c4caca3fb00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 11:11:12 +0200 Subject: [PATCH 07/10] fix(ai): the arrival reaction reserves through the shared ledger gate The arrival-reaction claim carried its own copy of the reservation 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 background ceiling. On the row the wave was written for, 1.2 million tokens served by the user's own plan, the reaction was refused all day at half the old ceiling, reproducing the defect the wave exists to remove on the one surface it forgot to convert. reserveBudget now takes the transaction client, so both writes still roll back together and there is no second copy left to drift. The row also remembers who paid for its reservation. The supersede refund in data-arrival has no chain to ask and could only reverse the total, so the operator share of every superseded reservation stayed on the row forever, walking the operator's ceiling upward against spend that never happened. A supersede is routine, not an edge case: any second measurement of the same kind on the same local date before the worker runs. The resume path re-derived the owner from a chain the health ledger may have reordered since, which leaks the same way or drains another request's balance. A structural guard makes the invariant executable. Every INSERT column list, SET clause and RETURNING list that names one counter must name the other, and the set of files holding raw ledger SQL is pinned. Both defects above were a grep away and both would have failed this test. --- .../migration.sql | 18 ++ prisma/schema.prisma | 6 + .../coach-usage-owner-columns-guard.test.ts | 132 ++++++++++++++ .../reaction-line-degradation.test.ts | 168 +++++++++++++++++- src/lib/jobs/data-arrival.ts | 22 +++ src/lib/jobs/reaction-line.ts | 71 ++++---- .../arrival-reaction-claim.test.ts | 104 +++++++++++ 7 files changed, 481 insertions(+), 40 deletions(-) create mode 100644 prisma/migrations/0341_arrival_reaction_cost_owner/migration.sql create mode 100644 src/__tests__/coach-usage-owner-columns-guard.test.ts 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..74fee8a02 --- /dev/null +++ b/prisma/migrations/0341_arrival_reaction_cost_owner/migration.sql @@ -0,0 +1,18 @@ +-- v1.38.19 (Wave E, fix round 1) — 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 688951392..0a2fe73c0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6761,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 (Wave E) — 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..bda895bfe --- /dev/null +++ b/src/__tests__/coach-usage-owner-columns-guard.test.ts @@ -0,0 +1,132 @@ +/** + * Structural guard — `coach_usage.total_tokens` and `coach_usage.operator_tokens` + * move together in EVERY raw statement. + * + * v1.38.19 (Wave E) 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 the exact defect the wave exists to fix. + * + * 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/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index 8e2ff0ac5..53b3d4ac6 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -88,7 +88,8 @@ vi.mock("@/lib/ai/coach/budget", () => ({ reserveBudget: (...a: unknown[]) => reserveBudget(...a), reconcileSpend: (...a: unknown[]) => reconcileSpend(...a), resolveDailyCap: () => 200_000, - resolveDailyCapFor: () => 200_000, + // A background surface on an operator-funded chain: half the ceiling. + resolveDailyCapFor: () => 100_000, resolveCostOwner: () => "operator" as const, })); @@ -175,7 +176,7 @@ function expectSurfaceStillWorks() { expect(digest.line.length).toBeGreaterThan(0); } -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); findUnique.mockResolvedValue({ id: "r1", @@ -193,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 (Wave E, fix round 1) — 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 }, @@ -504,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); @@ -513,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, on the surface the wave forgot to convert: 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, which is the exact defect the + // wave exists to remove, reproduced at HALF the old ceiling. + 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([ @@ -693,3 +742,104 @@ describe("reaction line — degradation", () => { ); }); }); + +/** + * v1.38.19 (Wave E, fix round 1) — 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/data-arrival.ts b/src/lib/jobs/data-arrival.ts index cb4318ac6..6c427ef33 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 (Wave E) — `"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 (Wave E, fix round 1) — 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/reaction-line.ts b/src/lib/jobs/reaction-line.ts index 2d49c25e2..21dbb9bc4 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -41,6 +41,7 @@ import { isModuleEnabled } from "@/lib/modules/gate"; import { buildDateKey, reconcileSpend, + reserveBudget, resolveCostOwner, resolveDailyCapFor, type BudgetCostOwner, @@ -317,10 +318,11 @@ type ReactionReservation = { reserved: number; dateKey: string; /** - * v1.38.19 (Wave E) — the cost owner the reservation was booked to. A - * RESUMED reservation (the claim survived a crash, its tokens are on the - * row) recomputes it from the same chain the reservation was made under, so - * the reconcile reverses exactly what the reservation added. + * v1.38.19 (Wave E) — 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; }; @@ -361,32 +363,28 @@ async function reserveClaimBudget( }); if (stillOwned.count !== 1) throw new ReactionClaimLostError(); - const reserved = ARRIVAL_REACTION_RESERVE_TOKENS; - // v1.38.19 (Wave E) — this reservation is the claim-linked twin of - // `reserveBudget`, so it books the owner split the same way: an - // operator-funded chain increments `operator_tokens` inside the SAME - // statement as the total, and the refusal path reverses both. - const operatorReserved = owner === "operator" ? reserved : 0; - const rows = await tx.$queryRaw<{ total_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 - `; - 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}), - 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} - `; + // v1.38.19 (Wave E, fix round 1) — 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 the operator's own 2026-09-11 row (1.2 M + // total, ~0 operator) the arrival reaction was refused all day, which is + // the exact defect this wave exists to remove, reproduced on the one + // surface the wave forgot to convert. `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 }, @@ -408,6 +406,11 @@ async function reserveClaimBudget( data: { generationReservedTokens: reserved, generationBudgetDateKey: dateKey, + // v1.38.19 (Wave E, fix round 1) — 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(); @@ -447,6 +450,7 @@ export async function runReactionLine( generationClaimedAt: true, generationReservedTokens: true, generationBudgetDateKey: true, + generationCostOwner: true, generationProviderInvokedAt: true, occurredAt: true, refId: true, @@ -543,7 +547,12 @@ export async function runReactionLine( allowed: true, reserved: row.generationReservedTokens!, dateKey: row.generationBudgetDateKey!, - owner: resolveCostOwner(chain), + // 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 { diff --git a/tests/integration/arrival-reaction-claim.test.ts b/tests/integration/arrival-reaction-claim.test.ts index 0db295536..3a4284b0e 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 (Wave E, fix round 1) — 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"); From c66bcbc2958ecde3028b864f30671860a23fd366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 11:11:17 +0200 Subject: [PATCH 08/10] feat(admin): the AI card shows the day's spend split by who paid The provider-health route answered a spendToday payload that nothing rendered, which is the pattern the two-ended rule exists to stop. It is also the screen the operator opens when a surface refuses on budget, and the one mixed number could not answer the question that raises: a day at 1.24 million tokens is alarming until the card says 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. Copy lands in all seven locales. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- .../provider-health-spend-today.test.tsx | 72 +++++++++++++++++++ .../admin/provider-health-section.tsx | 34 ++++++++- 9 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 src/components/admin/__tests__/provider-health-spend-today.test.tsx diff --git a/messages/de.json b/messages/de.json index bc23d2f08..75230309a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6632,7 +6632,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 dc66aa61b..fd51ac17b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6632,7 +6632,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 e5fddd66f..93979c8b1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6632,7 +6632,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 1640aa7e9..35b152aa8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6632,7 +6632,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 d965b3827..3cab23814 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6632,7 +6632,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 4dabc847b..6f5bb9ec2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7239,7 +7239,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 9769c0838..a562fe7be 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6632,7 +6632,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/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..1f92bdc03 --- /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 (Wave E) — 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..fecc95556 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 (Wave E) — 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")} From b565edc48d95f1a23f62003042aeade02c67eede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 12:37:54 +0200 Subject: [PATCH 09/10] docs(ai): drop planning vocabulary from the ledger comments CLAUDE.md forbids planning shorthand in committed artefacts, and this branch introduced 74 lines of it across source, tests, a schema field and two migrations. Someone reading the migration SQL in a year has no way to resolve a wave letter or a round number, and a comment that points at something outside the repository names no reason at all. Three passes. A version followed by a parenthetical keeps the version and loses the parenthetical, because the version says since when something holds. A sentence that leaned on the vocabulary to carry its meaning is rewritten to state the reason directly: the arrival reservation now says it was refused over money the operator never spent, rather than naming the defect a wave existed to remove. Three test titles carried the marker in the name itself and are renamed to what they pin. Comments only. No behaviour, no renames beyond those three titles. --- .../migration.sql | 2 +- .../migration.sql | 2 +- prisma/schema.prisma | 4 +-- .../coach-usage-owner-columns-guard.test.ts | 5 ++-- .../provider-health/__tests__/route.test.ts | 2 +- src/app/api/admin/provider-health/route.ts | 2 +- src/app/api/insights/chat/route.ts | 4 +-- .../labs/ocr/extract/__tests__/route.test.ts | 4 +-- .../provider-health-spend-today.test.tsx | 2 +- .../admin/provider-health-section.tsx | 2 +- src/lib/ai/__tests__/provider-runner.test.ts | 4 +-- src/lib/ai/coach/__tests__/budget.test.ts | 12 ++++---- src/lib/ai/coach/budget.ts | 30 +++++++++---------- src/lib/ai/provider-runner.ts | 6 ++-- src/lib/documents/auto-stage-labs.ts | 2 +- src/lib/documents/index-document.ts | 4 +-- .../briefing-provider-budget.test.ts | 6 ++-- .../comprehensive-budget-refusal.test.ts | 6 ++-- .../__tests__/status-provider-budget.test.ts | 10 +++---- src/lib/insights/briefing-provider.ts | 2 +- src/lib/insights/status-provider.ts | 4 +-- .../reaction-line-degradation.test.ts | 16 +++++----- src/lib/jobs/coach-nudge-ai.ts | 2 +- src/lib/jobs/data-arrival.ts | 4 +-- .../jobs/document-content-index-backfill.ts | 2 +- src/lib/jobs/document-summary.ts | 2 +- src/lib/jobs/reaction-line.ts | 16 +++++----- .../arrival-reaction-claim.test.ts | 2 +- tests/integration/coach-budget-owner.test.ts | 9 +++--- .../coach-budget-reservation.test.ts | 2 +- 30 files changed, 86 insertions(+), 84 deletions(-) diff --git a/prisma/migrations/0340_coach_usage_operator_tokens/migration.sql b/prisma/migrations/0340_coach_usage_operator_tokens/migration.sql index 8414906ba..2ad77f48f 100644 --- a/prisma/migrations/0340_coach_usage_operator_tokens/migration.sql +++ b/prisma/migrations/0340_coach_usage_operator_tokens/migration.sql @@ -1,4 +1,4 @@ --- v1.38.19 (Wave E) — the daily coach ledger gains a cost-owner dimension. +-- 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 diff --git a/prisma/migrations/0341_arrival_reaction_cost_owner/migration.sql b/prisma/migrations/0341_arrival_reaction_cost_owner/migration.sql index 74fee8a02..7cc71a13b 100644 --- a/prisma/migrations/0341_arrival_reaction_cost_owner/migration.sql +++ b/prisma/migrations/0341_arrival_reaction_cost_owner/migration.sql @@ -1,4 +1,4 @@ --- v1.38.19 (Wave E, fix round 1) — the arrival reaction remembers who paid for +-- 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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0a2fe73c0..09d54ef02 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6271,7 +6271,7 @@ model CoachUsage { userId String @map("user_id") dateKey String @map("date_key") totalTokens Int @default(0) @map("total_tokens") - /// v1.38.19 (Wave E) — the share of `totalTokens` served by a provider the + /// 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 @@ -6761,7 +6761,7 @@ 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 (Wave E) — who the reservation above was booked to, `"operator"` + /// 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. diff --git a/src/__tests__/coach-usage-owner-columns-guard.test.ts b/src/__tests__/coach-usage-owner-columns-guard.test.ts index bda895bfe..7b12a1a95 100644 --- a/src/__tests__/coach-usage-owner-columns-guard.test.ts +++ b/src/__tests__/coach-usage-owner-columns-guard.test.ts @@ -2,7 +2,7 @@ * Structural guard — `coach_usage.total_tokens` and `coach_usage.operator_tokens` * move together in EVERY raw statement. * - * v1.38.19 (Wave E) gave the daily ledger a second counter: `operator_tokens` + * 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 @@ -10,7 +10,8 @@ * 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 the exact defect the wave exists to fix. + * 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, 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 f0f819c3f..408ba63ea 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -207,7 +207,7 @@ describe("GET /api/admin/provider-health", () => { }); /** - * v1.38.19 (Wave E) — the two spend figures side by side. + * 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 diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 0e8b42e13..78e90ca01 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -111,7 +111,7 @@ export const GET = apiHandler(async () => { return a.providerType.localeCompare(b.providerType); }); - // v1.38.19 (Wave E) — the day's spend, split by who pays for it. + // 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 diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index c0880ce9d..c2e6315e4 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -609,7 +609,7 @@ async function handleChatRequest(request: NextRequest): Promise { "coach", ); if (!reservation.allowed) { - // v1.38.19 (Wave E, fix round 1) — say WHICH ceiling refused and WHOSE. + // 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 @@ -654,7 +654,7 @@ async function handleChatRequest(request: NextRequest): Promise { async function produceReply(): Promise { let result: CompletionResult; - // v1.38.19 (Wave E) — typed as the chain's provider union, not a bare + // 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; 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 9471f6ac1..97b655c31 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -120,9 +120,9 @@ 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, - // Wave E — and the cost owner that cap is enforced against as the 5th. + // ...the cost owner that cap is enforced against as the 5th... "operator", - // Wave E fix round 1 — the surface the ceiling is rationed for, 6th. + // ...and the surface the ceiling is rationed for as the 6th. "coach", ); }); diff --git a/src/components/admin/__tests__/provider-health-spend-today.test.tsx b/src/components/admin/__tests__/provider-health-spend-today.test.tsx index 1f92bdc03..bfe056560 100644 --- a/src/components/admin/__tests__/provider-health-spend-today.test.tsx +++ b/src/components/admin/__tests__/provider-health-spend-today.test.tsx @@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; /** - * v1.38.19 (Wave E) — the day's spend, split by who paid for it. + * 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 diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index fecc95556..273c3559b 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -27,7 +27,7 @@ import { formatDateTime } from "@/lib/format"; */ /** - * v1.38.19 (Wave E) — the day's spend, split by who pays for it. The operator + * 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 diff --git a/src/lib/ai/__tests__/provider-runner.test.ts b/src/lib/ai/__tests__/provider-runner.test.ts index 3ff5d781a..0080fc64b 100644 --- a/src/lib/ai/__tests__/provider-runner.test.ts +++ b/src/lib/ai/__tests__/provider-runner.test.ts @@ -774,7 +774,7 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () => expect(adminOpenai.callCount).toBe(1); }); - // v1.38.19 (Wave E) — the guard reads the OPERATOR-funded share. + // 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 @@ -833,7 +833,7 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () => expect(adminOpenai.callCount).toBe(0); }); - // v1.38.19 (Wave E, fix round 1) — the hop guard knows which SURFACE asked. + // 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 diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index 2a7f764b4..e303151d1 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -198,7 +198,7 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => { }); /** - * v1.38.19 (Wave E) — the cap is enforced against the counter it is ABOUT. + * 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 @@ -207,14 +207,14 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => { * is the defect; the operator ceiling may only see operator-funded tokens. */ /** - * v1.38.19 (Wave E) — background generation gets a bounded slice of the day. + * v1.38.19 — background generation gets a bounded slice of the day. * * 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. A * background surface may reserve at most half of the interactive ceiling. */ -describe("resolveDailyCapFor (Wave E — the job share)", () => { +describe("resolveDailyCapFor — the job share", () => { it("gives a background surface half the operator ceiling", () => { expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe( OPERATOR_COST_CAP * JOB_SURFACE_SHARE, @@ -247,7 +247,7 @@ describe("resolveDailyCapFor (Wave E — the job share)", () => { }); }); -describe("resolveTotalCapFor (Wave E, fix round 1 — the abuse ceiling)", () => { +describe("resolveTotalCapFor — the abuse ceiling", () => { it("keeps the user-plan ceiling on the day's mixed total for both owners", () => { // 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 @@ -267,7 +267,7 @@ describe("resolveTotalCapFor (Wave E, fix round 1 — the abuse ceiling)", () => }); }); -describe("reserveBudget cap by cost owner (Wave E)", () => { +describe("reserveBudget — the cap follows the cost owner", () => { let prismaMock: { $queryRaw: ReturnType; $executeRaw: ReturnType; @@ -336,7 +336,7 @@ describe("reserveBudget cap by cost owner (Wave E)", () => { expect(res.allowed).toBe(false); }); - // v1.38.19 (Wave E, fix round 1) — the abuse ceiling survives the owner split. + // 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: diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index e2b77969c..ec01876ea 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -18,7 +18,7 @@ import { prisma } from "@/lib/db"; import type { ProviderChainType } from "@/lib/ai/provider-chain"; /** - * v1.38.19 (Wave E, fix round 1) — the two raw statements the ledger owns can + * 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 @@ -79,7 +79,7 @@ export function resolveDailyCap( } /** - * v1.38.19 (Wave E) — the share of a day's ceiling that BACKGROUND generation + * v1.38.19 — the share of a day's ceiling that BACKGROUND generation * may reserve, all job surfaces together. * * Production evidence (2026-09-11): the operator's account wrote 150–330 @@ -91,7 +91,7 @@ export function resolveDailyCap( export const JOB_SURFACE_SHARE = 0.5; /** - * v1.38.19 (Wave E) — which kind of caller is asking. `"coach"` is every + * 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 @@ -100,7 +100,7 @@ export const JOB_SURFACE_SHARE = 0.5; export type BudgetSurface = "coach" | "job"; /** - * v1.38.19 (Wave E, fix round 1) — the job share rations the OPERATOR's + * v1.38.19 — the job share rations the OPERATOR's * invoice, so it applies only when the operator is paying. * * The first cut multiplied whichever ceiling `resolveDailyCap` returned, which @@ -120,7 +120,7 @@ function applyJobShare( } /** - * v1.38.19 (Wave E) — the daily ceiling for one SURFACE on a chain. `"coach"` + * 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) gets `JOB_SURFACE_SHARE` of it. The cost owner is unchanged — @@ -135,7 +135,7 @@ export function resolveDailyCapFor( } /** - * v1.38.19 (Wave E, fix round 1) — the ABUSE ceiling on the day's mixed total, + * v1.38.19 — the ABUSE ceiling on the day's mixed total, * for one surface and one cost owner. * * `resolveDailyCapFor` answers "how much of the owner's ceiling may this @@ -169,14 +169,14 @@ export function isOperatorFundedProvider(type: ProviderChainType): boolean { } /** - * v1.38.19 (Wave E) — who pays for a turn. `"operator"` means the tokens land + * 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 (Wave E) — the cost owner a chain RESERVES under: the primary is + * 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. @@ -204,7 +204,7 @@ export interface DailySpend { /** Every token recorded for the day, whoever paid for it. */ total: number; /** - * v1.38.19 (Wave E) — the share of `total` served by an operator-funded + * 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. @@ -264,7 +264,7 @@ export interface ReserveBudgetResult { /** The day's total AFTER this reservation (for observability). */ totalAfter: number; /** - * v1.38.19 (Wave E) — the owner this reservation was charged to. The + * 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. @@ -273,7 +273,7 @@ export interface ReserveBudgetResult { /** The day's operator-funded spend AFTER this reservation. */ operatorAfter: number; /** - * v1.38.19 (Wave E, fix round 1) — which ceiling refused this reservation, + * 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 @@ -298,7 +298,7 @@ export async function reserveBudget( Number.isFinite(estimatedTokens) && estimatedTokens > 0 ? Math.floor(estimatedTokens) : 0; - // v1.38.19 (Wave E) — a reservation is booked to `operator_tokens` only when + // 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. @@ -328,14 +328,14 @@ export async function reserveBudget( // PRIOR to its reservation was under the cap. So compare the post-increment // figure minus this reservation against the cap. // - // v1.38.19 (Wave E) — and compare the counter the cap is ABOUT. An + // 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; const priorOperator = operatorAfter - operatorReserved; const prior = owner === "operator" ? priorOperator : priorTotal; - // v1.38.19 (Wave E, fix round 1) — BOTH ceilings, always. The owner's cap + // 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 @@ -412,7 +412,7 @@ 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; - // v1.38.19 (Wave E) — settle the operator's counter against the hop that + // 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 diff --git a/src/lib/ai/provider-runner.ts b/src/lib/ai/provider-runner.ts index dd2dfec4b..433821bd5 100644 --- a/src/lib/ai/provider-runner.ts +++ b/src/lib/ai/provider-runner.ts @@ -438,11 +438,11 @@ async function runRawChain( total: 0, operator: 0, })); - // v1.38.19 (Wave E) — the OPERATOR-funded share, not the day's total: a + // 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 (Wave E, fix round 1) — and at the SURFACE's ceiling, not the + // 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 @@ -540,7 +540,7 @@ export async function runStreamingRawCompletionWithFallback(args: { providers: ProviderChainResolved[]; params: CompletionParams; /** - * v1.38.19 (Wave E, fix round 1) — which ceiling an operator-funded FALLBACK + * 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. */ diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index d77c9391f..23478147f 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -278,7 +278,7 @@ export async function maybeAutoStageLabFacts( // A text-structuring pass, not a vision call — reserve the proportionate // ceiling under the cost owner the auto-index resolved. - // v1.38.19 (Wave E) — auto-staging runs off the upload, with no one waiting + // 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( diff --git a/src/lib/documents/index-document.ts b/src/lib/documents/index-document.ts index 76b0a28a3..e512aee6e 100644 --- a/src/lib/documents/index-document.ts +++ b/src/lib/documents/index-document.ts @@ -131,7 +131,7 @@ export interface ResolvedIndexProvider { consentOk: boolean; dailyCap: number; /** - * v1.38.19 (Wave E) — whose budget `dailyCap` rations. The reservation books + * 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. */ @@ -168,7 +168,7 @@ export async function resolveIndexProvider( pick && consentOk ? resolveDailyCap([{ providerType: pick.entry.providerType }]) : 0; - // v1.38.19 (Wave E) — the cap and its cost owner are resolved from the SAME + // 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 diff --git a/src/lib/insights/__tests__/briefing-provider-budget.test.ts b/src/lib/insights/__tests__/briefing-provider-budget.test.ts index b51df1541..9f578cb4d 100644 --- a/src/lib/insights/__tests__/briefing-provider-budget.test.ts +++ b/src/lib/insights/__tests__/briefing-provider-budget.test.ts @@ -21,7 +21,7 @@ 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` (Wave E). */ +/** …and of `coach_usage.operator_tokens`. */ let ledgerOperator = 0; vi.mock("@/lib/db", () => ({ @@ -30,7 +30,7 @@ vi.mock("@/lib/db", () => ({ // (strings, userId, dateKey, reserved, reserved). $queryRaw: vi.fn( async (_strings: TemplateStringsArray, ...v: unknown[]) => { - // Wave E — (userId, dateKey, reserved, operatorReserved, …). + // (userId, dateKey, reserved, operatorReserved, …). ledgerTotal += Number(v[2] ?? 0); ledgerOperator += Number(v[3] ?? 0); return [{ total_tokens: ledgerTotal, operator_tokens: ledgerOperator }]; @@ -41,7 +41,7 @@ vi.mock("@/lib/db", () => ({ async (strings: TemplateStringsArray, ...v: unknown[]) => { const sql = strings.join("?"); const amount = Number(v[0] ?? 0); - // Wave E — the operator-funded share moves in the same statement. + // The operator-funded share moves in the same statement. const operatorAmount = Number(v[1] ?? 0); if (sql.includes("total_tokens + ")) { ledgerTotal += amount; diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index 8d214b0b6..b000afac4 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; let ledgerTotal = 0; -/** …and of `coach_usage.operator_tokens` (Wave E). */ +/** …and of `coach_usage.operator_tokens`. */ let ledgerOperator = 0; const findUnique = vi.fn(); @@ -31,7 +31,7 @@ vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn( async (_strings: TemplateStringsArray, ...v: unknown[]) => { - // Wave E — (userId, dateKey, reserved, operatorReserved, …). + // (userId, dateKey, reserved, operatorReserved, …). ledgerTotal += Number(v[2] ?? 0); ledgerOperator += Number(v[3] ?? 0); return [{ total_tokens: ledgerTotal, operator_tokens: ledgerOperator }]; @@ -41,7 +41,7 @@ vi.mock("@/lib/db", () => ({ async (strings: TemplateStringsArray, ...v: unknown[]) => { const sql = strings.join("?"); const amount = Number(v[0] ?? 0); - // Wave E — the operator-funded share moves in the same statement. + // The operator-funded share moves in the same statement. const operatorAmount = Number(v[1] ?? 0); if (sql.includes("total_tokens + ")) { ledgerTotal += amount; diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index c756c3dac..c22616a04 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -18,7 +18,7 @@ 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` (Wave E). */ +/** …and of `coach_usage.operator_tokens`. */ let ledgerOperator = 0; vi.mock("@/lib/db", () => ({ @@ -27,7 +27,7 @@ vi.mock("@/lib/db", () => ({ // (strings, userId, dateKey, reserved, reserved). $queryRaw: vi.fn( async (_strings: TemplateStringsArray, ...v: unknown[]) => { - // Wave E — (userId, dateKey, reserved, operatorReserved, …). + // (userId, dateKey, reserved, operatorReserved, …). ledgerTotal += Number(v[2] ?? 0); ledgerOperator += Number(v[3] ?? 0); return [{ total_tokens: ledgerTotal, operator_tokens: ledgerOperator }]; @@ -38,7 +38,7 @@ vi.mock("@/lib/db", () => ({ async (strings: TemplateStringsArray, ...v: unknown[]) => { const sql = strings.join("?"); const amount = Number(v[0] ?? 0); - // Wave E — the operator-funded share moves in the same statement. + // The operator-funded share moves in the same statement. const operatorAmount = Number(v[1] ?? 0); if (sql.includes("total_tokens + ")) { ledgerTotal += amount; @@ -129,7 +129,7 @@ describe("runStatusCompletion — ledger accounting", () => { }); it("refuses a background generation at the job share while the chat still runs", async () => { - // Wave E — the automatic status/reference generators may spend at most + // The automatic status/reference generators may spend at most // half the operator ceiling, so the interactive chat still has a day left // when the jobs have had theirs. One token past the job share: ledgerTotal = 100_001; @@ -177,7 +177,7 @@ 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 (Wave E, fix round 1) — the refusal has to SAY which ceiling it + // 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 diff --git a/src/lib/insights/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index 16c2a2c42..1b8d15079 100644 --- a/src/lib/insights/briefing-provider.ts +++ b/src/lib/insights/briefing-provider.ts @@ -118,7 +118,7 @@ export async function runBriefingCompletion( args.maxTokens + Math.ceil((args.systemPrompt.length + args.userPrompt.length) / 4); - // v1.38.19 (Wave E) — a background surface: half the day's ceiling when the + // 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( diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 6f63fba14..bb187ab5d 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -219,7 +219,7 @@ export async function runStatusCompletion( const dateKey = buildDateKey(); const estimatedTokens = maxTokens + Math.ceil((systemPrompt.length + userPrompt.length) / 4); - // v1.38.19 (Wave E) — a background surface: half the day's ceiling when the + // 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( @@ -236,7 +236,7 @@ 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 (Wave E, fix round 1) — the shared `error` outcome cannot say + // 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 diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index 53b3d4ac6..ac9403751 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -199,7 +199,7 @@ beforeEach(async () => { isModuleEnabled.mockResolvedValue(true); chainRequiresServerManagedConsent.mockReturnValue(false); hasActiveConsentForSurface.mockResolvedValue(true); - // v1.38.19 (Wave E, fix round 1) — the claim reservation is no longer a + // 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 @@ -525,12 +525,12 @@ describe("reaction line — degradation", () => { }); it("a day the user's own plan paid for still admits the reaction", async () => { - // The 2026-09-11 shape, on the surface the wave forgot to convert: 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, which is the exact defect the - // wave exists to remove, reproduced at HALF the old ceiling. + // 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", @@ -744,7 +744,7 @@ describe("reaction line — degradation", () => { }); /** - * v1.38.19 (Wave E, fix round 1) — a reservation is never stranded. + * 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 diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index b991fb4ed..a2916d907 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -191,7 +191,7 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { const budget = AI_BUDGETS.coachNudge; const maxTokens = budget.maxTokens ?? 160; - // v1.38.19 (Wave E) — a background surface: half the day's ceiling. + // v1.38.19 — a background surface: half the day's ceiling. const dailyCap = resolveDailyCapFor("job", chain); const dateKey = buildDateKey(); diff --git a/src/lib/jobs/data-arrival.ts b/src/lib/jobs/data-arrival.ts index 6c427ef33..1fa671331 100644 --- a/src/lib/jobs/data-arrival.ts +++ b/src/lib/jobs/data-arrival.ts @@ -62,7 +62,7 @@ interface LockedReaction { generationReservedTokens: number | null; generationBudgetDateKey: string | null; /** - * v1.38.19 (Wave E) — `"operator"` when the reservation below was booked to + * 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. @@ -125,7 +125,7 @@ async function claimReaction( current.generationBudgetDateKey != null && current.generationProviderInvokedAt == null ) { - // v1.38.19 (Wave E, fix round 1) — reverse BOTH counters, or neither. + // 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 diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index d8e4ad718..53dec0f23 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -200,7 +200,7 @@ export async function runContentIndexBackfillForUser( // leaves every PDF out of "index all documents". const candidateMimes = [...IMAGE_MIMES, "application/pdf"]; - // v1.38.19 (Wave E) — a background surface: half the day's ceiling. + // v1.38.19 — a background surface: half the day's ceiling. const dailyCap = resolveDailyCapFor("job", [ { providerType: pick.entry.providerType }, ]); diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 647115ee5..257586a01 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -191,7 +191,7 @@ export async function runDocumentSummaryJob( userId, AI_BUDGETS.documentSummary.maxTokens, dateKey, - // v1.38.19 (Wave E) — a background surface: half the day's ceiling. + // v1.38.19 — a background surface: half the day's ceiling. resolveDailyCapFor("job", [{ providerType: pick.entry.providerType }]), resolveCostOwner([{ providerType: pick.entry.providerType }]), "job", diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index 21dbb9bc4..35e994009 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -318,7 +318,7 @@ type ReactionReservation = { reserved: number; dateKey: string; /** - * v1.38.19 (Wave E) — the cost owner the reservation was booked to. It is + * 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 @@ -363,15 +363,15 @@ async function reserveClaimBudget( }); if (stillOwned.count !== 1) throw new ReactionClaimLostError(); - // v1.38.19 (Wave E, fix round 1) — ONE reservation implementation. + // 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 the operator's own 2026-09-11 row (1.2 M - // total, ~0 operator) the arrival reaction was refused all day, which is - // the exact defect this wave exists to remove, reproduced on the one - // surface the wave forgot to convert. `reserveBudget` takes 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( @@ -406,7 +406,7 @@ async function reserveClaimBudget( data: { generationReservedTokens: reserved, generationBudgetDateKey: dateKey, - // v1.38.19 (Wave E, fix round 1) — persist the cost owner with the + // 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). @@ -562,7 +562,7 @@ export async function runReactionLine( job.userId, revision, buildDateKey(), - // v1.38.19 (Wave E) — a background surface: half the day's ceiling. + // v1.38.19 — a background surface: half the day's ceiling. resolveDailyCapFor("job", chain), resolveCostOwner(chain), ); diff --git a/tests/integration/arrival-reaction-claim.test.ts b/tests/integration/arrival-reaction-claim.test.ts index 3a4284b0e..97398cb71 100644 --- a/tests/integration/arrival-reaction-claim.test.ts +++ b/tests/integration/arrival-reaction-claim.test.ts @@ -266,7 +266,7 @@ describe("ArrivalReaction — the day claim", () => { expect(usage).toMatchObject({ totalTokens: 280, messageCount: 1 }); }); - // v1.38.19 (Wave E, fix round 1) — the refund reverses BOTH counters. + // 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 diff --git a/tests/integration/coach-budget-owner.test.ts b/tests/integration/coach-budget-owner.test.ts index 08344af12..6e45304ea 100644 --- a/tests/integration/coach-budget-owner.test.ts +++ b/tests/integration/coach-budget-owner.test.ts @@ -1,5 +1,5 @@ /** - * v1.38.19 (Wave E) — the daily coach ledger attributes tokens to the COST + * 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 @@ -219,12 +219,13 @@ describe("coach_usage.operator_tokens (real Postgres)", () => { expect(row?.operatorTokens).toBe(OPERATOR_COST_CAP); }); - // v1.38.19 (Wave E, fix round 1) — the incident, end to end. + // 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. Before the wave it was refused because the ledger had no - // owner dimension; this pins that the refusal cannot come back. + // 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"); diff --git a/tests/integration/coach-budget-reservation.test.ts b/tests/integration/coach-budget-reservation.test.ts index 1f0bec052..2b4f28443 100644 --- a/tests/integration/coach-budget-reservation.test.ts +++ b/tests/integration/coach-budget-reservation.test.ts @@ -38,7 +38,7 @@ 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 (Wave E) — the 900 sits on BOTH counters. The ledger grew a cost + // 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 From bcef5aeeeefbd73b1e2e312a1b1c91a6242e591f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 12:55:52 +0200 Subject: [PATCH 10/10] fix(ai): hold a reserve so the chat is never locked out by jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background ceiling applied only when the operator funded the chain, so on a chain the operator does not pay for the automatic generators and the chat shared one undivided ceiling and the first could still take all of it. That is the configuration the recommended remedy for the original lockout produces — put the personal Codex account first — so the guarantee was missing on exactly the setup it was written for. The ceiling is now expressed as what it is meant to protect. Background work may run until the day's spend would leave less than a fixed reserve for whoever is waiting on an answer, on both cost owners. A reserve and a quota look alike at the boundary and mean opposite things in the middle: a quota stops useful generation on a day nobody was ever going to interrupt, while a reserve costs it nothing until the moment it would take the last of the day. An account generating 1.45 million tokens of insights on its own plan keeps every one of them and the chat still holds 400 000. INTERACTIVE_RESERVE_SHARE replaces JOB_SURFACE_SHARE. The old name described a quota over the jobs rather than a floor under the person waiting, and a name that states the opposite of the intent is what put the wrong comparison in this ledger twice already. The fraction is a default an operator will want to change. The setting and its documentation are separate work. --- src/lib/ai/__tests__/provider-runner.test.ts | 12 +- src/lib/ai/coach/__tests__/budget.test.ts | 162 +++++++++++++++--- src/lib/ai/coach/budget.ts | 73 ++++---- src/lib/ai/provider-runner.ts | 2 +- .../__tests__/status-provider-budget.test.ts | 14 +- tests/integration/coach-budget-owner.test.ts | 92 ++++++++++ 6 files changed, 281 insertions(+), 74 deletions(-) diff --git a/src/lib/ai/__tests__/provider-runner.test.ts b/src/lib/ai/__tests__/provider-runner.test.ts index 0080fc64b..58a077a6d 100644 --- a/src/lib/ai/__tests__/provider-runner.test.ts +++ b/src/lib/ai/__tests__/provider-runner.test.ts @@ -842,9 +842,9 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () => // `[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 at the job share", async () => { - budgetState.spent = 150_000; - budgetState.operatorSpent = 150_000; + 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) }], @@ -874,10 +874,10 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () => }); it("still lets the interactive chat take that same hop", async () => { - // The whole point of the share: at 150 000 operator tokens the background + // 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 = 150_000; - budgetState.operatorSpent = 150_000; + budgetState.spent = 160_000; + budgetState.operatorSpent = 160_000; const codex = new ScriptedProvider({ type: "codex", script: [{ ok: false, error: err(500) }], diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index e303151d1..3757daa61 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -4,7 +4,7 @@ import { buildDateKey, OPERATOR_COST_CAP, USER_PLAN_CAP, - JOB_SURFACE_SHARE, + INTERACTIVE_RESERVE_SHARE, resolveCostOwner, resolveDailyCap, resolveDailyCapFor, @@ -207,20 +207,27 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => { * is the defect; the operator ceiling may only see operator-funded tokens. */ /** - * v1.38.19 — background generation gets a bounded slice of the day. + * 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. A - * background surface may reserve at most half of the interactive ceiling. + * 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 job share", () => { - it("gives a background surface half the operator ceiling", () => { +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 * JOB_SURFACE_SHARE, + OPERATOR_COST_CAP - + Math.floor(OPERATOR_COST_CAP * INTERACTIVE_RESERVE_SHARE), ); expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe( - 100_000, + 160_000, ); }); @@ -233,37 +240,39 @@ describe("resolveDailyCapFor — the job share", () => { ); }); - it("does not ration a chain the operator does not pay for", () => { - // The share exists to protect the OPERATOR's invoice. A self-hoster on a - // local model, or a user on their own ChatGPT plan, costs the operator - // nothing — halving their background generation is a silent downgrade with - // no bill behind it. + 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( - USER_PLAN_CAP, + 1_600_000, ); expect(resolveDailyCapFor("job", [{ providerType: "local" }])).toBe( - USER_PLAN_CAP, + 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 for both owners", () => { + 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", "operator")).toBe(USER_PLAN_CAP); - expect(resolveTotalCapFor("coach", "user")).toBe(USER_PLAN_CAP); + expect(resolveTotalCapFor("coach")).toBe(USER_PLAN_CAP); }); - it("gives a background surface its share of the total ceiling too", () => { - expect(resolveTotalCapFor("job", "operator")).toBe( - USER_PLAN_CAP * JOB_SURFACE_SHARE, - ); - // Nothing to ration when the operator is not paying. - expect(resolveTotalCapFor("job", "user")).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); }); }); @@ -396,6 +405,111 @@ describe("reserveBudget — the cap follows the cost owner", () => { 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([ { diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index ec01876ea..b96f6d7e1 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -79,16 +79,22 @@ export function resolveDailyCap( } /** - * v1.38.19 — the share of a day's ceiling that BACKGROUND generation - * may reserve, all job surfaces together. + * 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. Half the - * ceiling keeps the interactive surfaces a day of their own no matter how much - * background generation the instance schedules. + * 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 JOB_SURFACE_SHARE = 0.5; +export const INTERACTIVE_RESERVE_SHARE = 0.2; /** * v1.38.19 — which kind of caller is asking. `"coach"` is every @@ -100,22 +106,22 @@ export const JOB_SURFACE_SHARE = 0.5; export type BudgetSurface = "coach" | "job"; /** - * v1.38.19 — the job share rations the OPERATOR's - * invoice, so it applies only when the operator is paying. + * 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. * - * The first cut multiplied whichever ceiling `resolveDailyCap` returned, which - * halved background generation for a self-hoster on a local model — zero - * marginal cost to anyone, and an unannounced downgrade for exactly the - * audience this project is built for. The share's whole justification is the - * operator's bill; it does not transfer to a chain the operator does not fund. + * 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 applyJobShare( - surface: BudgetSurface, - chain: ReadonlyArray<{ providerType: ProviderChainType }>, - cap: number, -): number { - return surface === "job" && resolveCostOwner(chain) === "operator" - ? Math.floor(cap * JOB_SURFACE_SHARE) +function withInteractiveReserve(surface: BudgetSurface, cap: number): number { + return surface === "job" + ? cap - Math.floor(cap * INTERACTIVE_RESERVE_SHARE) : cap; } @@ -123,20 +129,19 @@ function applyJobShare( * 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) gets `JOB_SURFACE_SHARE` of it. The cost owner is unchanged — - * this only rations how much of the owner's ceiling a background surface may - * claim. + * 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 applyJobShare(surface, chain, resolveDailyCap(chain)); + return withInteractiveReserve(surface, resolveDailyCap(chain)); } /** - * v1.38.19 — the ABUSE ceiling on the day's mixed total, - * for one surface and one cost owner. + * 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 @@ -145,16 +150,12 @@ export function resolveDailyCapFor( * 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` stays the ceiling on - * the total in BOTH arms, and a background surface gets its share of that too. + * 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, - owner: BudgetCostOwner, -): number { - return surface === "job" && owner === "operator" - ? Math.floor(USER_PLAN_CAP * JOB_SURFACE_SHARE) - : USER_PLAN_CAP; +export function resolveTotalCapFor(surface: BudgetSurface): number { + return withInteractiveReserve(surface, USER_PLAN_CAP); } /** @@ -341,7 +342,7 @@ export async function reserveBudget( // 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, owner); + const totalCap = resolveTotalCapFor(surface); const limit: ReserveBudgetResult["limit"] = prior >= cap ? "owner-cap" : priorTotal >= totalCap ? "total-cap" : null; if (limit !== null) { diff --git a/src/lib/ai/provider-runner.ts b/src/lib/ai/provider-runner.ts index 433821bd5..37270e54f 100644 --- a/src/lib/ai/provider-runner.ts +++ b/src/lib/ai/provider-runner.ts @@ -454,7 +454,7 @@ async function runRawChain( const hopCap = resolveDailyCapFor(surface, [ { providerType: candidate.providerType }, ]); - const hopTotalCap = resolveTotalCapFor(surface, "operator"); + const hopTotalCap = resolveTotalCapFor(surface); if (spent.operator >= hopCap || spent.total >= hopTotalCap) { const hop: FallbackHop = { providerType: candidate.providerType, diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index c22616a04..1ef502f0e 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -128,12 +128,12 @@ describe("runStatusCompletion — ledger accounting", () => { expect(ledgerTotal).toBe(1234); }); - it("refuses a background generation at the job share while the chat still runs", async () => { - // The automatic status/reference generators may spend at most - // half the operator ceiling, so the interactive chat still has a day left - // when the jobs have had theirs. One token past the job share: - ledgerTotal = 100_001; - ledgerOperator = 100_001; + 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()); @@ -189,7 +189,7 @@ describe("runStatusCompletion — ledger accounting", () => { owner: "operator", surface: "job", limit: "owner-cap", - cap: OPERATOR_COST_CAP * 0.5, + cap: 160_000, operatorAfter: OPERATOR_COST_CAP, }), }), diff --git a/tests/integration/coach-budget-owner.test.ts b/tests/integration/coach-budget-owner.test.ts index 6e45304ea..8420d50c5 100644 --- a/tests/integration/coach-budget-owner.test.ts +++ b/tests/integration/coach-budget-owner.test.ts @@ -312,6 +312,98 @@ describe("coach_usage.operator_tokens (real Postgres)", () => { 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");