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 }.
This commit is contained in:
Marc-André Bombeck committed 2026-10-06 19:44:52 +02:00
1 parent 74e68745c1
commit adb4cd5c40
2 files changed
+109 -11

No files matched your search

@@ -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<Response>)(
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<Response>)(
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({}));
@@ -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<AssistantFlagsRow> = {};
const auditDetails: Record<string, unknown> = {};
for (const [key, value] of Object.entries(parsed.data)) {
if (typeof value === "boolean") {
(updates as Record<string, boolean>)[key] = value;
auditDetails[key] = value;
if (value !== undefined) {
(updates as Record<string, boolean | string>)[key] = value;
}
}
const auditDetails: Record<string, unknown> = { ...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));