mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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));
|
||||
|
||||
Reference in new issue
Block a user