mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
feat(ai): resolve an AI usage level per record and publish it as ai.usage
Three levels (frugal, standard, full) with an operator ceiling and default and a per-account choice within them, resolved in the capability layer for the record and published on GET /api/auth/me as ai.usage: the level in effect, the stored choice, the bounds, why (user, default, admin_cap), the three options with whether each is allowed and an estimate of tokens per day scaled from the record's last seven full days of coach_usage, and the measured mean. A delegate sees the level, never the owner's spend. PUT /api/auth/me/ai-usage stores the account's own choice or null for the default, refusing a level above the ceiling with 422 ai.usage.aboveCeiling and meta.maxLevel. The new supplementLabel capability needs standard or above; below it the capability is unavailable with the new usage_level reason, refused with 403 ai.usage.levelTooLow and meta.requiredLevel. Refs #1217
This commit is contained in:
1 parent
e573f28ae8
commit
6f673a7b84
22 files changed
+1342
-84
No files matched your search
@@ -165,6 +165,53 @@ describe("AI capability route inventory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Capabilities no route asks, each with its reason. Every other capability is
|
||||
* asked by at least one route on `AI_ROUTES`, so a key cannot be added to the
|
||||
* table and forgotten at the edge.
|
||||
*
|
||||
* - `reactionLines`: written by a job after a new reading arrives, never on
|
||||
* request; the job asks `aiCapabilityForJob`.
|
||||
* - `supplementLabel` (v1.43, #1217): its route
|
||||
* (`medications/[id]/ingredients/extract`) is a contract stub until the
|
||||
* supplement package fills it. The check below fails the moment the stub
|
||||
* answers anything but 501, so the entry has to move to `AI_ROUTES` with
|
||||
* the gate.
|
||||
*/
|
||||
const CAPABILITIES_WITHOUT_ROUTE: Readonly<
|
||||
Partial<Record<(typeof AI_CAPABILITY_KEYS)[number], string>>
|
||||
> = {
|
||||
reactionLines: "job only",
|
||||
supplementLabel: "src/app/api/medications/[id]/ingredients/extract/route.ts",
|
||||
};
|
||||
|
||||
describe("every capability reaches a route", () => {
|
||||
it("is asked by an AI route, or named with its reason", () => {
|
||||
const asked = new Set(
|
||||
Object.values(AI_ROUTES).flatMap((entry) => [...entry.capabilities]),
|
||||
);
|
||||
const orphaned = AI_CAPABILITY_KEYS.filter(
|
||||
(key) => !asked.has(key) && !(key in CAPABILITIES_WITHOUT_ROUTE),
|
||||
);
|
||||
expect(orphaned).toEqual([]);
|
||||
const stale = Object.keys(CAPABILITIES_WITHOUT_ROUTE).filter((key) =>
|
||||
asked.has(key as (typeof AI_CAPABILITY_KEYS)[number]),
|
||||
);
|
||||
expect(stale, "asked by a route now: drop it from the list").toEqual([]);
|
||||
});
|
||||
|
||||
it("knows supplementLabel, and its route is still the stub", () => {
|
||||
expect(AI_CAPABILITY_KEYS).toContain("supplementLabel");
|
||||
const path = CAPABILITIES_WITHOUT_ROUTE.supplementLabel!;
|
||||
expect(ROUTES).toContain(path);
|
||||
const text = code(path);
|
||||
expect(
|
||||
/\bnotImplemented\s*\(/.test(text) && !ANY_GATE.test(text),
|
||||
`${path} is no longer a stub: gate it with requireAiCapability("supplementLabel") and list it on AI_ROUTES`,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/** The route that queues each kind of background run. */
|
||||
const RUN_QUEUED_BY: Record<DocumentAiRunKindValue, string> = {
|
||||
DOCUMENT_INDEX: "src/app/api/documents/inbound/[id]/index/route.ts",
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* `PUT /api/auth/me/ai-usage` (#1217): the account's own level, within the
|
||||
* operator's ceiling. A level above it is refused with 422
|
||||
* `ai.usage.aboveCeiling` and `meta.maxLevel`, never capped silently; `null`
|
||||
* returns to the instance default; the answer is the resolved `ai.usage`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
stored: null as string | null,
|
||||
settings: {
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
} as Record<string, unknown>,
|
||||
usage: [] as Array<{ totalTokens: number }>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
aiUsageLevel: db.stored,
|
||||
timezone: "UTC",
|
||||
})),
|
||||
update: vi.fn(async (args: { data: { aiUsageLevel: string | null } }) => {
|
||||
db.stored = args.data.aiUsageLevel;
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
appSettings: { findUnique: vi.fn(async () => db.settings) },
|
||||
coachUsage: { findMany: vi.fn(async () => db.usage) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "UTC"),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const put = PUT as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
function req(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/ai-usage", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.stored = null;
|
||||
db.settings = {
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
};
|
||||
db.usage = [];
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/ai-usage", () => {
|
||||
it("stores a level within the ceiling and answers the resolved state", async () => {
|
||||
db.usage = [
|
||||
{ totalTokens: 90_000 },
|
||||
{ totalTokens: 110_000 },
|
||||
{ totalTokens: 100_000 },
|
||||
];
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.user.update).mock.calls[0]![0]).toEqual({
|
||||
where: { id: "user-1" },
|
||||
data: { aiUsageLevel: "FRUGAL" },
|
||||
});
|
||||
const { data } = await res.json();
|
||||
expect(data).toMatchObject({
|
||||
level: "frugal",
|
||||
preference: "frugal",
|
||||
defaultLevel: "standard",
|
||||
maxLevel: "standard",
|
||||
source: "user",
|
||||
measuredTokensPerDay: 100_000,
|
||||
measuredDays: 3,
|
||||
});
|
||||
expect(data.options).toEqual([
|
||||
{ level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 },
|
||||
{ level: "standard", allowed: true, estimatedTokensPerDay: 200_000 },
|
||||
{ level: "full", allowed: false, estimatedTokensPerDay: 666_667 },
|
||||
]);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.aiUsage.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: { previous: null, next: "frugal" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a level above the ceiling with the ceiling named", async () => {
|
||||
const res = await put(req({ level: "full" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toMatchObject({
|
||||
errorCode: "ai.usage.aboveCeiling",
|
||||
maxLevel: "standard",
|
||||
});
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts full once the operator allows it", async () => {
|
||||
db.settings = { ...db.settings, aiUsageMaxLevel: "FULL" };
|
||||
const res = await put(req({ level: "full" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.level).toBe("full");
|
||||
});
|
||||
|
||||
it("returns to the instance default on null", async () => {
|
||||
db.stored = "FRUGAL";
|
||||
const res = await put(req({ level: null }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.user.update).mock.calls[0]![0].data).toEqual({
|
||||
aiUsageLevel: null,
|
||||
});
|
||||
const { data } = await res.json();
|
||||
expect(data).toMatchObject({
|
||||
level: "standard",
|
||||
preference: null,
|
||||
source: "default",
|
||||
});
|
||||
// Fewer than three days of history: no estimate.
|
||||
expect(
|
||||
data.options.every(
|
||||
(o: { estimatedTokensPerDay: number | null }) =>
|
||||
o.estimatedTokensPerDay === null,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ level: "lavish" }],
|
||||
[{}],
|
||||
[{ level: "frugal", extra: true }],
|
||||
[{ level: 1 }],
|
||||
])("refuses %j as invalid", async (body) => {
|
||||
const res = await put(req(body));
|
||||
expect(res.status).toBe(422);
|
||||
expect((await res.json()).meta.errorCode).toBe("ai.usage.invalid");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses the 31st change in an hour", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(429);
|
||||
expect((await res.json()).meta.errorCode).toBe("ai.usage.rateLimited");
|
||||
expect(vi.mocked(checkRateLimit).mock.calls[0]!.slice(1)).toEqual([
|
||||
30,
|
||||
60 * 60 * 1000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a caller without a session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,22 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
loadAiUsageSettings,
|
||||
loadAiUsageState,
|
||||
} from "@/lib/ai/capabilities/usage";
|
||||
import { usageLevelRank, usageLevelToDb } from "@/lib/ai/usage-level";
|
||||
import { aiUsagePutSchema } from "@/lib/validations/ai-usage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -38,5 +51,43 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("auth.me.ai_usage.put");
|
||||
const { level } = parsed.data;
|
||||
const settings = await loadAiUsageSettings();
|
||||
if (
|
||||
level !== null &&
|
||||
usageLevelRank(level) > usageLevelRank(settings.maxLevel)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "auth.me.ai_usage.put.above_ceiling" },
|
||||
meta: { level, maxLevel: settings.maxLevel },
|
||||
});
|
||||
return apiError(
|
||||
"This level is above the highest one the server allows",
|
||||
422,
|
||||
{ errorCode: "ai.usage.aboveCeiling", maxLevel: settings.maxLevel },
|
||||
);
|
||||
}
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { aiUsageLevel: true },
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { aiUsageLevel: level === null ? null : usageLevelToDb(level) },
|
||||
});
|
||||
await auditLog("user.aiUsage.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
previous: previous?.aiUsageLevel?.toLowerCase() ?? null,
|
||||
next: level,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.me.ai_usage.put" },
|
||||
meta: { level: level ?? "default" },
|
||||
});
|
||||
|
||||
return apiSuccess(await loadAiUsageState(user.id, { withMeasurement: true }));
|
||||
});
|
||||
Reference in new issue
Block a user