Merge branch 'worktree-agent-acf4680fc308d77c8' into release/v1.30.25

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 04:24:13 +02:00
commit b1370626fc
9 files changed
+583 -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(),
}));
@@ -278,3 +289,72 @@ describe("POST /api/ai/test — dropdown-aware override", () => {
expect(body.error).toBe("Anthropic API key not configured");
});
});
/**
* Cost ceiling. The 5/min bucket bounded burst rate but nothing cumulative —
* sustained, it permits ~7 200 probes a day, and with an empty body this route
* resolves the SAME provider chain generation uses, which can be the
* operator's own credential. So the surface could reach the operator's key
* indefinitely with no daily ceiling and no ledger entry at all.
*/
describe("POST /api/ai/test — daily ceiling + ledger", () => {
function okProvider() {
vi.mocked(resolveProviderForTest).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: '{"ok":true}',
providerType: "anthropic",
model: "claude-3-5-sonnet-latest",
tokensUsed: 5,
})),
} as never);
}
it("refuses once the per-user daily probe limit is spent", async () => {
const { checkRateLimit } = await import("@/lib/rate-limit");
okProvider();
// Burst bucket fine, cumulative daily bucket exhausted.
vi.mocked(checkRateLimit)
.mockResolvedValueOnce({ allowed: true } as never)
.mockResolvedValueOnce({ allowed: false } as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(429);
// Refused before any provider egress.
expect(resolveProviderForTest).not.toHaveBeenCalled();
});
it("refuses when the day's token budget is already exhausted", async () => {
const { reserveBudget } = await import("@/lib/ai/coach/budget");
const generateCompletion = vi.fn();
vi.mocked(resolveProviderForTest).mockResolvedValue({
type: "admin-key",
generateCompletion,
} as never);
vi.mocked(reserveBudget).mockResolvedValueOnce({
allowed: false,
reserved: 32,
totalAfter: 200_000,
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(429);
expect(generateCompletion).not.toHaveBeenCalled();
});
it("records a successful probe on the ledger", async () => {
const { reconcileSpend } = await import("@/lib/ai/coach/budget");
okProvider();
await POST(emptyRequest() as never);
expect(reconcileSpend).toHaveBeenCalledWith(
"u-1",
32,
expect.any(Number),
"2026-01-01",
);
});
});
+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),
@@ -220,3 +220,92 @@ describe("coach chat — snapshot sent once per conversation (C4)", () => {
expect(prompt).toContain("Your BP looks stable.");
});
});
/**
* The erosion guard.
*
* The re-ground condition used to be the open-ended `allTurns.length >
* TURN_CAP`, which is true for EVERY turn past the history cap — not just the
* one that crosses it. So a conversation that ran long re-shipped the full
* ~15k-token snapshot on turn 21, 22, 23, … permanently, reinstating exactly
* the per-turn cost the snapshot-once design removes, and paying it on the
* longest (most expensive) conversations. The intent was always to re-ground
* ONCE, at the boundary where the original snapshot scrolls out of the
* verbatim window.
*/
describe("coach chat — snapshot re-grounds at the elision boundary only", () => {
const TURN_CAP = 20;
beforeEach(() => {
vi.clearAllMocks();
runStreamingRawCompletionWithFallback.mockResolvedValue({
result: { content: "Your BP looks stable.", tokensUsed: 42, model: "m" },
workingProvider: { providerType: "admin-openai" },
});
});
/** Drive one turn against a conversation that already has `n` turns on disk. */
async function turnWithPriorCount(n: number): Promise<string> {
fetchConversationWithMessages.mockResolvedValue({
id: "c1",
summary: "earlier summary",
messages: Array.from({ length: n }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: `turn ${i}`,
})),
});
await postAndDrain({ conversationId: "c1", message: "next?" });
return lastUserPrompt();
}
it("re-grounds on the turn that crosses the cap", async () => {
expect(await turnWithPriorCount(TURN_CAP)).toContain(SNAPSHOT_JSON);
});
it("does NOT re-ship the snapshot on every turn past the cap", async () => {
// Well past the boundary — these are the turns that used to pay the full
// snapshot cost forever.
for (const prior of [TURN_CAP + 2, TURN_CAP + 6, TURN_CAP + 20, 58]) {
expect(await turnWithPriorCount(prior)).not.toContain(SNAPSHOT_JSON);
}
});
it("ships the full snapshot only twice across a 60-turn conversation", async () => {
let shipped = 0;
const shippedAt: number[] = [];
// Turn 1 starts a fresh conversation (no prior turns on disk); every later
// turn loads the growing history. A successful turn persists both the user
// and the assistant message, so the count advances by 2.
fetchConversationWithMessages.mockResolvedValue(null);
await postAndDrain({ message: "How is my BP?" });
if (lastUserPrompt().includes(SNAPSHOT_JSON)) {
shipped++;
shippedAt.push(0);
}
for (let prior = 2; prior <= 60; prior += 2) {
if ((await turnWithPriorCount(prior)).includes(SNAPSHOT_JSON)) {
shipped++;
shippedAt.push(prior);
}
}
// Turn 1 (grounding) + the single crossing at the cap. Under the old
// predicate this was 1 + every turn from 20 to 60 — 21 full snapshots.
expect(shippedAt).toEqual([0, TURN_CAP]);
expect(shipped).toBe(2);
});
/**
* The turn count does not advance in fixed steps: the user message is
* persisted before the provider call, the assistant message only on success,
* so a turn that lost its reply advances the count by 1 instead of 2. An
* exact `=== TURN_CAP` equality would be stepped straight over by such a
* conversation and would then never re-ground at all. The window must catch
* the crossing from either parity.
*/
it("still re-grounds when a lost reply shifts the turn parity", async () => {
expect(await turnWithPriorCount(TURN_CAP + 1)).toContain(SNAPSHOT_JSON);
});
});
+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");