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:
Marc-André Bombeck committed 2026-10-15 22:49:01 +02:00
1 parent e573f28ae8
commit 6f673a7b84
22 files changed
+1342 -84

No files matched your search

+63 -47
View File
@@ -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: