mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge the coach cap counting only the tokens the operator paid for
Background insights can no longer use up the day before somebody asks a question; a fifth of the daily limit is held back for what a person does by hand.
This commit is contained in:
82 files changed
+2741
-200
No files matched your search
+2
-1
@@ -6683,7 +6683,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",
|
||||
|
||||
+2
-1
@@ -6683,7 +6683,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",
|
||||
|
||||
+2
-1
@@ -6683,7 +6683,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",
|
||||
|
||||
+2
-1
@@ -6683,7 +6683,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",
|
||||
|
||||
+2
-1
@@ -6683,7 +6683,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",
|
||||
|
||||
+2
-1
@@ -7290,7 +7290,8 @@
|
||||
"typeAnthropic": "Anthropic 키 (개인)",
|
||||
"typeCodex": "ChatGPT 로그인 (개인)",
|
||||
"typeLocal": "로컬 모델 (개인)",
|
||||
"typeGateway": "OpenAI 호환 게이트웨이 (개인)"
|
||||
"typeGateway": "OpenAI 호환 게이트웨이 (개인)",
|
||||
"spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
|
||||
+2
-1
@@ -6683,7 +6683,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",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 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
|
||||
-- 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;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 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
|
||||
-- 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;
|
||||
+19
-7
@@ -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 — 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)
|
||||
|
||||
@@ -6755,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 — 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")
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Structural guard — `coach_usage.total_tokens` and `coach_usage.operator_tokens`
|
||||
* move together in EVERY raw statement.
|
||||
*
|
||||
* 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
|
||||
* 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 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,
|
||||
* 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());
|
||||
});
|
||||
});
|
||||
@@ -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 — 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);
|
||||
|
||||
|
||||
@@ -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 — 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -30,9 +30,13 @@ 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,
|
||||
resolveDailyCapFor: () => 200_000,
|
||||
resolveCostOwner: () => "operator" as const,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
@@ -376,6 +380,8 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
|
||||
32,
|
||||
expect.any(Number),
|
||||
"2026-01-01",
|
||||
0,
|
||||
{ servedBy: expect.any(String), reservedOwner: "operator" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
providerType: provider.type === "admin-key" ? "admin-openai" : "local",
|
||||
},
|
||||
]),
|
||||
resolveCostOwner([
|
||||
{
|
||||
providerType: provider.type === "admin-key" ? "admin-openai" : "local",
|
||||
},
|
||||
]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({ action: { name: "ai.test.budget_exceeded" } });
|
||||
@@ -137,6 +144,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 +167,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),
|
||||
|
||||
@@ -51,6 +51,8 @@ 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", () => ({
|
||||
runStreamingRawCompletionWithFallback: vi.fn(),
|
||||
|
||||
@@ -69,6 +69,8 @@ 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", () => ({
|
||||
runInboundExtraction: vi.fn(),
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -254,6 +255,8 @@ async function handleTextExtract(
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -273,6 +276,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 +294,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 +359,8 @@ async function handleStoredExtract(
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -370,6 +380,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 +398,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 +480,8 @@ async function handleVisionExtract(
|
||||
AI_BUDGETS.ocrExtract.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -493,6 +510,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 +528,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",
|
||||
|
||||
@@ -52,6 +52,8 @@ 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", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -249,6 +250,8 @@ async function handleVisionIndex(
|
||||
AI_BUDGETS.documentTranscribe.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -269,6 +272,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 +300,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",
|
||||
|
||||
@@ -40,6 +40,8 @@ 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", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -166,6 +167,8 @@ async function handleTextSuggest(
|
||||
AI_BUDGETS.documentAssist.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -185,10 +188,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 +263,8 @@ async function handleVisionSuggest(
|
||||
AI_BUDGETS.documentAssist.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -275,10 +285,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",
|
||||
|
||||
@@ -53,6 +53,8 @@ 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", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -345,6 +346,8 @@ async function handleTextSummary(
|
||||
budgetFor(mode),
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -369,10 +372,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 +438,8 @@ async function handleVisionSummary(
|
||||
budgetFor(mode),
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -455,10 +465,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 @@ 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),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({
|
||||
@@ -205,7 +211,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 +246,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);
|
||||
|
||||
@@ -86,6 +86,8 @@ 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", () => ({
|
||||
detectRefusal: vi.fn(() => ({ refuse: false })),
|
||||
|
||||
@@ -134,6 +134,8 @@ 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),
|
||||
}));
|
||||
|
||||
const { detectRefusal } = vi.hoisted(() => ({
|
||||
|
||||
@@ -127,6 +127,8 @@ 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),
|
||||
}));
|
||||
|
||||
const { detectRefusal } = vi.hoisted(() => ({
|
||||
|
||||
@@ -109,6 +109,8 @@ 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),
|
||||
}));
|
||||
|
||||
const { detectRefusal } = vi.hoisted(() => ({
|
||||
|
||||
@@ -101,6 +101,8 @@ 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() }));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
|
||||
@@ -100,6 +100,8 @@ 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() }));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
|
||||
@@ -103,6 +103,8 @@ 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", () => ({
|
||||
detectRefusal: vi.fn(() => ({ refuse: false })),
|
||||
|
||||
@@ -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,8 @@ 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),
|
||||
}));
|
||||
|
||||
const { detectRefusal } = vi.hoisted(() => ({
|
||||
@@ -266,7 +269,11 @@ function chatReq(body: Record<string, unknown>): 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 +325,8 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
4500,
|
||||
"2026-06-21",
|
||||
2_000_000,
|
||||
"user",
|
||||
"coach",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -347,6 +356,8 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
3000,
|
||||
0,
|
||||
expect.anything(),
|
||||
0,
|
||||
{ servedBy: null, reservedOwner: "user" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -558,6 +569,8 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
1500,
|
||||
"2026-06-21",
|
||||
2_000_000,
|
||||
"user",
|
||||
"coach",
|
||||
);
|
||||
// The legacy path ships the snapshot figures in the user turn.
|
||||
const params = (
|
||||
|
||||
@@ -177,6 +177,8 @@ 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", () => ({
|
||||
detectRefusal: vi.fn(() => ({ refuse: false })),
|
||||
|
||||
@@ -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,11 +605,26 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
: AI_BUDGETS.coach.maxTokens,
|
||||
reqDateKey,
|
||||
dailyCap,
|
||||
resolveCostOwner(chain),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
// 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
|
||||
// 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" });
|
||||
}
|
||||
@@ -637,7 +654,10 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
|
||||
async function produceReply(): Promise<ReplyOutcome> {
|
||||
let result: CompletionResult;
|
||||
let workingProviderType: string;
|
||||
// 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;
|
||||
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 +722,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
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
|
||||
@@ -740,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: () => {
|
||||
@@ -784,9 +807,10 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
} 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 +911,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -45,6 +45,8 @@ 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 () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/labs/ocr-extract")>(
|
||||
@@ -95,6 +97,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 +120,10 @@ 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,
|
||||
// ...the cost owner that cap is enforced against as the 5th...
|
||||
"operator",
|
||||
// ...and the surface the ceiling is rationed for as the 6th.
|
||||
"coach",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -134,6 +142,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 +192,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 +234,8 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
|
||||
AI_BUDGETS.ocrExtract.maxTokens,
|
||||
0,
|
||||
"2026-06-26",
|
||||
0,
|
||||
{ servedBy: null, reservedOwner: "operator" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -183,6 +184,8 @@ async function handleTextExtract(
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({
|
||||
@@ -208,12 +211,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 +289,8 @@ async function handleVisionExtract(
|
||||
AI_BUDGETS.ocrExtract.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({
|
||||
@@ -303,7 +313,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 +329,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 +347,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 +358,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 +372,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 +413,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 +444,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 +481,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;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ 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),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
@@ -118,6 +120,9 @@ beforeEach(() => {
|
||||
allowed: true,
|
||||
reserved: 700,
|
||||
totalAfter: 700,
|
||||
owner: "user",
|
||||
operatorAfter: 0,
|
||||
limit: null,
|
||||
});
|
||||
vi.mocked(resolveDailyCap).mockReturnValue(1_234_567);
|
||||
vi.mocked(resolveProviderChain).mockResolvedValue([
|
||||
@@ -274,6 +279,8 @@ describe("POST /api/medications/extract — daily budget", () => {
|
||||
expect.any(Number),
|
||||
"2026-05-28",
|
||||
1_234_567,
|
||||
"user",
|
||||
"coach",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -283,6 +290,9 @@ describe("POST /api/medications/extract — daily budget", () => {
|
||||
allowed: false,
|
||||
reserved: 700,
|
||||
totalAfter: 9_999_999,
|
||||
owner: "user",
|
||||
operatorAfter: 0,
|
||||
limit: "owner-cap",
|
||||
});
|
||||
|
||||
const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
|
||||
@@ -307,6 +317,8 @@ describe("POST /api/medications/extract — daily budget", () => {
|
||||
700,
|
||||
120,
|
||||
"2026-05-28",
|
||||
0,
|
||||
{ servedBy: "openai", reservedOwner: "user" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -323,6 +335,8 @@ describe("POST /api/medications/extract — daily budget", () => {
|
||||
700,
|
||||
700,
|
||||
"2026-05-28",
|
||||
0,
|
||||
{ servedBy: "openai", reservedOwner: "user" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -335,7 +349,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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import {
|
||||
@@ -210,6 +211,8 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
estimatedTokens,
|
||||
dateKey,
|
||||
resolveDailyCap(chain),
|
||||
resolveCostOwner(chain),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({
|
||||
@@ -222,6 +225,7 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
let completion;
|
||||
try {
|
||||
completion = await runRawCompletionWithFallback({
|
||||
surface: "coach",
|
||||
userId,
|
||||
providers: chain,
|
||||
params: singleUserTurn({
|
||||
@@ -241,9 +245,10 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
} 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 +265,10 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
// 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) {
|
||||
|
||||
@@ -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 — 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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ProviderHealthSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ProviderHealthSection> — 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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,20 @@ import { formatDateTime } from "@/lib/format";
|
||||
* failure, which never had a status to record.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 (
|
||||
<SettingsCard>
|
||||
@@ -75,6 +96,15 @@ export function ProviderHealthSection() {
|
||||
title={t("admin.providerHealth.title")}
|
||||
description={t("admin.providerHealth.description")}
|
||||
/>
|
||||
{spendToday ? (
|
||||
<p className="text-muted-foreground text-sm" data-slot="ai-spend-today">
|
||||
{t("admin.providerHealth.spendToday", {
|
||||
date: spendToday.dateKey,
|
||||
total: groupDigits(spendToday.totalTokens),
|
||||
operator: groupDigits(spendToday.operatorTokens),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{isPending ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.providerHealth.loading")}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof import("../coach/budget")>("../coach/budget");
|
||||
return {
|
||||
...actual,
|
||||
readDailySpend: vi.fn(async () => budgetState.spent),
|
||||
readDailySpend: vi.fn(async () => ({
|
||||
total: budgetState.spent,
|
||||
operator: budgetState.operatorSpent,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -111,6 +114,7 @@ function err(status: number, msg = "boom"): Error & { httpStatus: number } {
|
||||
beforeEach(() => {
|
||||
clearLastWorkingProviderCache();
|
||||
budgetState.spent = 0;
|
||||
budgetState.operatorSpent = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -169,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 },
|
||||
@@ -182,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 },
|
||||
@@ -212,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 },
|
||||
@@ -228,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 },
|
||||
@@ -244,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" }),
|
||||
});
|
||||
@@ -265,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 },
|
||||
@@ -292,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 },
|
||||
@@ -320,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 },
|
||||
@@ -357,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" }),
|
||||
});
|
||||
@@ -379,6 +392,7 @@ describe("runRawCompletionWithFallback — empty input", () => {
|
||||
try {
|
||||
await runRawCompletionWithFallback({
|
||||
userId: "u-empty",
|
||||
surface: "coach",
|
||||
providers: [],
|
||||
params: singleUserTurn({ system: "s", user: "u" }),
|
||||
});
|
||||
@@ -413,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,
|
||||
@@ -430,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,
|
||||
@@ -449,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,
|
||||
@@ -484,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") },
|
||||
@@ -513,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,
|
||||
@@ -527,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,
|
||||
@@ -550,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 },
|
||||
@@ -578,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 },
|
||||
@@ -633,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),
|
||||
@@ -655,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),
|
||||
@@ -673,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 },
|
||||
@@ -697,6 +722,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) }],
|
||||
@@ -706,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 },
|
||||
@@ -727,6 +754,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) }],
|
||||
@@ -735,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 },
|
||||
@@ -745,11 +774,170 @@ describe("runRawCompletionWithFallback — operator-cost cap at hop time", () =>
|
||||
expect(adminOpenai.callCount).toBe(1);
|
||||
});
|
||||
|
||||
// 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
|
||||
// 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",
|
||||
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("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",
|
||||
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 — 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 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) }],
|
||||
});
|
||||
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 reserve: at 160 000 operator tokens the background
|
||||
// work is done for the day and the person waiting is not.
|
||||
budgetState.spent = 160_000;
|
||||
budgetState.operatorSpent = 160_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 },
|
||||
],
|
||||
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 }] });
|
||||
const outcome = await runRawCompletionWithFallback({
|
||||
userId: "u-own-key",
|
||||
surface: "coach",
|
||||
providers: [{ providerType: "openai", instance: openai }],
|
||||
params,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
buildDateKey,
|
||||
OPERATOR_COST_CAP,
|
||||
USER_PLAN_CAP,
|
||||
INTERACTIVE_RESERVE_SHARE,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
resolveDailyCapFor,
|
||||
resolveTotalCapFor,
|
||||
} from "../budget";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -60,19 +64,40 @@ 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",
|
||||
"coach",
|
||||
);
|
||||
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",
|
||||
"coach",
|
||||
);
|
||||
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",
|
||||
"coach",
|
||||
);
|
||||
expect(res.reserved).toBe(12);
|
||||
});
|
||||
});
|
||||
@@ -143,6 +168,8 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => {
|
||||
1_200,
|
||||
"2026-05-10",
|
||||
resolveDailyCap([{ providerType: "codex" }]),
|
||||
resolveCostOwner([{ providerType: "codex" }]),
|
||||
"coach",
|
||||
);
|
||||
expect(res.allowed).toBe(true);
|
||||
// The reservation upsert ran; no refund executeRaw fired.
|
||||
@@ -152,7 +179,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");
|
||||
@@ -161,6 +188,8 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => {
|
||||
1_200,
|
||||
"2026-05-10",
|
||||
resolveDailyCap([{ providerType: "admin-openai" }]),
|
||||
resolveCostOwner([{ providerType: "admin-openai" }]),
|
||||
"coach",
|
||||
);
|
||||
expect(res.allowed).toBe(false);
|
||||
// Refund of the reservation fired on refusal.
|
||||
@@ -168,6 +197,341 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `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.
|
||||
*/
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 interactive reserve", () => {
|
||||
it("lets a background surface reach everything but the reserve", () => {
|
||||
expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe(
|
||||
OPERATOR_COST_CAP -
|
||||
Math.floor(OPERATOR_COST_CAP * INTERACTIVE_RESERVE_SHARE),
|
||||
);
|
||||
expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe(
|
||||
160_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("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(
|
||||
1_600_000,
|
||||
);
|
||||
expect(resolveDailyCapFor("job", [{ providerType: "local" }])).toBe(
|
||||
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", () => {
|
||||
// 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")).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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reserveBudget — the cap follows the cost owner", () => {
|
||||
let prismaMock: {
|
||||
$queryRaw: ReturnType<typeof vi.fn>;
|
||||
$executeRaw: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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",
|
||||
"coach",
|
||||
);
|
||||
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",
|
||||
"coach",
|
||||
);
|
||||
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",
|
||||
"coach",
|
||||
);
|
||||
expect(res.allowed).toBe(false);
|
||||
});
|
||||
|
||||
// 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:
|
||||
// `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");
|
||||
});
|
||||
|
||||
// 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([
|
||||
{
|
||||
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)", () => {
|
||||
let prismaMock: { $executeRaw: ReturnType<typeof vi.fn> };
|
||||
|
||||
@@ -181,7 +545,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 +557,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 +568,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);
|
||||
});
|
||||
|
||||
@@ -41,9 +41,13 @@ 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),
|
||||
resolveDailyCapFor: vi.fn(() => 200_000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
|
||||
|
||||
@@ -87,6 +91,8 @@ beforeEach(() => {
|
||||
allowed: true,
|
||||
reserved: 300,
|
||||
totalAfter: 300,
|
||||
owner: "operator" as const,
|
||||
operatorAfter: 300,
|
||||
});
|
||||
buildCoachSnapshot.mockResolvedValue({ snapshotJson: '{"weight":[]}' });
|
||||
});
|
||||
@@ -239,6 +245,8 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
|
||||
300,
|
||||
"2026-07-18",
|
||||
200_000,
|
||||
"operator",
|
||||
"coach",
|
||||
);
|
||||
expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
@@ -246,6 +254,7 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
|
||||
120,
|
||||
"2026-07-18",
|
||||
0,
|
||||
{ servedBy: "openai", reservedOwner: "operator" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -254,6 +263,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 +284,8 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
|
||||
300,
|
||||
0,
|
||||
"2026-07-18",
|
||||
0,
|
||||
{ servedBy: null, reservedOwner: "operator" },
|
||||
);
|
||||
});
|
||||
});
|
||||
+246
-24
@@ -17,6 +17,16 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* upsert to do it; that copy drifted twice. One implementation, an executor
|
||||
* argument, no second copy to drift.
|
||||
*/
|
||||
export type BudgetExecutor = Pick<typeof prisma, "$queryRaw" | "$executeRaw">;
|
||||
|
||||
/**
|
||||
* Operator-cost daily ceiling — the cap that protects the OPERATOR's LLM bill.
|
||||
*
|
||||
@@ -63,12 +73,91 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 INTERACTIVE_RESERVE_SHARE = 0.2;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* surfaces a day of their own.
|
||||
*/
|
||||
export type BudgetSurface = "coach" | "job";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 withInteractiveReserve(surface: BudgetSurface, cap: number): number {
|
||||
return surface === "job"
|
||||
? cap - Math.floor(cap * INTERACTIVE_RESERVE_SHARE)
|
||||
: cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) 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 withInteractiveReserve(surface, resolveDailyCap(chain));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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` 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): number {
|
||||
return withInteractiveReserve(surface, USER_PLAN_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
|
||||
@@ -80,6 +169,28 @@ export function isOperatorFundedProvider(type: ProviderChainType): boolean {
|
||||
return type === "admin-openai" || type === "admin-codex";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 — 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 +201,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 — 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<number> {
|
||||
): Promise<DailySpend> {
|
||||
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 +258,115 @@ 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 — 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;
|
||||
/**
|
||||
* 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
|
||||
* 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(
|
||||
userId: string,
|
||||
estimatedTokens: number,
|
||||
dateKey: string = buildDateKey(),
|
||||
cap: number = OPERATOR_COST_CAP,
|
||||
dateKey: string,
|
||||
cap: number,
|
||||
owner: BudgetCostOwner,
|
||||
surface: BudgetSurface,
|
||||
db: BudgetExecutor = prisma,
|
||||
): Promise<ReserveBudgetResult> {
|
||||
const reserved =
|
||||
Number.isFinite(estimatedTokens) && estimatedTokens > 0
|
||||
? Math.floor(estimatedTokens)
|
||||
: 0;
|
||||
// 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.
|
||||
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 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)
|
||||
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.
|
||||
// PRIOR to its reservation was under the cap. So compare the post-increment
|
||||
// figure minus this reservation against the cap.
|
||||
//
|
||||
// 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;
|
||||
if (priorTotal >= cap) {
|
||||
const priorOperator = operatorAfter - operatorReserved;
|
||||
const prior = owner === "operator" ? priorOperator : priorTotal;
|
||||
// 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
|
||||
// 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);
|
||||
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, dateKey);
|
||||
return { allowed: false, reserved, totalAfter: priorTotal };
|
||||
await refundReservation(userId, reserved, operatorReserved, dateKey, db);
|
||||
return {
|
||||
allowed: false,
|
||||
reserved,
|
||||
totalAfter: priorTotal,
|
||||
owner,
|
||||
operatorAfter: priorOperator,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true, reserved, totalAfter };
|
||||
return {
|
||||
allowed: true,
|
||||
reserved,
|
||||
totalAfter,
|
||||
owner,
|
||||
operatorAfter,
|
||||
limit: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,12 +382,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<void> {
|
||||
const grossActual =
|
||||
Number.isFinite(actualTokens) && actualTokens > 0
|
||||
@@ -213,12 +413,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 — 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 +447,14 @@ export async function reconcileSpend(
|
||||
async function refundReservation(
|
||||
userId: string,
|
||||
reserved: number,
|
||||
operatorReserved: number,
|
||||
dateKey: string,
|
||||
db: BudgetExecutor = prisma,
|
||||
): Promise<void> {
|
||||
await prisma.$executeRaw`
|
||||
await db.$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}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import {
|
||||
@@ -235,6 +236,8 @@ export async function deriveClarifyingQuestions(
|
||||
QUESTIONS_MAX_TOKENS,
|
||||
dateKey,
|
||||
resolveDailyCap(chain),
|
||||
resolveCostOwner(chain),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) return fallback();
|
||||
|
||||
@@ -266,9 +269,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 +282,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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CompletionResult>,
|
||||
@@ -406,7 +409,7 @@ async function runRawChain(
|
||||
candidate: ProviderChainResolved,
|
||||
) => Record<string, string | number | boolean | null>,
|
||||
): Promise<RunRawWithFallbackResult> {
|
||||
const { userId, providers } = args;
|
||||
const { userId, providers, surface } = args;
|
||||
const ledger = args.ledger ?? postgresProviderHealthLedger;
|
||||
|
||||
if (providers.length === 0) {
|
||||
@@ -431,8 +434,28 @@ 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,
|
||||
}));
|
||||
// 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 — 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);
|
||||
if (spent.operator >= hopCap || spent.total >= hopTotalCap) {
|
||||
const hop: FallbackHop = {
|
||||
providerType: candidate.providerType,
|
||||
attempt: i + 1,
|
||||
@@ -445,7 +468,9 @@ 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.operator,
|
||||
operator_cap_limit: hopCap,
|
||||
ai_chain_surface: surface,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
@@ -514,6 +539,12 @@ export async function runStreamingRawCompletionWithFallback(args: {
|
||||
userId: string;
|
||||
providers: ProviderChainResolved[];
|
||||
params: CompletionParams;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
surface: BudgetSurface;
|
||||
/** Called once per streamed token/chunk for providers that stream. */
|
||||
onDelta: (delta: string) => void;
|
||||
/** See `RunWithFallbackParams.ledger`. */
|
||||
@@ -560,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<RunRawWithFallbackResult> {
|
||||
|
||||
@@ -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<ReturnType<typeof reserveBudget>>);
|
||||
mockExtract.mockResolvedValue({
|
||||
reportDate: "2026-07-10",
|
||||
|
||||
@@ -29,6 +29,8 @@ 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", () => ({
|
||||
runStreamingRawCompletionWithFallback: vi.fn(),
|
||||
|
||||
@@ -45,6 +45,8 @@ 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", () => ({
|
||||
AI_BUDGETS: { documentTranscribe: { temperature: 0, maxTokens: 4000 } },
|
||||
@@ -106,6 +108,7 @@ beforeEach(() => {
|
||||
vi.mocked(reserveBudget).mockResolvedValue({
|
||||
allowed: true,
|
||||
reserved: 1,
|
||||
owner: "operator",
|
||||
} as never);
|
||||
vi.mocked(localExtractText).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -132,7 +135,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 +232,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);
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ 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", () => ({
|
||||
AI_BUDGETS: { documentTranscribe: { temperature: 0, maxTokens: 4000 } },
|
||||
|
||||
@@ -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,19 @@ 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 — 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,
|
||||
"job",
|
||||
);
|
||||
if (!reservation.allowed) return { staged: false, reason: "budget" };
|
||||
|
||||
@@ -298,9 +305,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" },
|
||||
|
||||
@@ -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,8 @@ export async function streamFencedReply(
|
||||
AI_BUDGETS.documentChat.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"coach",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({ action: { name: "documents.chat.budget.exceeded" } });
|
||||
@@ -369,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],
|
||||
@@ -383,9 +387,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 +430,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();
|
||||
|
||||
@@ -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<ReturnType<typeof resolveDocumentVisionProvider>>["pick"];
|
||||
consentOk: boolean;
|
||||
dailyCap: number;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 — 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,8 @@ async function tryProviderIndex(
|
||||
AI_BUDGETS.documentTranscribe.maxTokens,
|
||||
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.
|
||||
@@ -238,6 +254,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 +279,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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`. */
|
||||
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[]) => {
|
||||
// (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;
|
||||
// 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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
let ledgerTotal = 0;
|
||||
/** …and of `coach_usage.operator_tokens`. */
|
||||
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[]) => {
|
||||
// (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;
|
||||
// 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" });
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`. */
|
||||
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[]) => {
|
||||
// (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;
|
||||
// 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" });
|
||||
});
|
||||
@@ -115,6 +128,34 @@ describe("runStatusCompletion — ledger accounting", () => {
|
||||
expect(ledgerTotal).toBe(1234);
|
||||
});
|
||||
|
||||
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());
|
||||
|
||||
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),
|
||||
"coach",
|
||||
);
|
||||
expect(chat.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("charges the reservation when the provider reports no token count", async () => {
|
||||
mockProviderReply(null);
|
||||
|
||||
@@ -126,6 +167,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());
|
||||
@@ -135,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 — 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: 160_000,
|
||||
operatorAfter: OPERATOR_COST_CAP,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// The refused reservation was refunded, not left on the row.
|
||||
@@ -158,6 +212,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 +227,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 +242,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());
|
||||
|
||||
@@ -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
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
resolveCostOwner,
|
||||
resolveDailyCapFor,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -117,16 +118,29 @@ export async function runBriefingCompletion(
|
||||
args.maxTokens +
|
||||
Math.ceil((args.systemPrompt.length + args.userPrompt.length) / 4);
|
||||
|
||||
// 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(
|
||||
args.userId,
|
||||
estimatedTokens,
|
||||
dateKey,
|
||||
resolveDailyCap(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);
|
||||
}
|
||||
@@ -135,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,
|
||||
@@ -153,12 +170,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 +187,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.
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
resolveCostOwner,
|
||||
resolveDailyCapFor,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
@@ -210,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
|
||||
@@ -218,11 +219,16 @@ export async function runStatusCompletion(
|
||||
const dateKey = buildDateKey();
|
||||
const estimatedTokens =
|
||||
maxTokens + Math.ceil((systemPrompt.length + userPrompt.length) / 4);
|
||||
// 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(
|
||||
userId,
|
||||
estimatedTokens,
|
||||
dateKey,
|
||||
resolveDailyCap(chain),
|
||||
jobCap,
|
||||
resolveCostOwner(chain),
|
||||
"job",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
// Over the day's ceiling. Reported as `error` — a TRANSIENT miss the caller
|
||||
@@ -230,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 — 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" };
|
||||
}
|
||||
@@ -242,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,
|
||||
@@ -264,11 +286,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 +307,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) {
|
||||
|
||||
@@ -76,6 +76,8 @@ 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", () => ({
|
||||
AI_BUDGETS: {
|
||||
|
||||
@@ -35,6 +35,8 @@ 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);
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ 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() }));
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ 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", () => ({
|
||||
AI_BUDGETS: { documentSummary: { temperature: 0.3, maxTokens: 600 } },
|
||||
@@ -124,6 +126,7 @@ beforeEach(() => {
|
||||
vi.mocked(reserveBudget).mockResolvedValue({
|
||||
allowed: true,
|
||||
reserved: 5,
|
||||
owner: "operator",
|
||||
} as never);
|
||||
});
|
||||
|
||||
@@ -151,7 +154,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 +226,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" } }),
|
||||
);
|
||||
|
||||
@@ -88,6 +88,9 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: (...a: unknown[]) => reserveBudget(...a),
|
||||
reconcileSpend: (...a: unknown[]) => reconcileSpend(...a),
|
||||
resolveDailyCap: () => 200_000,
|
||||
// A background surface on an operator-funded chain: half the ceiling.
|
||||
resolveDailyCapFor: () => 100_000,
|
||||
resolveCostOwner: () => "operator" as const,
|
||||
}));
|
||||
|
||||
const loadDailyDigest = vi.fn();
|
||||
@@ -173,7 +176,7 @@ function expectSurfaceStillWorks() {
|
||||
expect(digest.line.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
findUnique.mockResolvedValue({
|
||||
id: "r1",
|
||||
@@ -191,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 — 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 },
|
||||
@@ -467,6 +476,7 @@ describe("reaction line — degradation", () => {
|
||||
1_400,
|
||||
"2026-07-16",
|
||||
0,
|
||||
{ servedBy: expect.any(String), reservedOwner: "operator" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -501,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);
|
||||
|
||||
@@ -510,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: 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",
|
||||
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([
|
||||
@@ -528,6 +580,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 +677,7 @@ describe("reaction line — degradation", () => {
|
||||
900,
|
||||
"2026-07-16",
|
||||
0,
|
||||
{ servedBy: expect.any(String), reservedOwner: "operator" },
|
||||
);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expectSurfaceStillWorks();
|
||||
@@ -683,6 +738,108 @@ describe("reaction line — degradation", () => {
|
||||
1_100,
|
||||
"2026-07-16",
|
||||
100,
|
||||
{ servedBy: expect.any(String), reservedOwner: "operator" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,8 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
resolveCostOwner,
|
||||
resolveDailyCapFor,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
@@ -190,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 — 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).
|
||||
@@ -199,6 +201,8 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
|
||||
maxTokens,
|
||||
dateKey,
|
||||
dailyCap,
|
||||
resolveCostOwner(chain),
|
||||
"job",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({ action: { name: "coach.nudge.ai.budget_exceeded" } });
|
||||
@@ -224,12 +228,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 +242,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);
|
||||
|
||||
@@ -61,6 +61,13 @@ interface LockedReaction {
|
||||
occurredAt: Date;
|
||||
generationReservedTokens: number | null;
|
||||
generationBudgetDateKey: string | null;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 — 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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,7 +38,8 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
resolveCostOwner,
|
||||
resolveDailyCapFor,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -199,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 — 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;
|
||||
@@ -231,6 +235,8 @@ export async function runContentIndexBackfillForUser(
|
||||
AI_BUDGETS.documentTranscribe.maxTokens,
|
||||
dateKey,
|
||||
dailyCap,
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
"job",
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
reason = "budget-reached";
|
||||
@@ -239,7 +245,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 +258,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 +278,11 @@ export async function runContentIndexBackfillForUser(
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
0,
|
||||
{
|
||||
servedBy: pick.entry.providerType,
|
||||
reservedOwner: reservation.owner,
|
||||
},
|
||||
);
|
||||
await upsertContentIndex({
|
||||
userId,
|
||||
@@ -278,7 +295,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
resolveCostOwner,
|
||||
resolveDailyCapFor,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import {
|
||||
loadOwnedDocument,
|
||||
@@ -190,7 +191,10 @@ export async function runDocumentSummaryJob(
|
||||
userId,
|
||||
AI_BUDGETS.documentSummary.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
// v1.38.19 — 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) {
|
||||
@@ -224,6 +228,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 +279,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" },
|
||||
|
||||
@@ -41,7 +41,10 @@ import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
resolveDailyCap,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCapFor,
|
||||
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 +317,14 @@ type ReactionReservation = {
|
||||
allowed: boolean;
|
||||
reserved: number;
|
||||
dateKey: string;
|
||||
/**
|
||||
* 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
|
||||
* reservation never touched — or leave one it did.
|
||||
*/
|
||||
owner: BudgetCostOwner;
|
||||
};
|
||||
|
||||
class ReactionClaimLostError extends Error {
|
||||
@@ -334,6 +345,7 @@ async function reserveClaimBudget(
|
||||
revision: Date,
|
||||
dateKey: string,
|
||||
cap: number,
|
||||
owner: BudgetCostOwner,
|
||||
): Promise<ReactionReservation> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const stillOwned = await tx.arrivalReaction.updateMany({
|
||||
@@ -351,30 +363,33 @@ async function reserveClaimBudget(
|
||||
});
|
||||
if (stillOwned.count !== 1) throw new ReactionClaimLostError();
|
||||
|
||||
const reserved = ARRIVAL_REACTION_RESERVE_TOKENS;
|
||||
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())
|
||||
ON CONFLICT (user_id, date_key) DO UPDATE SET
|
||||
total_tokens = coach_usage.total_tokens + ${reserved},
|
||||
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}),
|
||||
message_count = GREATEST(0, message_count - 1),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = ${userId} AND date_key = ${dateKey}
|
||||
`;
|
||||
// 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 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(
|
||||
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 },
|
||||
});
|
||||
return { allowed: false, reserved, dateKey };
|
||||
return { allowed: false, reserved, dateKey, owner };
|
||||
}
|
||||
|
||||
const linked = await tx.arrivalReaction.updateMany({
|
||||
@@ -391,11 +406,16 @@ async function reserveClaimBudget(
|
||||
data: {
|
||||
generationReservedTokens: reserved,
|
||||
generationBudgetDateKey: dateKey,
|
||||
// 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).
|
||||
generationCostOwner: owner,
|
||||
},
|
||||
});
|
||||
if (linked.count !== 1) throw new ReactionClaimLostError();
|
||||
|
||||
return { allowed: true, reserved, dateKey };
|
||||
return { allowed: true, reserved, dateKey, owner };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -430,6 +450,7 @@ export async function runReactionLine(
|
||||
generationClaimedAt: true,
|
||||
generationReservedTokens: true,
|
||||
generationBudgetDateKey: true,
|
||||
generationCostOwner: true,
|
||||
generationProviderInvokedAt: true,
|
||||
occurredAt: true,
|
||||
refId: true,
|
||||
@@ -526,6 +547,12 @@ export async function runReactionLine(
|
||||
allowed: true,
|
||||
reserved: row.generationReservedTokens!,
|
||||
dateKey: row.generationBudgetDateKey!,
|
||||
// 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 {
|
||||
@@ -535,7 +562,9 @@ export async function runReactionLine(
|
||||
job.userId,
|
||||
revision,
|
||||
buildDateKey(),
|
||||
resolveDailyCap(chain),
|
||||
// v1.38.19 — a background surface: half the day's ceiling.
|
||||
resolveDailyCapFor("job", chain),
|
||||
resolveCostOwner(chain),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ReactionClaimLostError) {
|
||||
@@ -625,6 +654,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 +669,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
|
||||
|
||||
@@ -266,6 +266,110 @@ describe("ArrivalReaction — the day claim", () => {
|
||||
expect(usage).toMatchObject({ totalTokens: 280, messageCount: 1 });
|
||||
});
|
||||
|
||||
// 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
|
||||
// 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");
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 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
|
||||
* `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<string> {
|
||||
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",
|
||||
"coach",
|
||||
);
|
||||
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",
|
||||
"coach",
|
||||
);
|
||||
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",
|
||||
"coach",
|
||||
);
|
||||
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",
|
||||
"coach",
|
||||
);
|
||||
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("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",
|
||||
"coach",
|
||||
);
|
||||
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",
|
||||
"coach",
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
// 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. 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");
|
||||
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);
|
||||
});
|
||||
|
||||
// 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");
|
||||
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",
|
||||
"coach",
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 — 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),
|
||||
reserveBudget(userId, 600, dateKey, cap),
|
||||
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);
|
||||
const res = await reserveBudget(
|
||||
userId,
|
||||
600,
|
||||
dateKey,
|
||||
25_000,
|
||||
"operator",
|
||||
"coach",
|
||||
);
|
||||
expect(res.allowed).toBe(true);
|
||||
|
||||
const row = await getPrismaClient().coachUsage.findUnique({
|
||||
@@ -81,8 +132,18 @@ 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",
|
||||
"coach",
|
||||
);
|
||||
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 +160,18 @@ 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",
|
||||
"coach",
|
||||
);
|
||||
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 } },
|
||||
|
||||
Reference in new issue
Block a user