diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 43407e243..ecba71d5f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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.` (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: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6328b220d..5bf61297a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts index 3b06191b0..7f411cc63 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -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 = { + 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); + }, + ); +}); diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index 102729345..361a47866 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -39,7 +39,6 @@ const CONTRACT_STUBS: Readonly> = { ], "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> = { }; /** 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"] }) diff --git a/src/app/api/__tests__/ai-capability-route-inventory.test.ts b/src/app/api/__tests__/ai-capability-route-inventory.test.ts index 2bb7afd8c..7e53af027 100644 --- a/src/app/api/__tests__/ai-capability-route-inventory.test.ts +++ b/src/app/api/__tests__/ai-capability-route-inventory.test.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> +> = { + 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 = { DOCUMENT_INDEX: "src/app/api/documents/inbound/[id]/index/route.ts", diff --git a/src/app/api/auth/me/ai-usage/__tests__/route.test.ts b/src/app/api/auth/me/ai-usage/__tests__/route.test.ts new file mode 100644 index 000000000..71eb1b3af --- /dev/null +++ b/src/app/api/auth/me/ai-usage/__tests__/route.test.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, + 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; + +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); + }); +}); diff --git a/src/app/api/auth/me/ai-usage/route.ts b/src/app/api/auth/me/ai-usage/route.ts index 1ceeb2d7d..7d5fd3d57 100644 --- a/src/app/api/auth/me/ai-usage/route.ts +++ b/src/app/api/auth/me/ai-usage/route.ts @@ -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 })); }); diff --git a/src/hooks/use-ai-capability.ts b/src/hooks/use-ai-capability.ts index 156115a4f..497c27e3d 100644 --- a/src/hooks/use-ai-capability.ts +++ b/src/hooks/use-ai-capability.ts @@ -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 diff --git a/src/lib/__tests__/ai-refusal-envelope.test.ts b/src/lib/__tests__/ai-refusal-envelope.test.ts index a21e8f390..d363ede4f 100644 --- a/src/lib/__tests__/ai-refusal-envelope.test.ts +++ b/src/lib/__tests__/ai-refusal-envelope.test.ts @@ -47,6 +47,7 @@ const CONTRACT: Record = { 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", diff --git a/src/lib/ai/__tests__/usage-level.test.ts b/src/lib/ai/__tests__/usage-level.test.ts new file mode 100644 index 000000000..7b06d9c0c --- /dev/null +++ b/src/lib/ai/__tests__/usage-level.test.ts @@ -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 = { frugal: 0, standard: 1, full: 2 }; + +describe("resolveAiUsageLevel — every choice x default x ceiling", () => { + const choices: Array = [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); + }); +}); diff --git a/src/lib/ai/capabilities/__tests__/resolve.test.ts b/src/lib/ai/capabilities/__tests__/resolve.test.ts index e631014bd..5a250991d 100644 --- a/src/lib/ai/capabilities/__tests__/resolve.test.ts +++ b/src/lib/ai/capabilities/__tests__/resolve.test.ts @@ -58,6 +58,7 @@ function baseline(): AiCapabilityInputs { }, activeConsentKinds: new Set(), recordKind: "self", + usageLevel: "full", }; } @@ -103,6 +104,11 @@ const PERTURB: Record, 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", + ); + }); +}); diff --git a/src/lib/ai/capabilities/load.ts b/src/lib/ai/capabilities/load.ts index 9e43dc842..e277667ea 100644 --- a/src/lib/ai/capabilities/load.ts +++ b/src/lib/ai/capabilities/load.ts @@ -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 { - 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; } diff --git a/src/lib/ai/capabilities/refusal.ts b/src/lib/ai/capabilities/refusal.ts index 0f6a44639..01c21d44f 100644 --- a/src/lib/ai/capabilities/refusal.ts +++ b/src/lib/ai/capabilities/refusal.ts @@ -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 = { 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", }; diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index 0152a7ac7..93a6f2341 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -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; 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), ]); diff --git a/src/lib/ai/capabilities/types.ts b/src/lib/ai/capabilities/types.ts index 1dc6274df..1acb92931 100644 --- a/src/lib/ai/capabilities/types.ts +++ b/src/lib/ai/capabilities/types.ts @@ -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; 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"; diff --git a/src/lib/ai/capabilities/usage.ts b/src/lib/ai/capabilities/usage.ts new file mode 100644 index 000000000..e3eda8f27 --- /dev/null +++ b/src/lib/ai/capabilities/usage.ts @@ -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 { + const read = async (): Promise => { + 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 { + const read = async (): Promise => { + 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 { + 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 { + 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 { + 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, + }); +} diff --git a/src/lib/ai/usage-level.ts b/src/lib/ai/usage-level.ts new file mode 100644 index 000000000..e81673b15 --- /dev/null +++ b/src/lib/ai/usage-level.ts @@ -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> +> = 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, + }; +} diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index d2af8a35f..2d46fa5c1 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -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 }) }, }; }); diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index b2acc7860..0ea2d9db3 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -43,7 +43,10 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "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"], diff --git a/src/lib/openapi/routes/ai-usage.ts b/src/lib/openapi/routes/ai-usage.ts index 58f7f7504..1711b4267 100644 --- a/src/lib/openapi/routes/ai-usage.ts +++ b/src/lib/openapi/routes/ai-usage.ts @@ -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 = { "`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling).", content: { "application/json": { schema: errorEnvelope } }, }, - ...notImplementedResponse, }, }, }, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 79e693cec..de83b2e61 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -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 diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index f2d059c20..b74579208 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -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.` (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.` (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(), })