mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
commit
0b82d109f0
34 files changed
+1682
-95
No files matched your search
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user