feat(admin): set the AI usage bounds and count accounts per level

PUT /api/admin/settings/assistant-flags takes an optional aiUsage block:
the highest level an account may choose, the default for accounts without
a choice (422 ai.usage.defaultAboveCeiling when above the ceiling) and the
sync regeneration gap at standard (1..48 hours, null follows
AI_SYNC_REGEN_GAP_HOURS, else 6). Lowering the ceiling caps every account
above it at once and keeps their stored choice. The provider-health readout
counts accounts per effective level.

Refs #1217
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:49:01 +02:00
1 parent 6f673a7b84
commit d3770b9010
4 files changed
+207 -1

No files matched your search

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