mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
+63
-47
@@ -23992,7 +23992,6 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429": *a5
|
||||
"501": *a30
|
||||
/api/medications/{id}/ingredients:
|
||||
put:
|
||||
tags:
|
||||
@@ -35504,41 +35503,42 @@ components:
|
||||
(`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the
|
||||
per-provider additions) are enumerated in each `/test` operation instead, where the differences can be
|
||||
stated; and a 401 raised by a route checking a credential of its own may carry no code at all. ai —
|
||||
ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.invalid,
|
||||
ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed,
|
||||
aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics —
|
||||
analytics.invalid_query; anamnesis — anamnesis.auditC.invalid, anamnesis.auditC.rateLimited,
|
||||
anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue,
|
||||
anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.admin.required,
|
||||
auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid,
|
||||
auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required,
|
||||
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.stepup.required,
|
||||
auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing,
|
||||
backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large;
|
||||
biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded,
|
||||
coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
|
||||
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
|
||||
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
|
||||
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
|
||||
consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
|
||||
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
|
||||
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
|
||||
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
|
||||
disable-coach.body.invalid_json; dawarich — dawarich.invalid, dawarich.rate_limited; day — day.invalid;
|
||||
demo — demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid,
|
||||
documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
|
||||
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
|
||||
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
|
||||
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
|
||||
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
|
||||
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound,
|
||||
documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic,
|
||||
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
|
||||
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
|
||||
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
|
||||
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
|
||||
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
|
||||
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling,
|
||||
ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider
|
||||
— ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut,
|
||||
aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis —
|
||||
anamnesis.auditC.invalid, anamnesis.auditC.rateLimited, anamnesis.fact.conflict,
|
||||
anamnesis.fact.currentExists, anamnesis.fact.invalidValue, anamnesis.lifeConfirm.invalid,
|
||||
anamnesis.lifeSections.invalid; auth — auth.admin.required, auth.already_authenticated,
|
||||
auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked,
|
||||
auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient,
|
||||
auth.session.insecure_transport, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
|
||||
backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active,
|
||||
backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site —
|
||||
body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle,
|
||||
coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit,
|
||||
coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired,
|
||||
coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs —
|
||||
coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle —
|
||||
cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
|
||||
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
|
||||
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
|
||||
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
|
||||
dawarich — dawarich.invalid, dawarich.rate_limited; day — day.invalid; demo — demo.readOnly; documents —
|
||||
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
|
||||
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
|
||||
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
|
||||
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
|
||||
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
|
||||
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
|
||||
documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed,
|
||||
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
|
||||
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
|
||||
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
|
||||
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
|
||||
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
|
||||
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
|
||||
documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
|
||||
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
|
||||
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
|
||||
@@ -35625,19 +35625,27 @@ components:
|
||||
capability:
|
||||
description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`,
|
||||
`briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`,
|
||||
`documentAi`, `labsOcr`, `medicationExtract`)."
|
||||
`documentAi`, `labsOcr`, `medicationExtract`, `supplementLabel`)."
|
||||
type: string
|
||||
reason:
|
||||
description: "On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it:
|
||||
`operator_disabled` → `assistant.disabled.<switch>` (or `module.disabled` with `module` when the
|
||||
operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`,
|
||||
`module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own
|
||||
typed code where it has one, otherwise `ai.provider.none` (422), `consent_required` →
|
||||
`consent.ai.required`, `check_failed` → `ai.unavailable` (503)."
|
||||
typed code where it has one, otherwise `ai.provider.none` (422), `usage_level` → `ai.usage.levelTooLow`
|
||||
with `requiredLevel`, `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable`
|
||||
(503)."
|
||||
type: string
|
||||
module:
|
||||
description: "Beside `module.disabled`: the module that is off. On an AI refusal it is the capability's owning module."
|
||||
type: string
|
||||
requiredLevel:
|
||||
description: "Beside `ai.usage.levelTooLow` (v1.43): the lowest AI usage level at which the capability is offered."
|
||||
type: string
|
||||
enum:
|
||||
- frugal
|
||||
- standard
|
||||
- full
|
||||
additionalProperties: {}
|
||||
required:
|
||||
- data
|
||||
@@ -47738,6 +47746,7 @@ components:
|
||||
- not_permitted_for_record
|
||||
- module_disabled
|
||||
- user_disabled
|
||||
- usage_level
|
||||
- no_provider
|
||||
- consent_required
|
||||
description: "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is
|
||||
@@ -47747,9 +47756,10 @@ components:
|
||||
admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off
|
||||
for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI
|
||||
opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else.
|
||||
`no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider
|
||||
chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does
|
||||
not know treats the capability as unavailable."
|
||||
`usage_level` — the record's AI usage level (`ai.usage.level`, v1.43) is below what the capability needs; today
|
||||
only `supplementLabel` on `frugal`. `no_provider` — no configured provider can serve this capability's input.
|
||||
`consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a
|
||||
client that meets a value it does not know treats the capability as unavailable."
|
||||
AiCapabilityState:
|
||||
type: object
|
||||
properties:
|
||||
@@ -47763,10 +47773,10 @@ components:
|
||||
onDeviceAllowed:
|
||||
type: boolean
|
||||
description: "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`,
|
||||
`module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the
|
||||
device too. True otherwise, since a missing server provider or a missing consent for server egress do not
|
||||
concern a model that runs on the device. Resolved here so the client never decides which reasons apply off
|
||||
the server."
|
||||
`module_disabled`, `user_disabled`, `usage_level` and `check_failed`: the operator's and the person's
|
||||
decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for
|
||||
server egress do not concern a model that runs on the device. Resolved here so the client never decides
|
||||
which reasons apply off the server."
|
||||
required:
|
||||
- available
|
||||
- reason
|
||||
@@ -55227,6 +55237,7 @@ components:
|
||||
- not_permitted_for_record
|
||||
- module_disabled
|
||||
- user_disabled
|
||||
- usage_level
|
||||
- no_provider
|
||||
- consent_required
|
||||
required:
|
||||
@@ -79968,6 +79979,9 @@ components:
|
||||
medicationExtract:
|
||||
description: The "medicationExtract" capability. Covered by the operator's "documentAi" switch.
|
||||
$ref: "#/components/schemas/AiCapabilityState"
|
||||
supplementLabel:
|
||||
description: The "supplementLabel" capability. Covered by the operator's "documentAi" switch.
|
||||
$ref: "#/components/schemas/AiCapabilityState"
|
||||
required:
|
||||
- coach
|
||||
- briefing
|
||||
@@ -79979,6 +79993,7 @@ components:
|
||||
- documentAi
|
||||
- labsOcr
|
||||
- medicationExtract
|
||||
- supplementLabel
|
||||
additionalProperties: false
|
||||
description: Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory
|
||||
upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted
|
||||
@@ -79987,7 +80002,8 @@ components:
|
||||
paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` —
|
||||
model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest,
|
||||
summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image.
|
||||
`medicationExtract` — turning a typed medication description into a schedule.
|
||||
`medicationExtract` — turning a typed medication description into a schedule. `supplementLabel` (v1.43) —
|
||||
reading a supplement's label photo into its ingredients; unavailable on the `frugal` usage level.
|
||||
AiProviderState:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -622,7 +622,7 @@ model User {
|
||||
// `useAuth()` hook.
|
||||
disableCoach Boolean @default(false) @map("disable_coach")
|
||||
|
||||
/// @pending-consumer(#v143-B1) The account's own AI usage level (#1217).
|
||||
/// The account's own AI usage level (#1217).
|
||||
/// NULL follows the instance default (`AppSettings.aiUsageDefaultLevel`);
|
||||
/// a stored level above `AppSettings.aiUsageMaxLevel` is kept but capped
|
||||
/// at read time. Written by `PUT /api/auth/me/ai-usage`, resolved in
|
||||
@@ -5268,11 +5268,13 @@ model AppSettings {
|
||||
// `aiSyncRegenGapHours` the minimum gap between status-card regenerations
|
||||
// after a sync at `standard` (NULL = env `AI_SYNC_REGEN_GAP_HOURS`, else 6).
|
||||
// Edited through `PUT /api/admin/settings/assistant-flags` (`aiUsage`).
|
||||
/// @pending-consumer(#v143-B1) Instance ceiling for the per-account AI usage level.
|
||||
/// Instance ceiling for the per-account AI usage level (resolved in
|
||||
/// `src/lib/ai/capabilities/usage.ts`).
|
||||
aiUsageMaxLevel AiUsageLevel @default(STANDARD) @map("ai_usage_max_level")
|
||||
/// @pending-consumer(#v143-B1) Level for accounts without their own choice.
|
||||
/// Level for accounts without their own choice.
|
||||
aiUsageDefaultLevel AiUsageLevel @default(STANDARD) @map("ai_usage_default_level")
|
||||
/// @pending-consumer(#v143-B1) Sync regeneration gap in hours (1..48); NULL = env or 6.
|
||||
/// Sync regeneration gap in hours (1..48); NULL = env or 6. Read by the
|
||||
/// status invalidator at the `standard` level.
|
||||
aiSyncRegenGapHours Int? @map("ai_sync_regen_gap_hours")
|
||||
|
||||
// v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches
|
||||
|
||||
@@ -19,6 +19,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { AI_CAPABILITY_KEYS } from "@/lib/ai/capabilities/types";
|
||||
import { DOCUMENT_AI_RUN_KINDS } from "@/lib/documents/ai-runs/types";
|
||||
import type { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
|
||||
@@ -283,3 +284,29 @@ describe("the status chokepoint's capability argument", () => {
|
||||
expect(withCapability.capability).toBe("statusText");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.43 (#1217) — capabilities whose model call has not landed yet. The
|
||||
* capability is published (clients can render its state) before the route
|
||||
* that reads a label photo exists; until then that route must not reach a
|
||||
* provider at all. When it does, it becomes an egress site above, re-checking
|
||||
* its capability at the wire like every document read, and leaves this list.
|
||||
*/
|
||||
const CAPABILITY_EGRESS_PENDING: Record<string, string> = {
|
||||
supplementLabel: "app/api/medications/[id]/ingredients/extract/route.ts",
|
||||
};
|
||||
|
||||
describe("capabilities whose model call is still to come", () => {
|
||||
it.each(Object.entries(CAPABILITY_EGRESS_PENDING))(
|
||||
"%s is a published capability whose route does not call a model yet",
|
||||
(key, rel) => {
|
||||
expect(AI_CAPABILITY_KEYS as readonly string[]).toContain(key);
|
||||
const text = code(rel);
|
||||
expect(
|
||||
EGRESS_CALL.test(text),
|
||||
`${rel} calls a provider now: list it in EGRESS_SITES with its re-check and drop it here`,
|
||||
).toBe(false);
|
||||
expect(/\bnotImplemented\s*\(/.test(text)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -39,7 +39,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
],
|
||||
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
|
||||
"auth/claim/sso/route.ts": ["profile_claim.sso.start"],
|
||||
"auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"],
|
||||
"biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"],
|
||||
"dawarich/connect/route.ts": ["dawarich.connect"],
|
||||
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
|
||||
@@ -64,7 +63,7 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
};
|
||||
|
||||
/** The size of the register when it was written; see the header. */
|
||||
const REGISTER_SIZE = 24;
|
||||
const REGISTER_SIZE = 23;
|
||||
|
||||
function routeFiles(): string[] {
|
||||
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
|
||||
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
|
||||
import type { AiUsageState } from "@/lib/ai/usage-level";
|
||||
|
||||
/**
|
||||
* The web reader for the `ai` block on `GET /api/auth/me`.
|
||||
@@ -85,6 +86,25 @@ function useCoachReasoningInner(): CoachReasoningState | null {
|
||||
return user?.coachReasoning ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.43 (#1217) — the record's AI usage level (`ai.usage` on
|
||||
* `GET /api/auth/me`): the level in effect, the person's choice, the
|
||||
* operator's bounds, the three options with their estimates. `null` while it
|
||||
* is not known or on a server that does not publish it; the setting is then
|
||||
* not shown. Render from it; never compute an estimate or an allowance here.
|
||||
*/
|
||||
export function useAiUsage(): AiUsageState | null {
|
||||
const hasClient = useQueryClientMounted();
|
||||
if (!hasClient) return null;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return useAiUsageInner();
|
||||
}
|
||||
|
||||
function useAiUsageInner(): AiUsageState | null {
|
||||
const { user } = useAuth();
|
||||
return user?.ai?.usage ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's answer for one capability, or `null` while there is none yet
|
||||
* (`/me` loading, no signed-in account, no query client). For the few places
|
||||
|
||||
@@ -47,6 +47,7 @@ const CONTRACT: Record<AiUnavailableReason, [number, string]> = {
|
||||
not_permitted_for_record: [403, "ai.record.notPermitted"],
|
||||
module_disabled: [403, "module.disabled"],
|
||||
user_disabled: [403, "module.disabled"],
|
||||
usage_level: [403, "ai.usage.levelTooLow"],
|
||||
no_provider: [422, "ai.provider.none"],
|
||||
consent_required: [403, "consent.ai.required"],
|
||||
};
|
||||
@@ -75,6 +76,18 @@ describe("the refusal table", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("names the level a usage refusal needs (#1217)", () => {
|
||||
expect(aiRefusal("supplementLabel", "usage_level")).toEqual({
|
||||
status: 403,
|
||||
meta: {
|
||||
errorCode: "ai.usage.levelTooLow",
|
||||
capability: "supplementLabel",
|
||||
reason: "usage_level",
|
||||
requiredLevel: "standard",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the code shipped clients know for the Coach", () => {
|
||||
expect(aiRefusal("coach", "operator_disabled").meta.errorCode).toBe(
|
||||
"assistant.disabled.coach",
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* #1217 — the AI usage level: resolution, effects and the estimate.
|
||||
*
|
||||
* The resolution matrix is exhaustive over every stored choice (three levels
|
||||
* and none), every default and every ceiling: 4 x 3 x 3 = 36 cases, each
|
||||
* checked against a rule written out independently of the resolver.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AI_USAGE_LEVELS,
|
||||
AI_USAGE_LEVEL_EFFECTS,
|
||||
buildAiUsageState,
|
||||
capUsageLevel,
|
||||
estimateTokensPerDay,
|
||||
parseSyncRegenGapHours,
|
||||
resolveAiUsageLevel,
|
||||
statusRegenGapHours,
|
||||
usageLevelFromDb,
|
||||
usageLevelToDb,
|
||||
type AiUsageLevel,
|
||||
} from "@/lib/ai/usage-level";
|
||||
|
||||
const RANK: Record<AiUsageLevel, number> = { frugal: 0, standard: 1, full: 2 };
|
||||
|
||||
describe("resolveAiUsageLevel — every choice x default x ceiling", () => {
|
||||
const choices: Array<AiUsageLevel | null> = [null, ...AI_USAGE_LEVELS];
|
||||
let cases = 0;
|
||||
for (const preference of choices) {
|
||||
for (const defaultLevel of AI_USAGE_LEVELS) {
|
||||
for (const maxLevel of AI_USAGE_LEVELS) {
|
||||
cases++;
|
||||
it(`choice ${preference ?? "none"}, default ${defaultLevel}, ceiling ${maxLevel}`, () => {
|
||||
const got = resolveAiUsageLevel({
|
||||
preference,
|
||||
defaultLevel,
|
||||
maxLevel,
|
||||
});
|
||||
if (preference === null) {
|
||||
// No choice: the default, never above the ceiling.
|
||||
const expected =
|
||||
RANK[defaultLevel] > RANK[maxLevel] ? maxLevel : defaultLevel;
|
||||
expect(got).toEqual({ level: expected, source: "default" });
|
||||
} else if (RANK[preference] > RANK[maxLevel]) {
|
||||
// A choice above the ceiling is capped, and says so.
|
||||
expect(got).toEqual({ level: maxLevel, source: "admin_cap" });
|
||||
} else {
|
||||
expect(got).toEqual({ level: preference, source: "user" });
|
||||
}
|
||||
// Whatever the inputs, the result never passes the ceiling.
|
||||
expect(RANK[got.level]).toBeLessThanOrEqual(RANK[maxLevel]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
it("covered every combination", () => {
|
||||
expect(cases).toBe(36);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what each level does", () => {
|
||||
it("regenerates status cards after a sync: a day, the operator's gap, the hour", () => {
|
||||
expect(statusRegenGapHours("frugal", "sync", 6)).toBe(24);
|
||||
expect(statusRegenGapHours("standard", "sync", 6)).toBe(6);
|
||||
expect(statusRegenGapHours("standard", "sync", 12)).toBe(12);
|
||||
expect(statusRegenGapHours("full", "sync", 12)).toBe(1);
|
||||
});
|
||||
|
||||
it("regenerates after a hand entry: six hours on frugal, the hour otherwise", () => {
|
||||
expect(statusRegenGapHours("frugal", "manual", 6)).toBe(6);
|
||||
expect(statusRegenGapHours("standard", "manual", 24)).toBe(1);
|
||||
expect(statusRegenGapHours("full", "manual", 24)).toBe(1);
|
||||
});
|
||||
|
||||
it("caps the status snapshot at 10 000 on frugal and 14 000 above", () => {
|
||||
expect(
|
||||
AI_USAGE_LEVELS.map((l) => AI_USAGE_LEVEL_EFFECTS[l].statusSnapshotChars),
|
||||
).toEqual([10_000, 14_000, 14_000]);
|
||||
});
|
||||
|
||||
it("switches background reasoning and the automatic document read off on frugal only", () => {
|
||||
for (const level of AI_USAGE_LEVELS) {
|
||||
const effect = AI_USAGE_LEVEL_EFFECTS[level];
|
||||
expect(effect.backgroundReasoning, level).toBe(level !== "frugal");
|
||||
expect(effect.documentsAutoRead, level).toBe(level !== "frugal");
|
||||
}
|
||||
});
|
||||
|
||||
it("orders the token factors with the levels, full at one", () => {
|
||||
const factors = AI_USAGE_LEVELS.map(
|
||||
(l) => AI_USAGE_LEVEL_EFFECTS[l].tokenFactor,
|
||||
);
|
||||
expect(factors).toEqual([...factors].sort((a, b) => a - b));
|
||||
expect(AI_USAGE_LEVEL_EFFECTS.full.tokenFactor).toBe(1);
|
||||
// Calibrated by the watch-day simulation: standard ~0.31 of full,
|
||||
// frugal ~0.15, the nightly warm included.
|
||||
expect(AI_USAGE_LEVEL_EFFECTS.standard.tokenFactor).toBe(0.3);
|
||||
expect(AI_USAGE_LEVEL_EFFECTS.frugal.tokenFactor).toBe(0.15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the estimate", () => {
|
||||
it("is null without three days of history", () => {
|
||||
for (const measuredDays of [0, 1, 2]) {
|
||||
expect(
|
||||
estimateTokensPerDay({
|
||||
measuredTokensPerDay: 100_000,
|
||||
measuredDays,
|
||||
from: "standard",
|
||||
target: "full",
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
expect(
|
||||
estimateTokensPerDay({
|
||||
measuredTokensPerDay: null,
|
||||
measuredDays: 7,
|
||||
from: "standard",
|
||||
target: "full",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("scales the measured use by the factor ratio, whole tokens", () => {
|
||||
const at = (target: AiUsageLevel) =>
|
||||
estimateTokensPerDay({
|
||||
measuredTokensPerDay: 100_000,
|
||||
measuredDays: 5,
|
||||
from: "standard",
|
||||
target,
|
||||
});
|
||||
expect(at("standard")).toBe(100_000);
|
||||
expect(at("full")).toBe(333_333);
|
||||
expect(at("frugal")).toBe(50_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAiUsageState", () => {
|
||||
it("publishes three options in ceiling order with allowance and estimate", () => {
|
||||
const state = buildAiUsageState({
|
||||
preference: "full",
|
||||
defaultLevel: "standard",
|
||||
maxLevel: "standard",
|
||||
measuredTokensPerDay: 50_000,
|
||||
measuredDays: 7,
|
||||
});
|
||||
expect(state.level).toBe("standard");
|
||||
expect(state.source).toBe("admin_cap");
|
||||
expect(state.preference).toBe("full");
|
||||
expect(state.options.map((o) => [o.level, o.allowed])).toEqual([
|
||||
["frugal", true],
|
||||
["standard", true],
|
||||
["full", false],
|
||||
]);
|
||||
expect(state.options.map((o) => o.estimatedTokensPerDay)).toEqual([
|
||||
25_000, 50_000, 166_667,
|
||||
]);
|
||||
expect(state.measuredTokensPerDay).toBe(50_000);
|
||||
expect(state.measuredDays).toBe(7);
|
||||
});
|
||||
|
||||
it("publishes no estimate without history", () => {
|
||||
const state = buildAiUsageState({
|
||||
preference: null,
|
||||
defaultLevel: "standard",
|
||||
maxLevel: "full",
|
||||
measuredTokensPerDay: null,
|
||||
measuredDays: 0,
|
||||
});
|
||||
expect(state.options.every((o) => o.estimatedTokensPerDay === null)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(state.source).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spelling and parsing", () => {
|
||||
it("round-trips the database spelling", () => {
|
||||
for (const level of AI_USAGE_LEVELS) {
|
||||
expect(usageLevelFromDb(usageLevelToDb(level))).toBe(level);
|
||||
}
|
||||
expect(usageLevelFromDb(null)).toBeNull();
|
||||
expect(usageLevelFromDb("LAVISH")).toBeNull();
|
||||
});
|
||||
|
||||
it("caps a level at a ceiling", () => {
|
||||
expect(capUsageLevel("full", "standard")).toBe("standard");
|
||||
expect(capUsageLevel("frugal", "standard")).toBe("frugal");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["6", 6],
|
||||
[12, 12],
|
||||
["48", 48],
|
||||
["0", null],
|
||||
["49", null],
|
||||
["2.5", null],
|
||||
["", null],
|
||||
[undefined, null],
|
||||
["abc", null],
|
||||
])("parses the sync gap %s as %s", (raw, expected) => {
|
||||
expect(parseSyncRegenGapHours(raw)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ function baseline(): AiCapabilityInputs {
|
||||
},
|
||||
activeConsentKinds: new Set(),
|
||||
recordKind: "self",
|
||||
usageLevel: "full",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,6 +104,11 @@ const PERTURB: Record<Exclude<AiUnavailableReason, "check_failed">, Perturb> = {
|
||||
inputs.moduleAccess = access;
|
||||
return true;
|
||||
},
|
||||
usage_level: (inputs, key) => {
|
||||
if (AI_CAPABILITIES[key].minUsageLevel === undefined) return false;
|
||||
inputs.usageLevel = "frugal";
|
||||
return true;
|
||||
},
|
||||
no_provider: (inputs) => {
|
||||
inputs.provider = { ...inputs.provider, entries: [], managedBy: null };
|
||||
return true;
|
||||
@@ -429,16 +435,18 @@ describe("on-device permission", () => {
|
||||
not_permitted_for_record: false,
|
||||
module_disabled: false,
|
||||
user_disabled: false,
|
||||
usage_level: false,
|
||||
no_provider: true,
|
||||
consent_required: true,
|
||||
};
|
||||
for (const reason of PERTURBABLE) {
|
||||
const inputs = baseline();
|
||||
PERTURB[reason](inputs, "briefing");
|
||||
expect(
|
||||
resolveAiCapability("briefing", inputs).onDeviceAllowed,
|
||||
reason,
|
||||
).toBe(expected[reason]);
|
||||
// `usage_level` only applies to a capability with a minimum level.
|
||||
const key = reason === "usage_level" ? "supplementLabel" : "briefing";
|
||||
PERTURB[reason](inputs, key);
|
||||
expect(resolveAiCapability(key, inputs).onDeviceAllowed, reason).toBe(
|
||||
expected[reason],
|
||||
);
|
||||
}
|
||||
expect(resolveAiCapability("briefing", null).onDeviceAllowed).toBe(
|
||||
expected.check_failed,
|
||||
@@ -486,3 +494,43 @@ describe("the provider block", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the AI usage level (#1217)", () => {
|
||||
it("closes only the capabilities that need a higher level", () => {
|
||||
const inputs = baseline();
|
||||
inputs.usageLevel = "frugal";
|
||||
const resolved = resolveAiCapabilities(inputs);
|
||||
for (const key of AI_CAPABILITY_KEYS) {
|
||||
const needsMore = AI_CAPABILITIES[key].minUsageLevel !== undefined;
|
||||
expect(resolved[key].reason, key).toBe(needsMore ? "usage_level" : null);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers the label photo from standard up", () => {
|
||||
for (const [level, available] of [
|
||||
["frugal", false],
|
||||
["standard", true],
|
||||
["full", true],
|
||||
] as const) {
|
||||
const inputs = baseline();
|
||||
inputs.usageLevel = level;
|
||||
expect(
|
||||
resolveAiCapability("supplementLabel", inputs).available,
|
||||
level,
|
||||
).toBe(available);
|
||||
}
|
||||
});
|
||||
|
||||
it("ranks after the person's opt-out and before the provider", () => {
|
||||
const inputs = baseline();
|
||||
inputs.usageLevel = "frugal";
|
||||
inputs.provider = { ...inputs.provider, entries: [], managedBy: null };
|
||||
expect(resolveAiCapability("supplementLabel", inputs).reason).toBe(
|
||||
"usage_level",
|
||||
);
|
||||
inputs.moduleAccess = { ...inputs.moduleAccess, nutrients: "disabled" };
|
||||
expect(resolveAiCapability("supplementLabel", inputs).reason).toBe(
|
||||
"module_disabled",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,9 @@
|
||||
* memoised reads the account payload and every module gate use;
|
||||
* - provider presence: the record's credential row and the operator's
|
||||
* provider columns (`probeProviderChain`), presence only;
|
||||
* - consent: the kinds of the record's active receipts, one indexed read.
|
||||
* - consent: the kinds of the record's active receipts, one indexed read;
|
||||
* - the record's AI usage level (v1.43): the operator's bounds (memoised)
|
||||
* and one point read of the record's choice.
|
||||
*
|
||||
* Any failure resolves to `null`, which the resolver turns into
|
||||
* `check_failed` for every capability. Failing closed is safe because no data
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
type AiRecordKind,
|
||||
} from "./resolve";
|
||||
import type { AiCapabilities } from "./types";
|
||||
import { loadAiUsageState, loadRecordUsageLevel } from "./usage";
|
||||
|
||||
/** Whose record, seen by whom, through which grant. */
|
||||
export interface AiCapabilityScope {
|
||||
@@ -89,14 +92,21 @@ export function loadAiCapabilityInputs(
|
||||
`ai-capability-inputs:${scopeKey(scope)}`,
|
||||
async () => {
|
||||
try {
|
||||
const [switches, modules, availability, provider, activeConsentKinds] =
|
||||
await Promise.all([
|
||||
loadAssistantSwitches(),
|
||||
resolveModuleMap(scope.recordId),
|
||||
getOperatorModuleAvailability(),
|
||||
probeProviderChain(scope.recordId, scope.authority),
|
||||
loadActiveConsentKinds(scope.recordId),
|
||||
]);
|
||||
const [
|
||||
switches,
|
||||
modules,
|
||||
availability,
|
||||
provider,
|
||||
activeConsentKinds,
|
||||
usageLevel,
|
||||
] = await Promise.all([
|
||||
loadAssistantSwitches(),
|
||||
resolveModuleMap(scope.recordId),
|
||||
getOperatorModuleAvailability(),
|
||||
probeProviderChain(scope.recordId, scope.authority),
|
||||
loadActiveConsentKinds(scope.recordId),
|
||||
loadRecordUsageLevel(scope.recordId),
|
||||
]);
|
||||
if (switches === null) return null;
|
||||
const { moduleAccess } = buildModuleDisclosure(
|
||||
modules,
|
||||
@@ -110,6 +120,7 @@ export function loadAiCapabilityInputs(
|
||||
provider,
|
||||
activeConsentKinds,
|
||||
recordKind: scope.recordKind,
|
||||
usageLevel,
|
||||
};
|
||||
} catch {
|
||||
getEvent()?.addWarning(
|
||||
@@ -122,9 +133,22 @@ export function loadAiCapabilityInputs(
|
||||
);
|
||||
}
|
||||
|
||||
/** The whole `ai` block for one scope. */
|
||||
/**
|
||||
* The whole `ai` block for one scope, the record's AI usage level included
|
||||
* (v1.43). The measured use and the estimates are published for one's own
|
||||
* record only: a delegate sees which level a record runs at, not what its
|
||||
* owner spends. A usage read that fails leaves `usage` out rather than
|
||||
* failing the block; the capabilities already carry the level they resolved.
|
||||
*/
|
||||
export async function loadAiCapabilities(
|
||||
scope: AiCapabilityScope,
|
||||
): Promise<AiCapabilities> {
|
||||
return resolveAiBlock(await loadAiCapabilityInputs(scope));
|
||||
const [inputs, usage] = await Promise.all([
|
||||
loadAiCapabilityInputs(scope),
|
||||
loadAiUsageState(scope.recordId, {
|
||||
withMeasurement: scope.recordKind === "self",
|
||||
}).catch(() => undefined),
|
||||
]);
|
||||
const block = resolveAiBlock(inputs);
|
||||
return usage ? { ...block, usage } : block;
|
||||
}
|
||||
@@ -8,12 +8,14 @@
|
||||
*/
|
||||
import { MODULE_DISABLED_ERROR_CODE } from "@/lib/modules/gate";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { AiUsageLevel } from "@/lib/ai/usage-level";
|
||||
|
||||
import {
|
||||
AI_CAPABILITIES,
|
||||
AI_PROVIDER_NONE_ERROR_CODE,
|
||||
AI_RECORD_NOT_PERMITTED_ERROR_CODE,
|
||||
AI_UNAVAILABLE_ERROR_CODE,
|
||||
AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE,
|
||||
type AiCapabilityKey,
|
||||
type AiUnavailableReason,
|
||||
} from "./types";
|
||||
@@ -33,6 +35,8 @@ export interface AiRefusalMeta {
|
||||
capability: AiCapabilityKey;
|
||||
reason: AiUnavailableReason;
|
||||
module?: ModuleKey;
|
||||
/** Beside `ai.usage.levelTooLow`: the lowest level the capability needs. */
|
||||
requiredLevel?: AiUsageLevel;
|
||||
}
|
||||
|
||||
/** The HTTP shape of one refusal. Pure; exported for the envelope test. */
|
||||
@@ -72,6 +76,17 @@ export function aiRefusal(
|
||||
...(module === null ? {} : { module }),
|
||||
},
|
||||
};
|
||||
case "usage_level":
|
||||
return {
|
||||
status: 403,
|
||||
meta: {
|
||||
...base,
|
||||
errorCode: AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE,
|
||||
...(AI_CAPABILITIES[capability].minUsageLevel
|
||||
? { requiredLevel: AI_CAPABILITIES[capability].minUsageLevel }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
case "no_provider":
|
||||
return {
|
||||
status: noProvider?.status ?? 422,
|
||||
@@ -99,6 +114,7 @@ const REFUSAL_MESSAGE: Record<AiUnavailableReason, string> = {
|
||||
not_permitted_for_record: "AI work is not available for this record",
|
||||
module_disabled: "This AI feature's module is turned off for this record",
|
||||
user_disabled: "This AI feature is turned off in your settings",
|
||||
usage_level: "This AI feature is not available at the current AI usage level",
|
||||
no_provider: "No AI provider is set up for this feature",
|
||||
consent_required: "AI consent is required for this feature",
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
isExternalProvider,
|
||||
isOperatorHeldProvider,
|
||||
} from "@/lib/ai/provider-egress";
|
||||
import { usageLevelAtLeast, type AiUsageLevel } from "@/lib/ai/usage-level";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
|
||||
|
||||
@@ -88,6 +89,11 @@ export interface AiCapabilityInputs {
|
||||
/** Kinds of the record's active (non-revoked) consent receipts. */
|
||||
activeConsentKinds: ReadonlySet<string>;
|
||||
recordKind: AiRecordKind;
|
||||
/**
|
||||
* v1.43 (#1217) — the record's AI usage level in effect. The loader always
|
||||
* sets it; absent (a hand-built set of inputs) means no level restriction.
|
||||
*/
|
||||
usageLevel?: AiUsageLevel;
|
||||
}
|
||||
|
||||
// Which tags are operator-held and which stays local: `@/lib/ai/provider-egress`,
|
||||
@@ -271,6 +277,15 @@ function consentReason(
|
||||
: "consent_required";
|
||||
}
|
||||
|
||||
/** Whether the record's usage level is below what the capability needs. */
|
||||
function usageReason(
|
||||
def: AiCapabilityDefinition,
|
||||
level: AiUsageLevel | undefined,
|
||||
): AiUnavailableReason | null {
|
||||
if (def.minUsageLevel === undefined || level === undefined) return null;
|
||||
return usageLevelAtLeast(level, def.minUsageLevel) ? null : "usage_level";
|
||||
}
|
||||
|
||||
function stateFor(reason: AiUnavailableReason | null): AiCapabilityState {
|
||||
if (reason === null) {
|
||||
return { available: true, reason: null, onDeviceAllowed: true };
|
||||
@@ -314,6 +329,7 @@ export function explainAiCapability(
|
||||
switchReason,
|
||||
inputs.providerWorkAdmitted ? null : "not_permitted_for_record",
|
||||
modules.reason,
|
||||
usageReason(def, inputs.usageLevel),
|
||||
providerReason(def, inputs.provider),
|
||||
consentReason(def, inputs.provider, inputs.activeConsentKinds),
|
||||
]);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* contract, the structural guards — can depend on it without pulling a
|
||||
* database client along.
|
||||
*/
|
||||
import type { AiUsageLevel, AiUsageState } from "@/lib/ai/usage-level";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
/** Every AI capability, in publication order. */
|
||||
@@ -29,6 +30,7 @@ export const AI_CAPABILITY_KEYS = [
|
||||
"documentAi",
|
||||
"labsOcr",
|
||||
"medicationExtract",
|
||||
"supplementLabel",
|
||||
] as const;
|
||||
|
||||
export type AiCapabilityKey = (typeof AI_CAPABILITY_KEYS)[number];
|
||||
@@ -49,6 +51,9 @@ export type AiCapabilityKey = (typeof AI_CAPABILITY_KEYS)[number];
|
||||
* record's own switch or by the edge of the active grant.
|
||||
* - `user_disabled`: the record's own AI opt-out: `disableCoach` for the
|
||||
* Coach, the `insights` module ("AI analysis") for everything else.
|
||||
* - `usage_level`: the record's AI usage level (v1.43, #1217) is below the
|
||||
* level the capability needs (`minUsageLevel`). A choice, like the
|
||||
* opt-out, so it ranks right after it and before the provider layers.
|
||||
* - `no_provider`: no configured provider can serve this capability's input.
|
||||
* - `consent_required`: the provider chain needs a consent receipt and none
|
||||
* is active.
|
||||
@@ -59,6 +64,7 @@ export const AI_UNAVAILABLE_REASONS = [
|
||||
"not_permitted_for_record",
|
||||
"module_disabled",
|
||||
"user_disabled",
|
||||
"usage_level",
|
||||
"no_provider",
|
||||
"consent_required",
|
||||
] as const;
|
||||
@@ -166,6 +172,11 @@ export interface AiCapabilityDefinition {
|
||||
consent: { rule: AiConsentRule; kinds: readonly AiConsentKind[] };
|
||||
modality: AiModality;
|
||||
providerOrder: AiProviderOrder;
|
||||
/**
|
||||
* v1.43 (#1217) — the lowest AI usage level at which the capability is
|
||||
* offered. Absent: every level.
|
||||
*/
|
||||
minUsageLevel?: AiUsageLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,6 +300,18 @@ export const AI_CAPABILITIES: Readonly<
|
||||
modality: "text",
|
||||
providerOrder: "chain",
|
||||
},
|
||||
// v1.43 — reading a supplement's label photo into its ingredients. A photo
|
||||
// is a document, and an extra model run per label, so it is not offered on
|
||||
// the `frugal` usage level.
|
||||
supplementLabel: {
|
||||
key: "supplementLabel",
|
||||
operatorSwitch: "documentAi",
|
||||
modules: { mode: "all", keys: ["medications", "nutrients"] },
|
||||
consent: { rule: "document", kinds: EXTRACTION_KINDS },
|
||||
modality: "document",
|
||||
providerOrder: "document",
|
||||
minUsageLevel: "standard",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -328,6 +351,11 @@ export interface AiProviderState {
|
||||
export interface AiCapabilities {
|
||||
capabilities: Record<AiCapabilityKey, AiCapabilityState>;
|
||||
provider: AiProviderState;
|
||||
/**
|
||||
* v1.43 (#1217) — the record's AI usage level, its bounds and the estimate.
|
||||
* Absent on servers before v1.43 and on blocks resolved without it.
|
||||
*/
|
||||
usage?: AiUsageState;
|
||||
}
|
||||
|
||||
// ── Refusal codes ──────────────────────────────────────────────────────────
|
||||
@@ -345,3 +373,5 @@ export const AI_RECORD_NOT_PERMITTED_ERROR_CODE = "ai.record.notPermitted";
|
||||
export const AI_PROVIDER_NONE_ERROR_CODE = "ai.provider.none";
|
||||
/** The capability's inputs could not be loaded; the answer failed closed. */
|
||||
export const AI_UNAVAILABLE_ERROR_CODE = "ai.unavailable";
|
||||
/** v1.43 — the record's AI usage level is below what the capability needs. */
|
||||
export const AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE = "ai.usage.levelTooLow";
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* v1.43 (#1217) — the reads behind an AI usage level.
|
||||
*
|
||||
* The vocabulary, the resolver and the estimate are pure and live in
|
||||
* `@/lib/ai/usage-level`; this file gathers their inputs:
|
||||
*
|
||||
* - the operator's bounds and sync gap: the `AppSettings` singleton, one
|
||||
* memoised read per request (read afresh inside a background run, so a
|
||||
* changed ceiling reaches the next account of a nightly pass);
|
||||
* - the record's stored choice: one point read of `users.ai_usage_level`;
|
||||
* - the measured use: the record's `coach_usage` rows of the last seven
|
||||
* full days in its own time zone.
|
||||
*
|
||||
* A level is resolved for the RECORD, like every AI capability: a background
|
||||
* job runs per record, and a delegate or guardian inside somebody else's
|
||||
* record sees that record's level. Only the account itself can change it
|
||||
* (`PUT /api/auth/me/ai-usage`); a managed profile has no choice of its own
|
||||
* and follows the instance default.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
AI_USAGE_LEVEL_EFFECTS,
|
||||
AI_USAGE_MEASURE_DAYS,
|
||||
DEFAULT_AI_USAGE_DEFAULT_LEVEL,
|
||||
DEFAULT_AI_USAGE_MAX_LEVEL,
|
||||
DEFAULT_SYNC_REGEN_GAP_HOURS,
|
||||
buildAiUsageState,
|
||||
parseSyncRegenGapHours,
|
||||
resolveAiUsageLevel,
|
||||
usageLevelFromDb,
|
||||
type AiUsageLevel,
|
||||
type AiUsageResolution,
|
||||
type AiUsageState,
|
||||
} from "@/lib/ai/usage-level";
|
||||
|
||||
/** The operator's side of the usage levels. */
|
||||
export interface AiUsageSettings {
|
||||
maxLevel: AiUsageLevel;
|
||||
defaultLevel: AiUsageLevel;
|
||||
/** The stored sync gap (1..48), `null` when the operator set none. */
|
||||
storedSyncRegenGapHours: number | null;
|
||||
/** The sync gap in effect: the stored one, else the environment, else six. */
|
||||
syncRegenGapHours: number;
|
||||
}
|
||||
|
||||
/** The environment's sync gap, else six. */
|
||||
export function envSyncRegenGapHours(): number {
|
||||
return (
|
||||
parseSyncRegenGapHours(process.env.AI_SYNC_REGEN_GAP_HOURS) ??
|
||||
DEFAULT_SYNC_REGEN_GAP_HOURS
|
||||
);
|
||||
}
|
||||
|
||||
/** Pure: the operator's settings from the raw row (or its absence). */
|
||||
export function aiUsageSettingsFromRow(
|
||||
row: {
|
||||
aiUsageMaxLevel?: string | null;
|
||||
aiUsageDefaultLevel?: string | null;
|
||||
aiSyncRegenGapHours?: number | null;
|
||||
} | null,
|
||||
): AiUsageSettings {
|
||||
const stored = parseSyncRegenGapHours(row?.aiSyncRegenGapHours);
|
||||
return {
|
||||
maxLevel:
|
||||
usageLevelFromDb(row?.aiUsageMaxLevel) ?? DEFAULT_AI_USAGE_MAX_LEVEL,
|
||||
defaultLevel:
|
||||
usageLevelFromDb(row?.aiUsageDefaultLevel) ??
|
||||
DEFAULT_AI_USAGE_DEFAULT_LEVEL,
|
||||
storedSyncRegenGapHours: stored,
|
||||
syncRegenGapHours: stored ?? envSyncRegenGapHours(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator's bounds and sync gap. An unreadable row reads as the
|
||||
* instance defaults (`standard` / `standard`, the environment's gap): the
|
||||
* levels bound spend, and the default is what an untouched instance runs at.
|
||||
*/
|
||||
export function loadAiUsageSettings(): Promise<AiUsageSettings> {
|
||||
const read = async (): Promise<AiUsageSettings> => {
|
||||
try {
|
||||
const row = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
aiUsageMaxLevel: true,
|
||||
aiUsageDefaultLevel: true,
|
||||
aiSyncRegenGapHours: true,
|
||||
},
|
||||
});
|
||||
return aiUsageSettingsFromRow(row);
|
||||
} catch {
|
||||
getEvent()?.addWarning(
|
||||
"Failed to load the AI usage settings; the instance defaults apply",
|
||||
);
|
||||
return aiUsageSettingsFromRow(null);
|
||||
}
|
||||
};
|
||||
try {
|
||||
return memoizePerRequest("ai-usage-settings", read, {
|
||||
freshInBackground: true,
|
||||
});
|
||||
} catch {
|
||||
return read();
|
||||
}
|
||||
}
|
||||
|
||||
/** One record's level: its stored choice against the operator's bounds. */
|
||||
export interface RecordAiUsage extends AiUsageResolution {
|
||||
preference: AiUsageLevel | null;
|
||||
settings: AiUsageSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The level in effect for one record. A missing row (an account deleted
|
||||
* under a running job) reads as no choice, so the default applies.
|
||||
*/
|
||||
export function loadRecordAiUsage(recordId: string): Promise<RecordAiUsage> {
|
||||
const read = async (): Promise<RecordAiUsage> => {
|
||||
const [settings, row] = await Promise.all([
|
||||
loadAiUsageSettings(),
|
||||
prisma.user.findUnique({
|
||||
where: { id: recordId },
|
||||
select: { aiUsageLevel: true },
|
||||
}),
|
||||
]);
|
||||
const preference = usageLevelFromDb(row?.aiUsageLevel);
|
||||
return {
|
||||
...resolveAiUsageLevel({
|
||||
preference,
|
||||
defaultLevel: settings.defaultLevel,
|
||||
maxLevel: settings.maxLevel,
|
||||
}),
|
||||
preference,
|
||||
settings,
|
||||
};
|
||||
};
|
||||
try {
|
||||
return memoizePerRequest(`ai-usage:${recordId}`, read, {
|
||||
freshInBackground: true,
|
||||
});
|
||||
} catch {
|
||||
return read();
|
||||
}
|
||||
}
|
||||
|
||||
/** Just the effective level, for a job or a gate. */
|
||||
export async function loadRecordUsageLevel(
|
||||
recordId: string,
|
||||
): Promise<AiUsageLevel> {
|
||||
return (await loadRecordAiUsage(recordId)).level;
|
||||
}
|
||||
|
||||
/**
|
||||
* The status snapshot's char cap for this record's level. An unreadable level
|
||||
* reads as the instance default's cap, never as an uncapped snapshot.
|
||||
*/
|
||||
export async function statusSnapshotCharsForRecord(
|
||||
recordId: string,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const level = await loadRecordUsageLevel(recordId);
|
||||
return AI_USAGE_LEVEL_EFFECTS[level].statusSnapshotChars;
|
||||
} catch {
|
||||
return AI_USAGE_LEVEL_EFFECTS[DEFAULT_AI_USAGE_DEFAULT_LEVEL]
|
||||
.statusSnapshotChars;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The record's measured AI use: the mean of its `coach_usage` totals over the
|
||||
* last seven full days in its own time zone (today is still running and does
|
||||
* not count). `coach_usage` counts the Coach and the background work
|
||||
* together. A day without a row had no AI use recorded and is not counted, so
|
||||
* `days` says how many days stand behind the mean.
|
||||
*/
|
||||
export async function loadMeasuredAiUsage(
|
||||
recordId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<{ tokensPerDay: number | null; days: number }> {
|
||||
const today = userDayKey(now, await resolveUserTimezone(recordId));
|
||||
const keys = Array.from({ length: AI_USAGE_MEASURE_DAYS }, (_, i) =>
|
||||
shiftDateKey(today, -(i + 1)),
|
||||
);
|
||||
const rows = await prisma.coachUsage.findMany({
|
||||
where: { userId: recordId, dateKey: { in: keys } },
|
||||
select: { totalTokens: true },
|
||||
});
|
||||
if (rows.length === 0) return { tokensPerDay: null, days: 0 };
|
||||
const total = rows.reduce((sum, row) => sum + row.totalTokens, 0);
|
||||
return { tokensPerDay: Math.round(total / rows.length), days: rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* `ai.usage` for one record. `withMeasurement: false` publishes the levels
|
||||
* without the measured use or the estimates: a delegate sees which level the
|
||||
* record runs at, never how much the owner spends.
|
||||
*/
|
||||
export async function loadAiUsageState(
|
||||
recordId: string,
|
||||
options: { withMeasurement: boolean },
|
||||
): Promise<AiUsageState> {
|
||||
const [usage, measured] = await Promise.all([
|
||||
loadRecordAiUsage(recordId),
|
||||
options.withMeasurement
|
||||
? loadMeasuredAiUsage(recordId).catch(() => ({
|
||||
tokensPerDay: null,
|
||||
days: 0,
|
||||
}))
|
||||
: Promise.resolve({ tokensPerDay: null, days: 0 }),
|
||||
]);
|
||||
return buildAiUsageState({
|
||||
preference: usage.preference,
|
||||
defaultLevel: usage.settings.defaultLevel,
|
||||
maxLevel: usage.settings.maxLevel,
|
||||
measuredTokensPerDay: measured.tokensPerDay,
|
||||
measuredDays: measured.days,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* v1.43 (#1217) — AI usage in three levels.
|
||||
*
|
||||
* One setting per account instead of many knobs. The operator sets the highest
|
||||
* level an account may choose (`maxLevel`) and the level an account without a
|
||||
* choice runs at (`defaultLevel`), since the operator pays for the shared key;
|
||||
* each person picks within that. A level decides how much background model
|
||||
* work the account asks for. It never decides what data is stored or shown,
|
||||
* and the interactive Coach is the same at every level (its own budget caps
|
||||
* apply as before).
|
||||
*
|
||||
* This file is the vocabulary: the levels, what each one does, the resolver
|
||||
* and the estimate. Pure, no I/O, so the account payload, the jobs, the
|
||||
* routes, the admin readout and the tests all read the same table. The reads
|
||||
* live in `./capabilities/usage.ts`.
|
||||
*
|
||||
* Wire spelling is lower case (`frugal` / `standard` / `full`); the database
|
||||
* enum (`AiUsageLevel`) is upper case. {@link AI_USAGE_LEVELS} is the ceiling
|
||||
* order: a level is allowed when it is at or below `maxLevel`.
|
||||
*/
|
||||
import {
|
||||
AI_USAGE_LEVELS,
|
||||
type AiUsageLevelWire,
|
||||
} from "@/lib/validations/ai-usage";
|
||||
|
||||
export { AI_USAGE_LEVELS };
|
||||
export type AiUsageLevel = AiUsageLevelWire;
|
||||
|
||||
/**
|
||||
* Where the effective level comes from:
|
||||
*
|
||||
* - `user`: the account's own stored choice;
|
||||
* - `default`: no stored choice, so the operator's default;
|
||||
* - `admin_cap`: a stored choice above the operator's ceiling, capped at
|
||||
* read time. The choice itself is kept, so raising the ceiling again
|
||||
* restores it.
|
||||
*/
|
||||
export const AI_USAGE_SOURCES = ["user", "default", "admin_cap"] as const;
|
||||
export type AiUsageSource = (typeof AI_USAGE_SOURCES)[number];
|
||||
|
||||
/** What one level does. Every effect a level has is a row here. */
|
||||
export interface AiUsageLevelEffect {
|
||||
/**
|
||||
* Minimum hours between two status-card regenerations of one scope after a
|
||||
* sync (a watch, an integration). `"instance"` reads the operator's sync
|
||||
* gap (`AppSettings.aiSyncRegenGapHours`, else `AI_SYNC_REGEN_GAP_HOURS`,
|
||||
* else six).
|
||||
*/
|
||||
syncRegenGapHours: number | "instance";
|
||||
/** The same gap after a hand entry. */
|
||||
manualRegenGapHours: number;
|
||||
/**
|
||||
* The status snapshot's char cap, measured on the compact JSON the prompt
|
||||
* ships (`degradeStatusSnapshotToBudget`). Fourteen thousand holds every
|
||||
* snapshot that fitted the old 24 000 pretty-printed characters.
|
||||
*/
|
||||
statusSnapshotChars: number;
|
||||
/**
|
||||
* Whether the background jobs that may reason (the briefing, the period
|
||||
* narratives, the Coach memory) ask for it. When true the job's own level
|
||||
* applies (`BACKGROUND_REASONING_LEVEL`), still capped by the operator.
|
||||
*/
|
||||
backgroundReasoning: boolean;
|
||||
/**
|
||||
* The daily briefing's form. `short`: the lead plus today, at most one
|
||||
* recommendation; every field of the contract stays present, so a client
|
||||
* decodes it unchanged.
|
||||
*/
|
||||
briefing: "full" | "short";
|
||||
/**
|
||||
* Whether the account's own "read documents automatically" setting may
|
||||
* act. When false, documents are read on request only.
|
||||
*/
|
||||
documentsAutoRead: boolean;
|
||||
/**
|
||||
* Relative token use, `full` = 1. Scales the measured use of the effective
|
||||
* level into an estimate for another level. Calibrated with the generation
|
||||
* simulation of a watch-synced account
|
||||
* (`src/lib/insights/__tests__/usage-level-simulation.test.ts`): status-card
|
||||
* generations a day, the nightly warm included, are 141 on `full`, 44 on
|
||||
* `standard` (0.31) and 21 on `frugal` (0.15, before its shorter briefing
|
||||
* and switched-off reasoning). The Coach is part of the measured use and
|
||||
* does not scale, so the result is published and shown as an estimate.
|
||||
*/
|
||||
tokenFactor: number;
|
||||
}
|
||||
|
||||
export const AI_USAGE_LEVEL_EFFECTS: Readonly<
|
||||
Record<AiUsageLevel, Readonly<AiUsageLevelEffect>>
|
||||
> = Object.freeze({
|
||||
frugal: {
|
||||
syncRegenGapHours: 24,
|
||||
manualRegenGapHours: 6,
|
||||
statusSnapshotChars: 10_000,
|
||||
backgroundReasoning: false,
|
||||
briefing: "short",
|
||||
documentsAutoRead: false,
|
||||
tokenFactor: 0.15,
|
||||
},
|
||||
standard: {
|
||||
syncRegenGapHours: "instance",
|
||||
manualRegenGapHours: 1,
|
||||
statusSnapshotChars: 14_000,
|
||||
backgroundReasoning: true,
|
||||
briefing: "full",
|
||||
documentsAutoRead: true,
|
||||
tokenFactor: 0.3,
|
||||
},
|
||||
full: {
|
||||
syncRegenGapHours: 1,
|
||||
manualRegenGapHours: 1,
|
||||
statusSnapshotChars: 14_000,
|
||||
backgroundReasoning: true,
|
||||
briefing: "full",
|
||||
documentsAutoRead: true,
|
||||
tokenFactor: 1,
|
||||
},
|
||||
});
|
||||
|
||||
/** The instance's own defaults, before an operator changes anything. */
|
||||
export const DEFAULT_AI_USAGE_MAX_LEVEL: AiUsageLevel = "standard";
|
||||
export const DEFAULT_AI_USAGE_DEFAULT_LEVEL: AiUsageLevel = "standard";
|
||||
|
||||
// ── Order ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function usageLevelRank(level: AiUsageLevel): number {
|
||||
return AI_USAGE_LEVELS.indexOf(level);
|
||||
}
|
||||
|
||||
/** Whether `level` is at or above `min`. */
|
||||
export function usageLevelAtLeast(
|
||||
level: AiUsageLevel,
|
||||
min: AiUsageLevel,
|
||||
): boolean {
|
||||
return usageLevelRank(level) >= usageLevelRank(min);
|
||||
}
|
||||
|
||||
/** `level`, lowered to `max` when it lies above it. */
|
||||
export function capUsageLevel(
|
||||
level: AiUsageLevel,
|
||||
max: AiUsageLevel,
|
||||
): AiUsageLevel {
|
||||
return usageLevelRank(level) > usageLevelRank(max) ? max : level;
|
||||
}
|
||||
|
||||
export function isAiUsageLevel(value: unknown): value is AiUsageLevel {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(AI_USAGE_LEVELS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Database spelling ───────────────────────────────────────────────────────
|
||||
|
||||
export type AiUsageLevelDb = "FRUGAL" | "STANDARD" | "FULL";
|
||||
|
||||
export function usageLevelFromDb(
|
||||
value: string | null | undefined,
|
||||
): AiUsageLevel | null {
|
||||
if (value == null) return null;
|
||||
const lower = value.toLowerCase();
|
||||
return isAiUsageLevel(lower) ? lower : null;
|
||||
}
|
||||
|
||||
export function usageLevelToDb(level: AiUsageLevel): AiUsageLevelDb {
|
||||
return level.toUpperCase() as AiUsageLevelDb;
|
||||
}
|
||||
|
||||
// ── Resolution ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AiUsageBounds {
|
||||
/** The account's stored choice; `null` follows the default. */
|
||||
preference: AiUsageLevel | null;
|
||||
defaultLevel: AiUsageLevel;
|
||||
maxLevel: AiUsageLevel;
|
||||
}
|
||||
|
||||
export interface AiUsageResolution {
|
||||
level: AiUsageLevel;
|
||||
source: AiUsageSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* The level in effect. A stored choice at or below the ceiling wins; above it
|
||||
* the ceiling applies and the choice is kept (`admin_cap`). Without a choice
|
||||
* the default applies, held to the ceiling too, so a default stored above
|
||||
* the ceiling (the admin route refuses one, an old row might hold one) can
|
||||
* never lift an account over it.
|
||||
*/
|
||||
export function resolveAiUsageLevel(bounds: AiUsageBounds): AiUsageResolution {
|
||||
const { preference, defaultLevel, maxLevel } = bounds;
|
||||
if (preference === null) {
|
||||
return { level: capUsageLevel(defaultLevel, maxLevel), source: "default" };
|
||||
}
|
||||
if (usageLevelRank(preference) > usageLevelRank(maxLevel)) {
|
||||
return { level: maxLevel, source: "admin_cap" };
|
||||
}
|
||||
return { level: preference, source: "user" };
|
||||
}
|
||||
|
||||
// ── Effects ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Where a batch of fresh measurements came from (`status-invalidation.ts`). */
|
||||
export type IngestSource = "manual" | "sync";
|
||||
|
||||
/** Default sync gap when neither the operator nor the environment sets one. */
|
||||
export const DEFAULT_SYNC_REGEN_GAP_HOURS = 6;
|
||||
export const SYNC_REGEN_GAP_HOURS_MIN = 1;
|
||||
export const SYNC_REGEN_GAP_HOURS_MAX = 48;
|
||||
|
||||
/** A sync gap in whole hours, or `null` when the value is not a usable one. */
|
||||
export function parseSyncRegenGapHours(value: unknown): number | null {
|
||||
const n =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && value.trim() !== ""
|
||||
? Number(value)
|
||||
: NaN;
|
||||
if (!Number.isInteger(n)) return null;
|
||||
if (n < SYNC_REGEN_GAP_HOURS_MIN || n > SYNC_REGEN_GAP_HOURS_MAX) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hours a scope waits after its last regeneration before an ingest of this
|
||||
* source may regenerate it again.
|
||||
*
|
||||
* @param instanceSyncGapHours The operator's resolved sync gap, read by
|
||||
* `standard`.
|
||||
*/
|
||||
export function statusRegenGapHours(
|
||||
level: AiUsageLevel,
|
||||
source: IngestSource,
|
||||
instanceSyncGapHours: number,
|
||||
): number {
|
||||
const effect = AI_USAGE_LEVEL_EFFECTS[level];
|
||||
if (source === "manual") return effect.manualRegenGapHours;
|
||||
return effect.syncRegenGapHours === "instance"
|
||||
? instanceSyncGapHours
|
||||
: effect.syncRegenGapHours;
|
||||
}
|
||||
|
||||
// ── Estimate ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Full days behind the measured mean, at most. */
|
||||
export const AI_USAGE_MEASURE_DAYS = 7;
|
||||
/** Days of history an estimate needs. */
|
||||
export const AI_USAGE_ESTIMATE_MIN_DAYS = 3;
|
||||
|
||||
/**
|
||||
* Tokens per day at `target`, scaled from what the account used at `from`.
|
||||
* `null` without enough history.
|
||||
*/
|
||||
export function estimateTokensPerDay(args: {
|
||||
measuredTokensPerDay: number | null;
|
||||
measuredDays: number;
|
||||
from: AiUsageLevel;
|
||||
target: AiUsageLevel;
|
||||
}): number | null {
|
||||
if (args.measuredTokensPerDay === null) return null;
|
||||
if (args.measuredDays < AI_USAGE_ESTIMATE_MIN_DAYS) return null;
|
||||
const ratio =
|
||||
AI_USAGE_LEVEL_EFFECTS[args.target].tokenFactor /
|
||||
AI_USAGE_LEVEL_EFFECTS[args.from].tokenFactor;
|
||||
return Math.round(args.measuredTokensPerDay * ratio);
|
||||
}
|
||||
|
||||
/** `ai.usage` on `GET /api/auth/me` (OpenAPI `AiUsageState`). */
|
||||
export interface AiUsageState {
|
||||
level: AiUsageLevel;
|
||||
preference: AiUsageLevel | null;
|
||||
defaultLevel: AiUsageLevel;
|
||||
maxLevel: AiUsageLevel;
|
||||
source: AiUsageSource;
|
||||
options: Array<{
|
||||
level: AiUsageLevel;
|
||||
allowed: boolean;
|
||||
estimatedTokensPerDay: number | null;
|
||||
}>;
|
||||
measuredTokensPerDay: number | null;
|
||||
measuredDays: number;
|
||||
}
|
||||
|
||||
/** The whole published state, from the bounds and the measured use. */
|
||||
export function buildAiUsageState(
|
||||
args: AiUsageBounds & {
|
||||
measuredTokensPerDay: number | null;
|
||||
measuredDays: number;
|
||||
},
|
||||
): AiUsageState {
|
||||
const { level, source } = resolveAiUsageLevel(args);
|
||||
return {
|
||||
level,
|
||||
preference: args.preference,
|
||||
defaultLevel: args.defaultLevel,
|
||||
maxLevel: args.maxLevel,
|
||||
source,
|
||||
options: AI_USAGE_LEVELS.map((option) => ({
|
||||
level: option,
|
||||
allowed: usageLevelRank(option) <= usageLevelRank(args.maxLevel),
|
||||
estimatedTokensPerDay: estimateTokensPerDay({
|
||||
measuredTokensPerDay: args.measuredTokensPerDay,
|
||||
measuredDays: args.measuredDays,
|
||||
from: level,
|
||||
target: option,
|
||||
}),
|
||||
})),
|
||||
measuredTokensPerDay: args.measuredTokensPerDay,
|
||||
measuredDays: args.measuredDays,
|
||||
};
|
||||
}
|
||||
@@ -180,6 +180,9 @@ const fakeDb = vi.hoisted(() => {
|
||||
encounter: { findMany: none },
|
||||
// The AI capability loader's consent read: no receipts.
|
||||
consentReceipt: { findMany: none },
|
||||
// v1.43 — the loader's usage-level read: no stored choice, so the
|
||||
// instance default applies.
|
||||
user: { findUnique: async () => ({ aiUsageLevel: null }) },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -43,7 +43,10 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"ai.provider.none",
|
||||
"ai.record.notPermitted",
|
||||
"ai.unavailable",
|
||||
"ai.usage.aboveCeiling",
|
||||
"ai.usage.defaultAboveCeiling",
|
||||
"ai.usage.invalid",
|
||||
"ai.usage.levelTooLow",
|
||||
"ai.usage.rateLimited",
|
||||
],
|
||||
ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"],
|
||||
|
||||
@@ -11,12 +11,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { AI_USAGE_LEVELS, aiUsagePutSchema } from "@/lib/validations/ai-usage";
|
||||
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
export const aiUsageLevel = z.enum(AI_USAGE_LEVELS).meta({
|
||||
id: "AiUsageLevel",
|
||||
@@ -111,7 +106,6 @@ export const aiUsagePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -129,7 +129,7 @@ export const moduleAccessMap = z
|
||||
export const aiUnavailableReason = z.enum(AI_UNAVAILABLE_REASONS).meta({
|
||||
id: "AiUnavailableReason",
|
||||
description:
|
||||
"Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is reported, because it names the layer that would have to change first. `check_failed` — an input could not be read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does not know treats the capability as unavailable.",
|
||||
"Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is reported, because it names the layer that would have to change first. `check_failed` — an input could not be read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. `usage_level` — the record's AI usage level (`ai.usage.level`, v1.43) is below what the capability needs; today only `supplementLabel` on `frugal`. `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does not know treats the capability as unavailable.",
|
||||
});
|
||||
|
||||
export const aiCapabilityState = z
|
||||
@@ -141,7 +141,7 @@ export const aiCapabilityState = z
|
||||
onDeviceAllowed: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, `module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for server egress do not concern a model that runs on the device. Resolved here so the client never decides which reasons apply off the server.",
|
||||
"Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, `module_disabled`, `user_disabled`, `usage_level` and `check_failed`: the operator's and the person's decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for server egress do not concern a model that runs on the device. Resolved here so the client never decides which reasons apply off the server.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
@@ -164,7 +164,7 @@ export const aiCapabilities = z
|
||||
.meta({
|
||||
id: "AiCapabilities",
|
||||
description:
|
||||
"Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule.",
|
||||
"Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule. `supplementLabel` (v1.43) — reading a supplement's label photo into its ingredients; unavailable on the `frugal` usage level.",
|
||||
});
|
||||
|
||||
export const aiProviderState = z
|
||||
|
||||
@@ -91,13 +91,13 @@ export const errorEnvelope = z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`).",
|
||||
"On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`, `supplementLabel`).",
|
||||
),
|
||||
reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.<switch>` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own typed code where it has one, otherwise `ai.provider.none` (422), `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` (503).",
|
||||
"On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.<switch>` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own typed code where it has one, otherwise `ai.provider.none` (422), `usage_level` → `ai.usage.levelTooLow` with `requiredLevel`, `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` (503).",
|
||||
),
|
||||
module: z
|
||||
.string()
|
||||
@@ -105,6 +105,12 @@ export const errorEnvelope = z
|
||||
.describe(
|
||||
"Beside `module.disabled`: the module that is off. On an AI refusal it is the capability's owning module.",
|
||||
),
|
||||
requiredLevel: z
|
||||
.enum(["frugal", "standard", "full"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Beside `ai.usage.levelTooLow` (v1.43): the lowest AI usage level at which the capability is offered.",
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user