mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() },
|
||||
|
||||
Reference in new issue
Block a user