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) {