mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
feat(ai): narrow the auto-read consent and refuse the connection test with AI off
Turning document auto-read on now mints an ai_extraction receipt instead of ai_full, so it covers reading documents, lab reports and medication text and no longer opens the Coach or the AI analysis. Receipts minted as ai_full by earlier releases stay valid. The catch-up over stored documents is only scheduled while documentAi is open for the record. The AI connection test is an AI call, so it is refused with assistant.disabled.enabled while the operator's master switch is off, and fails closed with ai.unavailable when the switches cannot be read (403, since the route never answers 5xx). A missing provider carries ai.provider.none and a spent daily budget ai.budget.exceeded.
This commit is contained in:
1 parent
bd8ea9d86b
commit
d239d31311
5 files changed
+263
-30
No files matched your search
@@ -43,8 +43,19 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
loadAssistantSwitches: vi.fn(async () => ({
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
documentAi: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { resolveProviderForTest } from "@/lib/ai/provider";
|
||||
import { loadAssistantSwitches } from "@/lib/feature-flags";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -367,6 +378,8 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(generateCompletion).not.toHaveBeenCalled();
|
||||
const body = (await response.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("ai.budget.exceeded");
|
||||
});
|
||||
|
||||
it("records a successful probe on the ledger", async () => {
|
||||
@@ -385,3 +398,48 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/ai/test — the operator's master switch and typed refusals", () => {
|
||||
it("refuses with the master's code when AI is off on the server, before any provider resolves", async () => {
|
||||
vi.mocked(loadAssistantSwitches).mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
coach: false,
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
documentAi: false,
|
||||
});
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(403);
|
||||
const body = (await response.json()) as { meta: Record<string, unknown> };
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "assistant.disabled.enabled",
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
expect(resolveProviderForTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed, without a 5xx, when the switches cannot be read", async () => {
|
||||
vi.mocked(loadAssistantSwitches).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
const body = (await response.json()) as { meta: Record<string, unknown> };
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "ai.unavailable",
|
||||
reason: "check_failed",
|
||||
});
|
||||
expect(resolveProviderForTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names a missing provider", async () => {
|
||||
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
||||
type: "none",
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as { meta: Record<string, unknown> };
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "ai.provider.none",
|
||||
reason: "no_provider",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,23 @@ import {
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { aiTestOverrideSchema } from "@/lib/validations/ai-provider";
|
||||
import {
|
||||
AI_PROVIDER_NONE_ERROR_CODE,
|
||||
AI_UNAVAILABLE_ERROR_CODE,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import { loadAssistantSwitches } from "@/lib/feature-flags";
|
||||
|
||||
/**
|
||||
* The master switch's refusal code. The connection test sends a fixed prompt
|
||||
* and no health data, so it belongs to no capability; it answers to the
|
||||
* operator's master switch alone, under the `assistant.disabled.<switch>`
|
||||
* template with the master's own name (`enabled`) as the last segment.
|
||||
*/
|
||||
const MASTER_SWITCH = "enabled";
|
||||
const AI_TEST_MASTER_DISABLED_CODE = `assistant.disabled.${MASTER_SWITCH}`;
|
||||
|
||||
/** The daily AI budget refusal's code. */
|
||||
const AI_TEST_BUDGET_CODE = "ai.budget.exceeded";
|
||||
|
||||
/**
|
||||
* Output ceiling of the probe below. Mirrors the `maxTokens` on the completion
|
||||
@@ -50,6 +67,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "ai.test" } });
|
||||
|
||||
// With the operator's master switch off no AI call leaves this server, and
|
||||
// the connection test is an AI call. A switch set that could not be read
|
||||
// fails closed, like every capability does.
|
||||
// (403, not the usual 503 for `ai.unavailable`: this route never answers
|
||||
// 5xx, because a proxy rewrites a 5xx into an HTML page the settings form
|
||||
// cannot parse. See the catch below.)
|
||||
const switches = await loadAssistantSwitches();
|
||||
if (switches === null) {
|
||||
return apiError("AI is unavailable right now", 403, {
|
||||
errorCode: AI_UNAVAILABLE_ERROR_CODE,
|
||||
reason: "check_failed",
|
||||
});
|
||||
}
|
||||
if (!switches.enabled) {
|
||||
annotate({ action: { name: "ai.test.operator_disabled" } });
|
||||
return apiError("AI is turned off on this server", 403, {
|
||||
errorCode: AI_TEST_MASTER_DISABLED_CODE,
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) return apiError("Too many test requests", 429);
|
||||
|
||||
@@ -96,7 +134,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
if (provider.type === "none") {
|
||||
return apiError("No AI provider configured", 422);
|
||||
return apiError("No AI provider configured", 422, {
|
||||
errorCode: AI_PROVIDER_NONE_ERROR_CODE,
|
||||
reason: "no_provider",
|
||||
});
|
||||
}
|
||||
|
||||
// Meter the probe on the same daily ledger every other AI surface writes to,
|
||||
@@ -123,7 +164,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({ action: { name: "ai.test.budget_exceeded" } });
|
||||
return apiError("Daily AI budget exhausted", 429);
|
||||
return apiError("Daily AI budget exhausted", 429, {
|
||||
errorCode: AI_TEST_BUDGET_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user