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");