Merge wave-ai-foundation: AI request efficiency, standardization, variety

Centralise per-surface AI budgets and right-size status maxTokens; add
seed support (pinned for reference surfaces, seedless for the briefing);
force JSON on Ollama and Anthropic; reroll only the daily-briefing prose
once a day for variety while findings stay stable; reuse the corrective
retry on the comprehensive path; extract shared prompt contracts as one
source of truth for grounding, tone, and the GLP-1 safety rule; reserve
the coach budget atomically before the call and reconcile burned tokens;
turn the local-AI private-host flag into a host allowlist.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 21:25:04 +02:00
commit 0b82d109f0
34 files changed
+1682 -95

No files matched your search

+54 -12
View File
@@ -46,6 +46,7 @@ import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import type { CompletionResult } from "@/lib/ai/types";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import {
coachChatRequestSchema,
@@ -61,8 +62,8 @@ import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
import {
buildDateKey,
enforceBudget,
recordSpend,
reserveBudget,
reconcileSpend,
} from "@/lib/ai/coach/budget";
import { detectRefusal } from "@/lib/ai/coach/refusal";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
@@ -214,7 +215,10 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
return apiError("Too many Coach requests, please wait a moment", 429);
}
await enforceBudget(userId);
// v1.18.7 (SENIOR-DEV HIGH) — the daily token cap is enforced atomically
// by reserving budget BEFORE the provider call (see the reservation right
// before `runRawCompletionWithFallback`), not by a read-then-write here.
// The old read-before-call gate let concurrent requests all pass the cap.
const locale = await resolveServerLocale({
request,
@@ -430,6 +434,27 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`.
await assertConsentForChain({ userId, chain, surface: "coach" });
// v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before
// the provider call. The reservation increments the day's total by the
// per-call ceiling (`maxTokens`) in one upsert and returns the new total;
// concurrent requests serialise on the row so they cannot all pass the cap.
// Over-cap → 429 refusal frame, reservation already refunded. The actual
// token count is reconciled against this reservation after the call,
// including on empty / sentinel replies (their tokens were still burned).
const reqDateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.coach.maxTokens,
reqDateKey,
);
if (!reservation.allowed) {
annotate({
action: { name: "coach.budget.exceeded" },
meta: { totalAfter: reservation.totalAfter },
});
return streamProviderError({ code: "coach.budget.exceeded" });
}
let result: CompletionResult;
let workingProviderType: string;
try {
@@ -439,13 +464,18 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
params: {
systemPrompt,
userPrompt,
temperature: 0.4,
maxTokens: 600,
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
},
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
} 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(
() => {},
);
if (err instanceof AllProvidersFailedError) {
annotate({
action: { name: "insights.coach.providerFailed" },
@@ -483,6 +513,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
throw err;
}
// v1.18.7 (SENIOR-DEV MEDIUM) — the provider call returned, so its tokens
// were billed regardless of reply quality. Reconcile the reservation
// against the actual count NOW, before any empty/sentinel short-circuit, so
// an empty or sentinel-only reply still records its burned cost (the old
// post-hoc `recordSpend` ran only on the happy path, undercounting these).
await reconcileSpend(
userId,
reservation.reserved,
result.tokensUsed ?? 0,
reqDateKey,
).catch(() => {
// Ledger reconcile is best-effort; a failure leaves the conservative
// reservation in place (never an undercount) and never breaks the turn.
});
const rawReply = (result.content ?? "").trim();
if (!rawReply) {
return streamProviderError({ code: "coach.provider.empty" });
@@ -610,13 +655,10 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
promptVersion: PROMPT_VERSION,
});
// Bump the day's spend ledger AFTER persistence so a retried request
// doesn't double-count when the persistence layer rolled back.
await recordSpend({
userId,
tokens: result.tokensUsed ?? 0,
dateKey: buildDateKey(),
});
// v1.18.7 — the day's spend was already reconciled against the
// reservation immediately after the provider returned (above), so there is
// no post-persistence ledger bump here. The reservation guarantees the
// tokens are counted even if persistence or streaming fails afterwards.
annotate({
action: { name: "insights.coach.replied" },
+7 -4
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
import { encrypt, decrypt } from "@/lib/crypto";
import { resolveProviderAvailability } from "@/lib/ai/provider";
import { annotate } from "@/lib/logging/context";
@@ -92,12 +93,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const trimmed = body.baseUrl.trim();
// SSRF guard: by default reject private/internal hostnames so a
// compromised user account cannot point the server at the cloud
// metadata endpoint or internal admin panels. Ops can enable local
// Ollama / LM Studio on this instance via env flag.
const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
// metadata endpoint or internal admin panels. v1.18.7 (SECURITY LOW) —
// ops opt in via `ALLOW_LOCAL_AI_PRIVATE_HOSTS`, now a host allowlist:
// `true` permits any private host (legacy), a comma-separated host list
// permits only those exact hostnames.
const allowPrivate = isLocalAiHostAllowed(trimmed);
if (!allowPrivate && !isPublicUrl(trimmed)) {
return apiError(
"Base URL points to an internal/private host. Ops must set ALLOW_LOCAL_AI_PRIVATE_HOSTS=true on this instance to allow it (intended for self-hosted Ollama / LM Studio).",
"Base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS — set it to the exact host (e.g. ollama.lan) or to true for any private host (intended for self-hosted Ollama / LM Studio).",
422,
);
}