Meter the status tier and stop the snapshot re-shipping every late turn

The provider chokepoint every status and reference generator funnels
through ran with no budget accounting at all. Nine generator families
egressed behind it — the specialised status cards, the generic metric
cards, biomarker cards, the batched assessment, the derived scores, the
period narrative, and the off-request Coach memory workers (rolling
summary, fact extraction, plan proposals) — and none of that spend landed
on the daily ledger or met any ceiling. On an operator-key account it was
unmetered operator spend.

Reserve and reconcile around the provider call, using the same atomic
upsert the Coach and document paths use. The ceiling follows the cost
owner: a chain whose primary is the operator's own credential draws the
operator ceiling, everything else draws the user-plan ceiling, so a
self-hoster on their own key is never rationed by the operator's bill.

The status enqueue keyed its de-dupe singleton on (user, metric, locale).
The locale on a status read is client-chosen, so each of the six
supported locales opened its own slot and one legitimate generation pass
could be multiplied into six. Key on (user, metric) and carry the locale
in the payload instead.

The Coach re-shipped the full snapshot on every turn past the history
cap, not just the turn that crosses it, so a long conversation paid the
per-turn snapshot cost the snapshot-once model exists to remove. Re-ground
on the crossing only, tested as a narrow window rather than an equality
because a turn advances the count by one or two depending on whether the
reply persisted.

Give /api/ai/test a daily ceiling and a ledger entry. Its burst bucket
bounded rate but nothing cumulative, and with an empty body it resolves
the same chain generation uses — which can be the operator's credential.

Correct the memory-refresh worker comment, which asserted a budget check
inside the chokepoint that did not exist.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 04:15:28 +02:00
1 parent a6851b7e80
commit 0ddc5097e4
7 files changed
+229 -21

No files matched your search

@@ -24,6 +24,17 @@ vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: () => "2026-01-01",
reserveBudget: vi.fn(async () => ({
allowed: true,
reserved: 32,
totalAfter: 32,
})),
reconcileSpend: vi.fn(async () => undefined),
resolveDailyCap: () => 200_000,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
+70 -1
View File
@@ -10,6 +10,32 @@ import { singleUserTurn } from "@/lib/ai/types";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
/**
* Output ceiling of the probe below. Mirrors the `maxTokens` on the completion
* so the ledger reserves what the call can actually spend.
*/
const AI_TEST_MAX_TOKENS = 32;
/**
* Daily ceiling on connection tests per user.
*
* The 5/min bucket alone bounded burst rate but nothing cumulative: sustained,
* it permits ~7 200 probes/day, and with an empty body this route resolves the
* SAME chain generation uses — which can be the operator's own credential. That
* is unmetered operator spend on a surface that exists to answer "are my
* settings right?". A person checks that a handful of times after editing a
* provider; 50 is far above real use and far below anything that matters on the
* bill.
*/
const AI_TEST_DAILY_LIMIT = 50;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
export const dynamic = "force-dynamic";
@@ -34,6 +60,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
if (!rl.allowed) return apiError("Too many test requests", 429);
// Cumulative ceiling on top of the burst bucket — see AI_TEST_DAILY_LIMIT.
const daily = await checkRateLimit(
`ai-test-daily:${user.id}`,
AI_TEST_DAILY_LIMIT,
ONE_DAY_MS,
);
if (!daily.allowed) {
annotate({ action: { name: "ai.test.daily_limit" } });
return apiError("Too many test requests today", 429);
}
// Body is optional. Empty body → behaves like before (test the saved
// config). Non-empty body → tests the unsaved selection without
// mutating the user row. Plaintext keys never persist.
@@ -65,19 +102,47 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("No AI provider configured", 422);
}
// Meter the probe on the same daily ledger every other AI surface writes to,
// so operator-key spend from this route is visible rather than invisible.
// `admin-key` is the operator's own credential and draws the operator
// ceiling; an override names the caller's own key, and every other resolved
// type is the user's own egress, so those draw the user-plan ceiling.
const dateKey = buildDateKey();
const reservation = await reserveBudget(
user.id,
AI_TEST_MAX_TOKENS,
dateKey,
resolveDailyCap([
{
providerType: provider.type === "admin-key" ? "admin-openai" : "local",
},
]),
);
if (!reservation.allowed) {
annotate({ action: { name: "ai.test.budget_exceeded" } });
return apiError("Daily AI budget exhausted", 429);
}
try {
const result = await provider.generateCompletion(
singleUserTurn({
system: "You are a connection-test responder.",
user: 'Reply with the JSON object {"ok": true} and nothing else.',
temperature: 0,
maxTokens: 32,
maxTokens: AI_TEST_MAX_TOKENS,
// The probe asks for a JSON object — keep the OpenAI / Codex strict
// JSON mode it relied on before the response_format gate landed.
responseFormat: "json",
}),
);
await reconcileSpend(
user.id,
reservation.reserved,
result.tokensUsed ?? reservation.reserved,
dateKey,
);
return apiSuccess({
ok: true,
providerType: result.providerType,
@@ -91,6 +156,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Log full details server-side for the operator and respond with a
// categorised, generic reason.
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
// 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);
annotate({
meta: {
ai_test_error: err.message.slice(0, 500),
+23 -5
View File
@@ -503,15 +503,33 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// figures stay in-context without re-paying for them. We therefore include
// the full block only when:
// - this is the first turn (no prior turns on disk), OR
// - the history window has begun eliding turns (`allTurns.length > TURN_CAP`)
// — once the oldest turns are folded into the rolling summary the original
// snapshot may have scrolled out of the verbatim window, so we re-ground.
// - the history window has JUST begun eliding turns — once the oldest turns
// are folded into the rolling summary the original snapshot may have
// scrolled out of the verbatim window, so we re-ground exactly once at
// that boundary.
// On the cheap path (a follow-up inside the verbatim window) we send a short
// pointer instead of the figures, preserving grounding + cross-metric
// correlation quality at a fraction of the wire cost.
//
// The elision test is a narrow WINDOW, not the open-ended `allTurns.length >
// TURN_CAP` it replaced. That predicate is true for every turn past the cap,
// not just the one that crosses it, so a long conversation re-shipped the
// whole ~15k-token snapshot on turn 21, 22, 23 … — reinstating the exact
// per-turn cost this design removed, and paying it on the LONGEST
// conversations. A 60-turn conversation paid it ~40 times instead of twice.
//
// Width 2 rather than an exact `=== TURN_CAP` equality, because the turn
// count does not advance in fixed steps: the user turn is persisted before
// the provider call, the assistant turn only on success, so a failed turn
// advances the count by 1 and a successful one by 2. An exact-equality check
// would be stepped straight over by a conversation that ever lost a reply,
// and would then NEVER re-ground. A width-2 window cannot be stepped over by
// steps of 1 or 2, so the crossing is always caught, and it can match at most
// twice — bounded either way.
const isFirstTurn = priorTurns.length === 0;
const historyEliding = allTurns.length > TURN_CAP;
const includeFullSnapshot = isFirstTurn || historyEliding;
const historyElisionCrossing =
priorTurns.length >= TURN_CAP && priorTurns.length <= TURN_CAP + 1;
const includeFullSnapshot = isFirstTurn || historyElisionCrossing;
const transcript = window
.map((t) => `${t.role.toUpperCase()}: ${t.content}`)
.join("\n\n");