Merge remote-tracking branch 'origin/release/v1.46.0' into fix/review-meds-docs

# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
#	src/__tests__/sharing-surface-guard.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 02:45:23 +01:00
commit d6c2760415
134 files changed
+3631 -1173

No files matched your search

@@ -16,6 +16,10 @@ vi.mock("@/lib/db", () => ({
upsert: vi.fn(),
},
user: { findMany: vi.fn().mockResolvedValue([]) },
providerHealth: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
insightStatusCache: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
},
}));
@@ -217,6 +221,31 @@ describe("PUT /api/admin/ai-settings", () => {
statusCode: 429,
});
});
// #1279 — background work leaves a provider the health ledger benched out
// of every account's chain. A fixed operator provider must not sit out a
// pause the broken one earned, so a saved change forgets what the ledger
// recorded for it on every account, and lets their notes try again.
it("forgets the operator provider's recorded failures on every account", async () => {
upsertEcho();
await PUT(jsonReq({ model: "gpt-4o-mini" }));
expect(prisma.providerHealth.deleteMany).toHaveBeenCalledWith({
where: { providerType: "admin-openai" },
});
expect(prisma.insightStatusCache.updateMany).toHaveBeenCalledWith({
where: {
OR: [{ retryAt: { not: null } }, { failureStreak: { gt: 0 } }],
},
data: { retryAt: null, negativeReason: null, failureStreak: 0 },
});
});
it("forgets nothing when the change is refused", async () => {
await expect(
PUT(jsonReq({ baseUrl: "https://attacker.example/v1" })),
).rejects.toMatchObject({ statusCode: 422 });
expect(prisma.providerHealth.deleteMany).not.toHaveBeenCalled();
});
});
/**
+12
View File
@@ -8,6 +8,8 @@ import { encrypt, decrypt } from "@/lib/crypto";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { legacyPrivateHostReport } from "@/lib/ai/legacy-private-host-report";
import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger";
import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window";
export const dynamic = "force-dynamic";
@@ -134,6 +136,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
details: auditDetails,
});
// #1279 — background work leaves out a provider the health ledger benched,
// on every account whose chain falls back on this one, and a status note
// waits out its negative window. A fixed operator provider must not sit out
// a pause the broken one earned, so what was recorded against it is
// forgotten on every account, as a person's own save does for theirs.
await Promise.all([
forgetProviderHealth({ providerType: "admin-openai" }),
clearStatusNegativeWindows(),
]);
const hasKey = Boolean(settings.adminAiKeyEncrypted);
return apiSuccess({
@@ -9,6 +9,7 @@ import { auditLog } from "@/lib/auth/audit";
import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger";
import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window";
/**
* Operator-shared central Codex — device-code poll (admin only).
@@ -94,7 +95,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
cookieStore.delete("central_codex_device");
// A new central sign-in: every account's ledger rows describe the refused
// one, and the central connection is one credential shared by all of them.
await forgetProviderHealth({ providerType: "admin-codex" });
// #1279 — and every record's note windows close, as on the admin AI
// settings save, so no note sits out a window the broken one earned.
await Promise.all([
forgetProviderHealth({ providerType: "admin-codex" }),
clearStatusNegativeWindows(),
]);
await auditLog("admin.central-codex.connected", {
userId: user.id,
+7 -2
View File
@@ -10,6 +10,7 @@ import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher";
import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger";
import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window";
/**
* v1.4.7.1: Device-code flow polling endpoint.
@@ -90,8 +91,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
cookieStore.delete("codex_device");
// A new sign-in: whatever the ledger held against the old one no longer
// describes this credential.
await forgetProviderHealth({ userId: user.id, providerType: "codex" });
// describes this credential, and (#1279) the person's notes stop waiting
// out windows it earned, as on a save of their provider settings.
await Promise.all([
forgetProviderHealth({ userId: user.id, providerType: "codex" }),
clearStatusNegativeWindows(user.id),
]);
await auditLog("codex.oauth.connected", {
userId: user.id,
@@ -210,6 +210,7 @@ beforeEach(() => {
},
workingProvider: { providerType: "anthropic", instance: {} },
fallbackHops: [],
tokensSpent: 0,
} as never);
});
@@ -356,6 +357,7 @@ describe("POST — numeric grounding", () => {
},
workingProvider: { providerType: "anthropic", instance: {} },
fallbackHops: [],
tokensSpent: 0,
} as never);
const res = await POST(
req("doc-1", { message: "what is my ldl?" }) as never,
@@ -393,6 +395,7 @@ describe("POST — numeric grounding", () => {
},
workingProvider: { providerType: "anthropic", instance: {} },
fallbackHops: [],
tokensSpent: 0,
} as never);
const res = await POST(
req("doc-1", { message: "how is my ldl?" }) as never,
@@ -325,6 +325,7 @@ async function runRawCompletionWithFallback(args: {
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
tokensSpent: 0,
};
}
@@ -349,6 +349,7 @@ export function streamingResult(content: string) {
model: "m-local",
},
workingProvider: { providerType: "local" },
tokensSpent: 0,
};
};
}
@@ -264,6 +264,7 @@ async function noToolTurn(content: string): Promise<string> {
runStreamingRawCompletionWithFallback.mockResolvedValue({
result: { content, tokensUsed: 42, model: "m" },
workingProvider: { providerType: "anthropic" },
tokensSpent: 0,
});
const res = await post(chatReq({ message: "How is my BP?" }));
await sse.done;
@@ -220,6 +220,7 @@ describe("coach chat — stateless no-tools snapshot grounding", () => {
runStreamingRawCompletionWithFallback.mockResolvedValue({
result: { content: "Your BP looks stable.", tokensUsed: 42, model: "m" },
workingProvider: { providerType: "admin-openai" },
tokensSpent: 0,
});
});
@@ -276,6 +277,7 @@ describe("coach chat — snapshot stays grounded across transcript elision", ()
runStreamingRawCompletionWithFallback.mockResolvedValue({
result: { content: "Your BP looks stable.", tokensUsed: 42, model: "m" },
workingProvider: { providerType: "admin-openai" },
tokensSpent: 0,
});
});
@@ -569,6 +569,7 @@ describe("coach chat — tool-mode routing (F1)", () => {
runStreamingRawCompletionWithFallback.mockResolvedValue({
result: { content: "Your BP is steady.", tokensUsed: 42, model: "m" },
workingProvider: { providerType: "anthropic" },
tokensSpent: 0,
});
await postAndDrain({ message: "How is my BP?" });
@@ -292,6 +292,7 @@ beforeEach(() => {
model: "m",
},
workingProvider: { providerType: "admin-openai" },
tokensSpent: 0,
});
isModuleEnabled.mockResolvedValue(true);
});
@@ -71,6 +71,9 @@ vi.mock("@/lib/db", () => ({
consentReceipt: {
findFirst: vi.fn(async () => ({ id: "receipt-1" })),
},
// The provider health ledger the chain walk reads. Nothing benched by
// default; the bench case below seeds a row.
providerHealth: { findMany: vi.fn(async () => []) },
},
}));
@@ -160,6 +163,8 @@ vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
import { NextRequest } from "next/server";
import { GET, POST, resolveInsightsRateLimit } from "../route";
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
import { resolveProvider } from "@/lib/ai/provider";
@@ -284,6 +289,51 @@ describe("POST /api/insights/generate — daily token ceiling", () => {
});
});
describe("POST /api/insights/generate — a provider the health ledger benched", () => {
// #1279 — "Re-run analysis" is a person waiting on the answer. Background
// work leaves a benched provider out; this call walks it at the tail of
// the chain, so it reaches the provider and reports what the provider
// said, not a connection failure that never happened.
it("still asks the benched provider and reports its own refusal", async () => {
const now = Date.now();
vi.mocked(prisma.providerHealth.findMany).mockResolvedValueOnce([
{
id: "ph-1",
userId: "u-1",
providerType: "admin-openai",
lastResult: "auth_failed",
lastStatus: 401,
consecutiveFailures: 1,
lastOkAt: null,
lastFailureAt: new Date(now),
nextRetryAt: new Date(now + 60 * 60 * 1000),
updatedAt: new Date(now),
},
]);
const err = Object.assign(new Error("OpenAI request failed (401)"), {
httpStatus: 401,
});
makeProviderThatThrows(err);
const provider = await vi.mocked(resolveProvider)("u-1");
const body = JSON.stringify({ force: true });
const request = new NextRequest("http://localhost/api/insights/generate", {
method: "POST",
headers: {
"content-type": "application/json",
"content-length": String(body.length),
},
body,
});
const res = await POST(request);
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
expect(res.status).toBe(422);
const envelope = (await res.json()) as ApiErrorEnvelope;
expect(envelope.error).toMatch(/AI provider rejected/i);
});
});
describe("POST /api/insights/generate — provider error mapping", () => {
it("rejects a body over the 16 KB cap with 413 before parsing", async () => {
const res = await POST(
@@ -192,6 +192,7 @@ describe("POST /api/medications/extract — happy path", () => {
},
workingProvider: { providerType: "openai", instance: {} as never },
fallbackHops: [],
tokensSpent: 0,
});
const res = await POST(
@@ -237,6 +238,7 @@ describe("POST /api/medications/extract — happy path", () => {
},
workingProvider: { providerType: "openai", instance: {} as never },
fallbackHops: [],
tokensSpent: 0,
});
const res = await POST(
@@ -261,6 +263,7 @@ describe("POST /api/medications/extract — daily budget", () => {
},
workingProvider: { providerType: "openai", instance: {} as never },
fallbackHops: [],
tokensSpent: 0,
};
}
@@ -409,6 +412,7 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
},
workingProvider: { providerType: "openai", instance: {} as never },
fallbackHops: [],
tokensSpent: 0,
});
const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
+23 -23
View File
@@ -71,6 +71,7 @@ import {
AllProvidersFailedError,
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { spentOnFailure } from "@/lib/ai/reasoning/answerless";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
@@ -230,13 +231,9 @@ async function handleExtract(request: NextRequest): Promise<Response> {
locale,
});
// Atomically reserve the day's budget BEFORE the provider call. The old
// `enforceBudget` read-then-write left a TOCTOU window (concurrent calls
// could each observe a sub-cap total and all proceed) AND passed no cap, so
// it always measured against `OPERATOR_COST_CAP` — rationing a self-hoster
// paying for their own key by the operator's ceiling. `resolveDailyCap`
// charges the operator ceiling only when the chain's primary is the
// operator's own credential; every other chain gets the user-plan ceiling.
// Atomically reserve the day's budget BEFORE the provider call, against the
// cost owner's ceiling: the operator's only when the chain's primary is the
// operator's own credential, the user-plan ceiling for every other chain.
const dateKey = accountToday;
const estimatedTokens =
MAX_OUTPUT_TOKENS +
@@ -268,11 +265,9 @@ async function handleExtract(request: NextRequest): Promise<Response> {
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
// Low temperature because we want a deterministic field
// extractor, not a creative rephrase.
// A deterministic field extractor, not a creative rephrase.
temperature: 0.1,
// The reply body is a single small JSON object; 600 is well
// over the empirical max we have seen on a 2k-char input.
// One small JSON object; well over the largest seen on 2k chars.
maxTokens: MAX_OUTPUT_TOKENS,
// Parsed with `parseModelJson` (JSON.parse) below — opt the OpenAI /
// Codex chains into their strict JSON-object mode (gated on this flag).
@@ -280,12 +275,17 @@ 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, 0, {
servedBy: null,
reservedOwner: reservation.owner,
}).catch(() => {});
// Nothing was generated — refund the reservation rather than bill an
// invented figure, keeping only what the failed hops reported spending
// (#1279). Best-effort: a failed refund must not mask the 503.
await reconcileSpend(
userId,
reservation.reserved,
spentOnFailure(err),
dateKey,
0,
{ servedBy: null, reservedOwner: reservation.owner },
).catch(() => {});
if (err instanceof AllProvidersFailedError) {
annotate({
action: { name: "medications.extract.provider-failed" },
@@ -296,12 +296,12 @@ async function handleExtract(request: NextRequest): Promise<Response> {
throw err;
}
// Reconcile against what the provider actually reported. This runs for the
// unusable-reply branches too: those tokens were burned upstream even though
// the body could not be used, so they stay on the ledger rather than funding
// 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;
// What the provider reported, plus (#1279) what the hops that failed before
// it spent; for the unusable-reply branches too, since those tokens were
// burned upstream. No count falls back to the reservation, never to zero.
const actualTokens =
(completion.result.tokensUsed ?? reservation.reserved) +
completion.tokensSpent;
await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey, 0, {
servedBy: completion.workingProvider.providerType,
reservedOwner: reservation.owner,
@@ -1,80 +0,0 @@
/**
* GET / PATCH the caller's preview-feature opt-ins.
*
* GET answers every flag, off unless the caller switched it on. PATCH sets
* the flags the body names and keeps the rest; unknown flags are refused
* with the multi-issue 422 so a typo cannot silently do nothing. The blob
* lives on its own `User` column (`preview_features_json`).
*
* The opt-ins are the caller's own, so this is an actor surface
* (`requireActorAuth`): a browser acting for another record keeps reaching
* the person's own switch. The PATCH reads and writes under a row lock, so two
* flags switched at once both stay switched.
*/
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { prisma, toJson } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
PREVIEW_FEATURE_KEYS,
resolvePreviewFeatures,
type PreviewFeatures,
} from "@/lib/settings/preview-features";
import { previewFeaturesPatchSchema } from "@/lib/validations/settings-prefs";
export const dynamic = "force-dynamic";
async function readStored(
userId: string,
db: Pick<typeof prisma, "user"> = prisma,
): Promise<unknown> {
const row = await db.user.findUnique({
where: { id: userId },
select: { previewFeaturesJson: true },
});
return row?.previewFeaturesJson;
}
function enabledFlags(features: PreviewFeatures): string[] {
return PREVIEW_FEATURE_KEYS.filter((key) => features[key]);
}
export const GET = apiHandler(async () => {
const { user } = await requireActorAuth();
const features = resolvePreviewFeatures(await readStored(user.id));
annotate({
action: { name: "settings.preview-features.read" },
meta: { enabled: enabledFlags(features) },
});
return apiSuccess(features);
});
export const PATCH = apiHandler(async (request: Request) => {
const { user } = await requireActorAuth();
const parsed = await parseJsonBody(request, previewFeaturesPatchSchema, {
maxBytes: 1024,
action: "settings.preview-features.validation-failed",
});
if (!parsed.ok) return parsed.error;
const next = await prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT 1 FROM "users" WHERE "id" = ${user.id} FOR UPDATE`;
const current = resolvePreviewFeatures(await readStored(user.id, tx));
const merged: PreviewFeatures = {
topics: parsed.data.topics ?? current.topics,
};
await tx.user.update({
where: { id: user.id },
data: { previewFeaturesJson: toJson(merged) },
});
return merged;
});
annotate({
action: { name: "settings.preview-features.update" },
meta: { enabled: enabledFlags(next) },
});
return apiSuccess(next);
});
@@ -22,6 +22,8 @@ vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
// The mirror walk's token lookup: no token switched, so no walk.
apiToken: { findMany: vi.fn(async () => []) },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },