From adb4cd5c40e3d24325e0ec5f9381e13beab2be39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 6 Oct 2026 19:44:52 +0200 Subject: [PATCH] feat(admin): carry the reasoning switch and cap on the assistant flags PUT /api/admin/settings/assistant-flags accepts aiReasoningEnabled and aiReasoningMaxEffort (low, medium, high), writes them field by field with an audit row like the other switches, and both methods echo them as reasoning: { enabled, maxEffort }. --- .../assistant-flags/__tests__/route.test.ts | 70 +++++++++++++++++++ .../admin/settings/assistant-flags/route.ts | 50 ++++++++++--- 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts index 41ad2bf8c..89f60dd8b 100644 --- a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts +++ b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts @@ -46,6 +46,8 @@ const ALL_ON = { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantDocumentAiEnabled: true, + aiReasoningEnabled: true, + aiReasoningMaxEffort: "high", }; beforeEach(() => { @@ -85,6 +87,34 @@ describe("GET /api/admin/settings/assistant-flags", () => { expect(body.data.raw.assistantEnabled).toBe(true); expect(body.data.resolved.coach).toBe(true); }); + + it("publishes the reasoning controls, defaulted on and uncapped", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/assistant-flags"), + ); + const body = (await res.json()) as { + data: { reasoning: { enabled: boolean; maxEffort: string } }; + }; + expect(body.data.reasoning).toEqual({ enabled: true, maxEffort: "high" }); + }); + + it("reads a stored cap and an off switch back", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + ...ALL_ON, + aiReasoningEnabled: false, + aiReasoningMaxEffort: "low", + } as never); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/assistant-flags"), + ); + const body = (await res.json()) as { + data: { reasoning: { enabled: boolean; maxEffort: string } }; + }; + expect(body.data.reasoning).toEqual({ enabled: false, maxEffort: "low" }); + }); }); describe("PUT /api/admin/settings/assistant-flags", () => { @@ -141,6 +171,46 @@ describe("PUT /api/admin/settings/assistant-flags", () => { expect(body.data.resolved.documentAi).toBe(false); }); + it("switches reasoning off and caps it, with an audit row", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + ...ALL_ON, + aiReasoningEnabled: false, + aiReasoningMaxEffort: "medium", + } as never); + + const res = await PUT( + putReq({ aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" }), + ); + expect(res.status).toBe(200); + const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0]; + expect(upsert.update).toEqual({ + aiReasoningEnabled: false, + aiReasoningMaxEffort: "medium", + }); + const { auditLog } = await import("@/lib/auth/audit"); + expect(vi.mocked(auditLog).mock.calls[0][1]).toMatchObject({ + details: { aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" }, + }); + const body = (await res.json()) as { + data: { reasoning: { enabled: boolean; maxEffort: string } }; + }; + expect(body.data.reasoning).toEqual({ + enabled: false, + maxEffort: "medium", + }); + }); + + it.each(["off", "xhigh", "", 3])( + "refuses %s as the highest level", + async (value) => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PUT(putReq({ aiReasoningMaxEffort: value })); + expect(res.status).toBe(422); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }, + ); + it("rejects an empty body", async () => { vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); const res = await PUT(putReq({})); diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts index af5c45ec5..8190a70ba 100644 --- a/src/app/api/admin/settings/assistant-flags/route.ts +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -28,6 +28,14 @@ * so the admin UI can render the master vs sub-flag distinction * visually. * + * v1.41 — the same route carries the operator's two reasoning controls: + * `aiReasoningEnabled` (off wins over every person's Coach setting and every + * background job) and `aiReasoningMaxEffort` (`low` | `medium` | `high`, the + * highest level any call may take). Echoed as `reasoning: { enabled, + * maxEffort }`. They sit beside the assistant switches rather than in them: + * switching reasoning off stops no AI capability, it only stops the model + * thinking longer before it answers. + * * `requireAdmin()` gates the route — non-admins get 403. */ import type { NextRequest } from "next/server"; @@ -45,6 +53,8 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { resolveAssistantFlags } from "@/lib/feature-flags"; import { annotate } from "@/lib/logging/context"; +import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels"; +import { parseReasoningMaxEffort } from "@/lib/ai/reasoning/resolve"; const assistantFlagsSchema = z .object({ @@ -53,6 +63,8 @@ const assistantFlagsSchema = z assistantBriefingEnabled: z.boolean().optional(), assistantInsightStatusEnabled: z.boolean().optional(), assistantDocumentAiEnabled: z.boolean().optional(), + aiReasoningEnabled: z.boolean().optional(), + aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(), }) .strict(); @@ -62,8 +74,20 @@ type AssistantFlagsRow = { assistantBriefingEnabled: boolean; assistantInsightStatusEnabled: boolean; assistantDocumentAiEnabled: boolean; + aiReasoningEnabled: boolean; + aiReasoningMaxEffort: string; }; +const ROW_SELECT = { + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantDocumentAiEnabled: true, + aiReasoningEnabled: true, + aiReasoningMaxEffort: true, +} as const; + function buildResponseShape(row: AssistantFlagsRow) { const resolved = resolveAssistantFlags({ enabled: row.assistantEnabled, @@ -81,6 +105,10 @@ function buildResponseShape(row: AssistantFlagsRow) { assistantDocumentAiEnabled: row.assistantDocumentAiEnabled, }, resolved, + reasoning: { + enabled: row.aiReasoningEnabled, + maxEffort: parseReasoningMaxEffort(row.aiReasoningMaxEffort), + }, }; } @@ -92,13 +120,7 @@ export const GET = apiHandler(async () => { const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" }, - select: { - assistantEnabled: true, - assistantCoachEnabled: true, - assistantBriefingEnabled: true, - assistantInsightStatusEnabled: true, - assistantDocumentAiEnabled: true, - }, + select: ROW_SELECT, }); const row: AssistantFlagsRow = { @@ -108,6 +130,8 @@ export const GET = apiHandler(async () => { assistantInsightStatusEnabled: settings?.assistantInsightStatusEnabled ?? true, assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true, + aiReasoningEnabled: settings?.aiReasoningEnabled ?? true, + aiReasoningMaxEffort: settings?.aiReasoningMaxEffort ?? "high", }; return apiSuccess(buildResponseShape(row)); @@ -128,14 +152,15 @@ export const PUT = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } + // Field by field from the parsed body; the schema is strict, so every key + // here is one the route owns. const updates: Partial = {}; - const auditDetails: Record = {}; for (const [key, value] of Object.entries(parsed.data)) { - if (typeof value === "boolean") { - (updates as Record)[key] = value; - auditDetails[key] = value; + if (value !== undefined) { + (updates as Record)[key] = value; } } + const auditDetails: Record = { ...updates }; if (Object.keys(updates).length === 0) { return apiError("No valid fields", 422); @@ -145,6 +170,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { where: { id: "singleton" }, update: updates, create: { id: "singleton", ...updates }, + select: ROW_SELECT, }); await auditLog("admin.settings.assistant-flags.update", { @@ -159,6 +185,8 @@ export const PUT = apiHandler(async (request: NextRequest) => { assistantBriefingEnabled: settings.assistantBriefingEnabled, assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled, assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled, + aiReasoningEnabled: settings.aiReasoningEnabled, + aiReasoningMaxEffort: settings.aiReasoningMaxEffort, }; return apiSuccess(buildResponseShape(row));