Files
HealthLog/src/lib/ai/capabilities/refusal.ts
T
Marc-André Bombeck 6f673a7b84 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
2026-10-15 22:49:01 +02:00

147 lines
4.5 KiB
TypeScript

/**
* The refusal an AI action answers with when its capability is unavailable.
*
* Kept apart from the gates so `apiHandler` can render the envelope without
* importing the loader and the provider resolution behind it. The mapping
* keeps every code a shipped client already branches on; see
* `./gate.ts` for the table.
*/
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";
/** How a route refuses `no_provider` when it already has a typed code. */
export interface NoProviderRefusal {
errorCode: string;
status?: number;
}
/** The consent code, shared with `ConsentRequiredError`. */
const CONSENT_REQUIRED_CODE = "consent.ai.required";
/** What the envelope carries in `meta`. */
export interface AiRefusalMeta {
errorCode: string;
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. */
export function aiRefusal(
capability: AiCapabilityKey,
reason: AiUnavailableReason,
module: ModuleKey | null = null,
noProvider?: NoProviderRefusal,
): { status: number; meta: AiRefusalMeta } {
const base = { capability, reason };
switch (reason) {
case "operator_disabled":
return module === null
? {
status: 403,
meta: {
...base,
errorCode: `assistant.disabled.${AI_CAPABILITIES[capability].operatorSwitch}`,
},
}
: {
status: 403,
meta: { ...base, errorCode: MODULE_DISABLED_ERROR_CODE, module },
};
case "not_permitted_for_record":
return {
status: 403,
meta: { ...base, errorCode: AI_RECORD_NOT_PERMITTED_ERROR_CODE },
};
case "module_disabled":
case "user_disabled":
return {
status: 403,
meta: {
...base,
errorCode: MODULE_DISABLED_ERROR_CODE,
...(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,
meta: {
...base,
errorCode: noProvider?.errorCode ?? AI_PROVIDER_NONE_ERROR_CODE,
},
};
case "consent_required":
return {
status: 403,
meta: { ...base, errorCode: CONSENT_REQUIRED_CODE },
};
case "check_failed":
return {
status: 503,
meta: { ...base, errorCode: AI_UNAVAILABLE_ERROR_CODE },
};
}
}
const REFUSAL_MESSAGE: Record<AiUnavailableReason, string> = {
check_failed: "AI is unavailable right now",
operator_disabled: "This AI feature is turned off on this server",
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",
};
/**
* Thrown by `requireAiCapability`; `apiHandler` renders it as
* `{ data: null, error, meta }` with its status.
*/
export class AiUnavailableError extends Error {
readonly capability: AiCapabilityKey;
readonly reason: AiUnavailableReason;
readonly status: number;
readonly meta: AiRefusalMeta;
constructor(
capability: AiCapabilityKey,
reason: AiUnavailableReason,
module: ModuleKey | null = null,
noProvider?: NoProviderRefusal,
) {
super(REFUSAL_MESSAGE[reason]);
this.name = "AiUnavailableError";
this.capability = capability;
this.reason = reason;
const refusal = aiRefusal(capability, reason, module, noProvider);
this.status = refusal.status;
this.meta = refusal.meta;
}
}