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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 11:11:01 +02:00
1 parent ff51b72cd1
commit 18ed57b867
30 files changed
+645 -25

No files matched your search

+1
View File
@@ -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" } });
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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 = (
+16 -1
View File
@@ -606,11 +606,25 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
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<Response> {
// 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: () => {
@@ -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",
);
});
+2
View File
@@ -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({
@@ -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);
+2
View File
@@ -212,6 +212,7 @@ async function handleExtract(request: NextRequest): Promise<Response> {
dateKey,
resolveDailyCap(chain),
resolveCostOwner(chain),
"coach",
);
if (!reservation.allowed) {
annotate({
@@ -224,6 +225,7 @@ async function handleExtract(request: NextRequest): Promise<Response> {
let completion;
try {
completion = await runRawCompletionWithFallback({
surface: "coach",
userId,
providers: chain,
params: singleUserTurn({