diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts index fa9f4259e..75fb1b373 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -25,6 +25,8 @@ vi.mock("@/lib/db", () => ({ }, // v1.42 — the operator key's model check reads the admin settings. appSettings: { findUnique: vi.fn(async () => null) }, + // v1.43 — accounts per effective AI usage level. + user: { groupBy: vi.fn(async () => []) }, }, })); @@ -378,3 +380,43 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", () expect(probeModelListing).not.toHaveBeenCalled(); }); }); + +// #1217 — the operator sees how many accounts run at each level, resolved +// against the current bounds, so lowering the ceiling moves the counts. +describe("usage levels", () => { + const userGroups = vi.mocked(prisma.user.groupBy); + const settingsRow = vi.mocked(prisma.appSettings.findUnique); + + beforeEach(() => { + groupBy.mockResolvedValue([] as never); + usageRows.mockResolvedValue([] as never); + }); + + it("counts each account at its effective level", async () => { + settingsRow.mockResolvedValue({ + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, + } as never); + userGroups.mockResolvedValue([ + { aiUsageLevel: null, _count: { _all: 5 } }, + { aiUsageLevel: "FRUGAL", _count: { _all: 2 } }, + // Above the ceiling: capped to standard. + { aiUsageLevel: "FULL", _count: { _all: 3 } }, + ] as never); + const res = await GET(); + const body = await res.json(); + expect(body.data.usageLevels).toEqual({ frugal: 2, standard: 8, full: 0 }); + }); + + it("reports zero everywhere on an empty instance", async () => { + settingsRow.mockResolvedValue(null); + userGroups.mockResolvedValue([] as never); + const res = await GET(); + expect((await res.json()).data.usageLevels).toEqual({ + frugal: 0, + standard: 0, + full: 0, + }); + }); +}); diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 8d35cada5..38a761efe 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -12,6 +12,13 @@ import { import { decrypt } from "@/lib/crypto"; import { isAnthropicBaseUrl } from "@/lib/ai/provider"; import { isCanonicalOpenAIEndpoint } from "@/lib/ai/openai-capabilities"; +import { loadAiUsageSettings } from "@/lib/ai/capabilities/usage"; +import { + AI_USAGE_LEVELS, + resolveAiUsageLevel, + usageLevelFromDb, + type AiUsageLevel, +} from "@/lib/ai/usage-level"; export const dynamic = "force-dynamic"; @@ -186,9 +193,37 @@ export const GET = apiHandler(async () => { operatorTokens: spend.operator, accounts: spend.accounts, }, + usageLevels: await countAccountsByUsageLevel(), }); }); +/** + * v1.43 (#1217) — accounts per EFFECTIVE AI usage level: each stored choice + * (or none) resolved against the operator's current bounds, so the readout + * moves the moment the operator lowers the ceiling or the default. One + * grouped read over the users' stored choices. + */ +async function countAccountsByUsageLevel(): Promise< + Record +> { + const [settings, groups] = await Promise.all([ + loadAiUsageSettings(), + prisma.user.groupBy({ by: ["aiUsageLevel"], _count: { _all: true } }), + ]); + const counts = Object.fromEntries( + AI_USAGE_LEVELS.map((level) => [level, 0]), + ) as Record; + for (const group of groups) { + const { level } = resolveAiUsageLevel({ + preference: usageLevelFromDb(group.aiUsageLevel), + defaultLevel: settings.defaultLevel, + maxLevel: settings.maxLevel, + }); + counts[level] += group._count._all; + } + return counts; +} + /** * The model check for the operator's key. Never fails the readout: a missing * settings row, a key that does not decrypt, or a probe error is simply no 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 89f60dd8b..350c54de8 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 @@ -48,6 +48,9 @@ const ALL_ON = { assistantDocumentAiEnabled: true, aiReasoningEnabled: true, aiReasoningMaxEffort: "high", + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, }; beforeEach(() => { @@ -211,6 +214,75 @@ describe("PUT /api/admin/settings/assistant-flags", () => { }, ); + // #1217 — the operator's usage bounds. + it("stores the usage bounds and echoes them with the gap in effect", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + ...ALL_ON, + aiUsageMaxLevel: "FULL", + aiUsageDefaultLevel: "FRUGAL", + aiSyncRegenGapHours: 12, + } as never); + const res = await PUT( + putReq({ + aiUsage: { + maxLevel: "full", + defaultLevel: "frugal", + syncRegenGapHours: 12, + }, + }), + ); + expect(res.status).toBe(200); + const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0]; + expect(upsert.update).toEqual({ + aiUsageMaxLevel: "FULL", + aiUsageDefaultLevel: "FRUGAL", + aiSyncRegenGapHours: 12, + }); + const body = (await res.json()) as { data: { aiUsage: unknown } }; + expect(body.data.aiUsage).toEqual({ + maxLevel: "full", + defaultLevel: "frugal", + syncRegenGapHours: 12, + effectiveSyncRegenGapHours: 12, + }); + }); + + it("refuses a default above the ceiling", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PUT( + putReq({ + aiUsage: { + maxLevel: "standard", + defaultLevel: "full", + syncRegenGapHours: null, + }, + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta: { errorCode: string } }; + expect(body.meta.errorCode).toBe("ai.usage.defaultAboveCeiling"); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); + + it.each([0, 49, 2.5])( + "refuses %s as the sync gap", + async (syncRegenGapHours) => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PUT( + putReq({ + aiUsage: { + maxLevel: "standard", + defaultLevel: "standard", + syncRegenGapHours, + }, + }), + ); + 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 8190a70ba..a372b5e88 100644 --- a/src/app/api/admin/settings/assistant-flags/route.ts +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -36,6 +36,16 @@ * switching reasoning off stops no AI capability, it only stops the model * thinking longer before it answers. * + * v1.43 (#1217) — and the AI usage bounds, optional `aiUsage`: + * `{ maxLevel, defaultLevel, syncRegenGapHours }`. `maxLevel` is the highest + * level an account may choose, `defaultLevel` the level an account without a + * choice runs at (never above `maxLevel`: 422 `ai.usage.defaultAboveCeiling`), + * `syncRegenGapHours` the minimum gap between status-card regenerations after + * a sync at `standard` (1..48, `null` follows `AI_SYNC_REGEN_GAP_HOURS`, else + * six). Lowering `maxLevel` caps every account above it at once; their stored + * choice is kept. Echoed as `aiUsage` with the resolved gap beside the stored + * one. + * * `requireAdmin()` gates the route — non-admins get 403. */ import type { NextRequest } from "next/server"; @@ -55,6 +65,13 @@ 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"; +import { aiUsageSettingsFromRow } from "@/lib/ai/capabilities/usage"; +import { + usageLevelRank, + usageLevelToDb, + type AiUsageLevelDb, +} from "@/lib/ai/usage-level"; +import { adminAiUsageSchema } from "@/lib/validations/ai-usage"; const assistantFlagsSchema = z .object({ @@ -65,6 +82,7 @@ const assistantFlagsSchema = z assistantDocumentAiEnabled: z.boolean().optional(), aiReasoningEnabled: z.boolean().optional(), aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(), + aiUsage: adminAiUsageSchema.optional(), }) .strict(); @@ -76,6 +94,9 @@ type AssistantFlagsRow = { assistantDocumentAiEnabled: boolean; aiReasoningEnabled: boolean; aiReasoningMaxEffort: string; + aiUsageMaxLevel: AiUsageLevelDb; + aiUsageDefaultLevel: AiUsageLevelDb; + aiSyncRegenGapHours: number | null; }; const ROW_SELECT = { @@ -86,6 +107,9 @@ const ROW_SELECT = { assistantDocumentAiEnabled: true, aiReasoningEnabled: true, aiReasoningMaxEffort: true, + aiUsageMaxLevel: true, + aiUsageDefaultLevel: true, + aiSyncRegenGapHours: true, } as const; function buildResponseShape(row: AssistantFlagsRow) { @@ -109,6 +133,18 @@ function buildResponseShape(row: AssistantFlagsRow) { enabled: row.aiReasoningEnabled, maxEffort: parseReasoningMaxEffort(row.aiReasoningMaxEffort), }, + aiUsage: aiUsageShape(row), + }; +} + +/** The usage bounds as the admin panel edits them, the gap in effect beside. */ +function aiUsageShape(row: AssistantFlagsRow) { + const settings = aiUsageSettingsFromRow(row); + return { + maxLevel: settings.maxLevel, + defaultLevel: settings.defaultLevel, + syncRegenGapHours: settings.storedSyncRegenGapHours, + effectiveSyncRegenGapHours: settings.syncRegenGapHours, }; } @@ -132,6 +168,9 @@ export const GET = apiHandler(async () => { assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true, aiReasoningEnabled: settings?.aiReasoningEnabled ?? true, aiReasoningMaxEffort: settings?.aiReasoningMaxEffort ?? "high", + aiUsageMaxLevel: settings?.aiUsageMaxLevel ?? "STANDARD", + aiUsageDefaultLevel: settings?.aiUsageDefaultLevel ?? "STANDARD", + aiSyncRegenGapHours: settings?.aiSyncRegenGapHours ?? null, }; return apiSuccess(buildResponseShape(row)); @@ -154,13 +193,28 @@ export const PUT = apiHandler(async (request: NextRequest) => { // Field by field from the parsed body; the schema is strict, so every key // here is one the route owns. + const { aiUsage, ...flags } = parsed.data; + if ( + aiUsage && + usageLevelRank(aiUsage.defaultLevel) > usageLevelRank(aiUsage.maxLevel) + ) { + return apiError("The default level is above the highest level", 422, { + errorCode: "ai.usage.defaultAboveCeiling", + }); + } const updates: Partial = {}; - for (const [key, value] of Object.entries(parsed.data)) { + for (const [key, value] of Object.entries(flags)) { if (value !== undefined) { (updates as Record)[key] = value; } } const auditDetails: Record = { ...updates }; + if (aiUsage) { + updates.aiUsageMaxLevel = usageLevelToDb(aiUsage.maxLevel); + updates.aiUsageDefaultLevel = usageLevelToDb(aiUsage.defaultLevel); + updates.aiSyncRegenGapHours = aiUsage.syncRegenGapHours; + auditDetails.aiUsage = aiUsage; + } if (Object.keys(updates).length === 0) { return apiError("No valid fields", 422); @@ -187,6 +241,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled, aiReasoningEnabled: settings.aiReasoningEnabled, aiReasoningMaxEffort: settings.aiReasoningMaxEffort, + aiUsageMaxLevel: settings.aiUsageMaxLevel, + aiUsageDefaultLevel: settings.aiUsageDefaultLevel, + aiSyncRegenGapHours: settings.aiSyncRegenGapHours, }; return apiSuccess(buildResponseShape(row));