mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(ai): hold a reserve so the chat is never locked out by jobs
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.
This commit is contained in:
1 parent
b565edc48d
commit
bcef5aeeee
6 files changed
+281
-74
No files matched your search
@@ -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) }],
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
+37
-36
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user