diff --git a/src/lib/ai/capabilities/__tests__/resolve.test.ts b/src/lib/ai/capabilities/__tests__/resolve.test.ts new file mode 100644 index 000000000..d6f1dba90 --- /dev/null +++ b/src/lib/ai/capabilities/__tests__/resolve.test.ts @@ -0,0 +1,478 @@ +/** + * The capability resolver, exhaustively. + * + * Three tables. Each reason is produced on its own for every capability it can + * apply to. Every ordered pair of reasons applied together reports the outer + * one, which is the precedence contract clients are promised. And the cases + * where the layers interact (provider order, document picks, consent kinds, + * the "either module" capability) are pinned one by one. + * + * Mutation check: swapping two entries of `AI_UNAVAILABLE_REASONS` turns the + * pair table red, naming the pair; dropping a layer from `resolveAiCapability` + * turns its single-reason row red. + */ +import { describe, expect, it } from "vitest"; + +import { + resolveAiBlock, + resolveAiCapabilities, + resolveAiCapability, + resolveAiProviderState, + type AiCapabilityInputs, + type ProviderEntryPresence, +} from "../resolve"; +import { + AI_CAPABILITIES, + AI_CAPABILITY_KEYS, + AI_OPT_OUT_MODULES, + AI_OPERATOR_SWITCHES, + AI_UNAVAILABLE_REASONS, + type AiCapabilityKey, + type AiUnavailableReason, +} from "../types"; +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; +import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; + +function allEnabled(): Record { + const out = {} as Record; + for (const key of MODULE_KEYS) out[key] = "enabled"; + return out; +} + +/** Everything on, a local vision model, no receipts: every capability is available. */ +function baseline(): AiCapabilityInputs { + return { + switches: { + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + documentAi: true, + }, + moduleAccess: allEnabled(), + providerWorkAdmitted: true, + provider: { + entries: [{ providerType: "local", vision: true }], + localOcrEnabled: false, + managedBy: "local", + }, + activeConsentKinds: new Set(), + recordKind: "self", + }; +} + +type Perturb = (inputs: AiCapabilityInputs, key: AiCapabilityKey) => boolean; + +/** + * One way to produce each reason (bar `check_failed`, which is null inputs). + * Returns false when the reason cannot apply to the capability, for example a + * module reason on a capability no module owns. + */ +const PERTURB: Record, Perturb> = { + operator_disabled: (inputs, key) => { + inputs.switches = { + ...inputs.switches, + [AI_CAPABILITIES[key].operatorSwitch]: false, + }; + return true; + }, + not_permitted_for_record: (inputs) => { + inputs.providerWorkAdmitted = false; + return true; + }, + module_disabled: (inputs, key) => { + const { keys } = AI_CAPABILITIES[key].modules; + if (keys.length === 0) return false; + const access = { ...inputs.moduleAccess }; + for (const moduleKey of keys) access[moduleKey] = "not_granted"; + inputs.moduleAccess = access; + return true; + }, + user_disabled: (inputs, key) => { + const { mode, keys } = AI_CAPABILITIES[key].modules; + const optOuts = keys.filter((moduleKey) => + AI_OPT_OUT_MODULES.has(moduleKey), + ); + if (optOuts.length === 0) return false; + // An "any" capability needs every path closed. + if (mode === "any" && optOuts.length !== keys.length) return false; + const access = { ...inputs.moduleAccess }; + for (const moduleKey of optOuts) { + if (access[moduleKey] === "enabled") access[moduleKey] = "disabled"; + } + inputs.moduleAccess = access; + return true; + }, + no_provider: (inputs) => { + inputs.provider = { ...inputs.provider, entries: [], managedBy: null }; + return true; + }, + consent_required: (inputs) => { + inputs.provider = { + ...inputs.provider, + entries: [{ providerType: "admin-openai", vision: true }], + managedBy: "server", + }; + inputs.activeConsentKinds = new Set(); + return true; + }, +}; + +const PERTURBABLE = AI_UNAVAILABLE_REASONS.filter( + (reason): reason is Exclude => + reason !== "check_failed", +); + +describe("baseline", () => { + it("makes every capability available", () => { + const resolved = resolveAiCapabilities(baseline()); + for (const key of AI_CAPABILITY_KEYS) { + expect(resolved[key], key).toEqual({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + } + }); + + it("publishes every capability key and nothing else", () => { + expect(Object.keys(resolveAiCapabilities(baseline())).sort()).toEqual( + [...AI_CAPABILITY_KEYS].sort(), + ); + }); +}); + +describe("each reason on its own", () => { + it("fails closed to check_failed when the inputs could not be loaded", () => { + const resolved = resolveAiCapabilities(null); + for (const key of AI_CAPABILITY_KEYS) { + expect(resolved[key]).toEqual({ + available: false, + reason: "check_failed", + onDeviceAllowed: false, + }); + } + }); + + for (const reason of PERTURBABLE) { + for (const key of AI_CAPABILITY_KEYS) { + const inputs = baseline(); + if (!PERTURB[reason](inputs, key)) continue; + it(`${key}: ${reason}`, () => { + expect(resolveAiCapability(key, inputs).reason).toBe(reason); + }); + } + } + + it("covers every reason for at least one capability", () => { + for (const reason of PERTURBABLE) { + const applies = AI_CAPABILITY_KEYS.some((key) => + PERTURB[reason](baseline(), key), + ); + expect(applies, reason).toBe(true); + } + }); +}); + +describe("every pair of reasons reports the outer one", () => { + let checked = 0; + for (let i = 0; i < PERTURBABLE.length; i++) { + for (let j = i + 1; j < PERTURBABLE.length; j++) { + const outer = PERTURBABLE[i]!; + const inner = PERTURBABLE[j]!; + for (const key of AI_CAPABILITY_KEYS) { + const inputs = baseline(); + // Inner first: the provider perturbations overwrite the same field, + // and the outer one has to be the one that sticks. + if (!PERTURB[inner](inputs, key)) continue; + if (!PERTURB[outer](inputs, key)) continue; + checked++; + it(`${key}: ${outer} over ${inner}`, () => { + expect(resolveAiCapability(key, inputs).reason).toBe(outer); + }); + } + } + } + + it("ran a non-trivial pair table", () => { + expect(checked).toBeGreaterThan(100); + }); +}); + +describe("the operator layer", () => { + it("master off forces every capability off, whatever else is wrong", () => { + const inputs = baseline(); + inputs.switches = { ...inputs.switches, enabled: false }; + inputs.providerWorkAdmitted = false; + inputs.provider = { ...inputs.provider, entries: [] }; + const resolved = resolveAiCapabilities(inputs); + for (const key of AI_CAPABILITY_KEYS) { + expect(resolved[key].reason, key).toBe("operator_disabled"); + } + }); + + it("a sub-switch closes exactly the capabilities it covers", () => { + for (const toggle of AI_OPERATOR_SWITCHES) { + const inputs = baseline(); + inputs.switches = { ...inputs.switches, [toggle]: false }; + const resolved = resolveAiCapabilities(inputs); + for (const key of AI_CAPABILITY_KEYS) { + const covered = AI_CAPABILITIES[key].operatorSwitch === toggle; + expect(resolved[key].available, `${toggle} → ${key}`).toBe(!covered); + } + } + }); + + it("reads operator module availability as an operator decision", () => { + const inputs = baseline(); + inputs.moduleAccess = { ...inputs.moduleAccess, insights: "unavailable" }; + expect(resolveAiCapability("briefing", inputs).reason).toBe( + "operator_disabled", + ); + }); +}); + +describe("the module layer", () => { + it("reads the record's own switch on an opt-out module as the person's choice", () => { + const inputs = baseline(); + inputs.moduleAccess = { ...inputs.moduleAccess, coach: "disabled" }; + expect(resolveAiCapability("coach", inputs).reason).toBe("user_disabled"); + }); + + it("reads the record's own switch on any other module as module_disabled", () => { + const inputs = baseline(); + inputs.moduleAccess = { ...inputs.moduleAccess, workouts: "disabled" }; + expect(resolveAiCapability("workoutInsights", inputs).reason).toBe( + "module_disabled", + ); + expect(resolveAiCapability("statusText", inputs).available).toBe(true); + }); + + it("closes workout notes when either of its two modules is off", () => { + const inputs = baseline(); + inputs.moduleAccess = { ...inputs.moduleAccess, insights: "disabled" }; + expect(resolveAiCapability("workoutInsights", inputs).reason).toBe( + "user_disabled", + ); + }); + + it("keeps the about-me questions while either consumer is on", () => { + const inputs = baseline(); + inputs.moduleAccess = { ...inputs.moduleAccess, coach: "disabled" }; + expect(resolveAiCapability("aboutMeQuestions", inputs).available).toBe( + true, + ); + inputs.moduleAccess = { + ...inputs.moduleAccess, + coach: "enabled", + insights: "disabled", + }; + expect(resolveAiCapability("aboutMeQuestions", inputs).available).toBe( + true, + ); + }); + + it("reports the reason nearest to available when both consumers are off", () => { + const inputs = baseline(); + inputs.moduleAccess = { + ...inputs.moduleAccess, + coach: "not_granted", + insights: "disabled", + }; + expect(resolveAiCapability("aboutMeQuestions", inputs).reason).toBe( + "user_disabled", + ); + }); + + it("gives medication extraction no owning module", () => { + const inputs = baseline(); + const access = { ...inputs.moduleAccess }; + for (const key of MODULE_KEYS) access[key] = "disabled"; + inputs.moduleAccess = access; + expect(resolveAiCapability("medicationExtract", inputs).available).toBe( + true, + ); + }); +}); + +describe("provider presence per modality", () => { + const openaiText: ProviderEntryPresence = { + providerType: "openai", + vision: false, + }; + + it("serves text from any entry and a document only from a vision entry", () => { + const inputs = baseline(); + inputs.provider = { ...inputs.provider, entries: [openaiText] }; + inputs.activeConsentKinds = new Set(["ai_full"]); + const resolved = resolveAiCapabilities(inputs); + expect(resolved.statusText.available).toBe(true); + expect(resolved.medicationExtract.available).toBe(true); + expect(resolved.documentAi.reason).toBe("no_provider"); + expect(resolved.labsOcr.reason).toBe("no_provider"); + }); + + it("lets a text provider read a document once in-browser OCR is on", () => { + const inputs = baseline(); + inputs.provider = { + ...inputs.provider, + entries: [openaiText], + localOcrEnabled: true, + }; + inputs.activeConsentKinds = new Set(["ai_extraction"]); + expect(resolveAiCapability("documentAi", inputs).available).toBe(true); + expect(resolveAiCapability("labsOcr", inputs).available).toBe(true); + }); +}); + +describe("the consent layer", () => { + it("needs no receipt for a person's own key on a self-snapshot capability", () => { + const inputs = baseline(); + inputs.provider = { + ...inputs.provider, + entries: [{ providerType: "anthropic", vision: true }], + }; + expect(resolveAiCapability("coach", inputs).available).toBe(true); + expect(resolveAiCapability("briefing", inputs).available).toBe(true); + }); + + it("needs a receipt once the operator's key is anywhere in the chain", () => { + const inputs = baseline(); + inputs.provider = { + ...inputs.provider, + entries: [ + { providerType: "anthropic", vision: true }, + { providerType: "admin-codex", vision: true }, + ], + }; + expect(resolveAiCapability("coach", inputs).reason).toBe( + "consent_required", + ); + }); + + it("matches kinds to groups, with ai_full covering all of them", () => { + const inputs = baseline(); + inputs.provider = { + ...inputs.provider, + entries: [{ providerType: "admin-openai", vision: true }], + }; + const cases: [string, AiCapabilityKey[], AiCapabilityKey[]][] = [ + [ + "ai_coach", + ["coach", "aboutMeQuestions"], + ["briefing", "statusText", "documentAi", "labsOcr"], + ], + [ + "ai_insights_only", + ["briefing", "periodNarrative", "statusText", "aboutMeQuestions"], + ["coach", "documentAi", "medicationExtract"], + ], + [ + "ai_extraction", + ["documentAi", "labsOcr", "medicationExtract"], + ["coach", "briefing", "statusText", "aboutMeQuestions"], + ], + ["ai_full", [...AI_CAPABILITY_KEYS], []], + ]; + for (const [kind, open, closed] of cases) { + inputs.activeConsentKinds = new Set([kind]); + const resolved = resolveAiCapabilities(inputs); + for (const key of open) { + expect(resolved[key].available, `${kind} opens ${key}`).toBe(true); + } + for (const key of closed) { + expect(resolved[key].reason, `${kind} leaves ${key}`).toBe( + "consent_required", + ); + } + } + }); + + it("needs a receipt for any external document read, the person's own key included", () => { + const inputs = baseline(); + inputs.provider = { + ...inputs.provider, + entries: [{ providerType: "anthropic", vision: true }], + }; + expect(resolveAiCapability("documentAi", inputs).reason).toBe( + "consent_required", + ); + expect(resolveAiCapability("medicationExtract", inputs).reason).toBe( + "consent_required", + ); + }); + + it("picks a document provider in document order and labs in chain order", () => { + const inputs = baseline(); + // The ChatGPT-subscription path first in the chain, a local model behind it. + inputs.provider = { + ...inputs.provider, + entries: [ + { providerType: "codex", vision: true }, + { providerType: "local", vision: true }, + ], + }; + // Document order puts local first: nothing leaves the machine. + expect(resolveAiCapability("documentAi", inputs).available).toBe(true); + // The labs scan keeps the chain's own order and picks the external entry. + expect(resolveAiCapability("labsOcr", inputs).reason).toBe( + "consent_required", + ); + }); +}); + +describe("on-device permission", () => { + it("follows the operator and the person, not the server provider or server consent", () => { + const expected: Record = { + check_failed: false, + operator_disabled: false, + not_permitted_for_record: false, + module_disabled: false, + user_disabled: 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]); + } + expect(resolveAiCapability("briefing", null).onDeviceAllowed).toBe( + expected.check_failed, + ); + }); +}); + +describe("the provider block", () => { + it("reports presence and origin", () => { + expect(resolveAiProviderState(baseline())).toEqual({ + configured: true, + managedBy: "local", + canConfigure: true, + }); + }); + + it("offers setup only on one's own record, and only with the master on", () => { + for (const recordKind of ["shared", "managed"] as const) { + const inputs = baseline(); + inputs.recordKind = recordKind; + expect(resolveAiProviderState(inputs).canConfigure, recordKind).toBe( + false, + ); + } + const inputs = baseline(); + inputs.switches = { ...inputs.switches, enabled: false }; + expect(resolveAiProviderState(inputs).canConfigure).toBe(false); + }); + + it("reports nothing configured when the inputs failed to load", () => { + expect(resolveAiBlock(null).provider).toEqual({ + configured: false, + managedBy: null, + canConfigure: false, + }); + }); +}); diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts new file mode 100644 index 000000000..737f2d4de --- /dev/null +++ b/src/lib/ai/capabilities/resolve.ts @@ -0,0 +1,290 @@ +/** + * The AI capability resolver: pure, total, no I/O. + * + * Every layer that can say no to AI work is an input here, and each can only + * subtract. The answer per capability is one of `available` or the OUTERMOST + * reason that applies (precedence in {@link AI_UNAVAILABLE_REASONS}), because + * that is the layer the reader would have to change first. Nothing in this + * file reads a database, a clock, a request or an environment variable; the + * loader (`./load.ts`) gathers the inputs and this decides. + * + * Deliberately NOT an input: budgets and rate limits. They move minute to + * minute, and a cached payload that published them would be wrong by the time + * a client acted on it. They stay a per-call refusal with their own codes. + */ +import { documentProviderRank } from "@/lib/documents/provider-rank"; +import type { ModuleKey } from "@/lib/modules/registry"; +import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; + +import { + AI_CAPABILITIES, + AI_CAPABILITY_KEYS, + AI_OPT_OUT_MODULES, + AI_UNAVAILABLE_REASONS, + ON_DEVICE_ALLOWED_REASONS, + type AiCapabilities, + type AiCapabilityDefinition, + type AiCapabilityKey, + type AiCapabilityState, + type AiOperatorSwitchSet, + type AiProviderManagedBy, + type AiProviderState, + type AiUnavailableReason, +} from "./types"; + +/** One configured provider in the chain, presence only. */ +export interface ProviderEntryPresence { + /** The chain tag: `openai`, `anthropic`, `local`, `admin-openai`, … */ + providerType: string; + /** Whether the entry's model can read an image. */ + vision: boolean; +} + +/** + * What the provider chain for a record looks like, without building a client, + * refreshing a token or touching the network. Already narrowed by the + * provider-work authority: a delegate's chain is empty, a guardian's is the + * operator's key alone. + */ +export interface ProviderPresence { + /** Usable entries in chain order, the central Codex and legacy fallback included. */ + entries: readonly ProviderEntryPresence[]; + /** The person's in-browser OCR opt-in, which lets a text-only provider read a document. */ + localOcrEnabled: boolean; + managedBy: AiProviderManagedBy | null; +} + +/** Whose record this is, as far as configuring a provider for it goes. */ +export type AiRecordKind = "self" | "shared" | "managed"; + +export interface AiCapabilityInputs { + /** Operator switches, master already applied. */ + switches: AiOperatorSwitchSet; + /** Per-module access for the record, as `moduleAccess` publishes it. */ + moduleAccess: Readonly>; + /** Whether provider work is admitted for this record at all. */ + providerWorkAdmitted: boolean; + provider: ProviderPresence; + /** Kinds of the record's active (non-revoked) consent receipts. */ + activeConsentKinds: ReadonlySet; + recordKind: AiRecordKind; +} + +/** Provider tags that egress through a credential the operator holds. */ +const OPERATOR_HELD_PROVIDER_TYPES: ReadonlySet = new Set([ + "admin-openai", + "admin-codex", +]); + +/** The one provider tag that keeps its input on the machine. */ +const LOCAL_PROVIDER_TYPE = "local"; + +const REASON_RANK: ReadonlyMap = new Map( + AI_UNAVAILABLE_REASONS.map((reason, index) => [reason, index]), +); + +function outermost( + reasons: readonly (AiUnavailableReason | null)[], +): AiUnavailableReason | null { + let best: AiUnavailableReason | null = null; + for (const reason of reasons) { + if (reason === null) continue; + if (best === null || REASON_RANK.get(reason)! < REASON_RANK.get(best)!) { + best = reason; + } + } + return best; +} + +function innermost( + reasons: readonly AiUnavailableReason[], +): AiUnavailableReason | null { + let best: AiUnavailableReason | null = null; + for (const reason of reasons) { + if (best === null || REASON_RANK.get(reason)! > REASON_RANK.get(best)!) { + best = reason; + } + } + return best; +} + +function moduleReason( + key: ModuleKey, + access: ModuleAccessState, +): AiUnavailableReason | null { + switch (access) { + case "enabled": + return null; + // The operator's instance-wide availability is an operator decision. + case "unavailable": + return "operator_disabled"; + // The edge of the active grant: the record's configuration, as far as + // this session may see it. + case "not_granted": + return "module_disabled"; + case "disabled": + return AI_OPT_OUT_MODULES.has(key) ? "user_disabled" : "module_disabled"; + } +} + +/** + * The module layer for one capability. `all` needs every owning module and + * reports the outermost reason among those that are off. `any` needs one: it + * is available as soon as one path is, and otherwise reports the INNERMOST + * reason, because moving that one layer is enough to open the capability. + */ +function modulesReason( + def: AiCapabilityDefinition, + moduleAccess: Readonly>, +): AiUnavailableReason | null { + const { mode, keys } = def.modules; + if (keys.length === 0) return null; + const reasons = keys.map((key) => + moduleReason(key, moduleAccess[key] ?? "unavailable"), + ); + if (mode === "all") return outermost(reasons); + if (reasons.some((reason) => reason === null)) return null; + return innermost(reasons as AiUnavailableReason[]); +} + +/** The entries in the order this capability would pick from. */ +function orderedEntries( + def: AiCapabilityDefinition, + entries: readonly ProviderEntryPresence[], +): readonly ProviderEntryPresence[] { + if (def.providerOrder === "chain") return entries; + // A stable sort keeps the person's own order within a rank. + return [...entries].sort( + (a, b) => + documentProviderRank(a.providerType) - + documentProviderRank(b.providerType), + ); +} + +/** + * The single provider a document read would use, or null when none can. The + * first vision-capable entry wins; failing that, a text-only provider reads the + * text the person's in-browser OCR produced, if they turned that on. + */ +function documentPick( + def: AiCapabilityDefinition, + provider: ProviderPresence, +): ProviderEntryPresence | null { + const ordered = orderedEntries(def, provider.entries); + const vision = ordered.find((entry) => entry.vision); + if (vision) return vision; + if (provider.localOcrEnabled && ordered.length > 0) return ordered[0]!; + return null; +} + +function providerReason( + def: AiCapabilityDefinition, + provider: ProviderPresence, +): AiUnavailableReason | null { + if (def.modality === "document") { + return documentPick(def, provider) === null ? "no_provider" : null; + } + return provider.entries.length === 0 ? "no_provider" : null; +} + +/** Does serving this capability need a consent receipt? */ +function consentNeeded( + def: AiCapabilityDefinition, + provider: ProviderPresence, +): boolean { + if (def.consent.rule === "self-snapshot") { + // A cascade may fall through to any entry, so one operator-held entry + // anywhere in the chain is enough to need the receipt. + return provider.entries.some((entry) => + OPERATOR_HELD_PROVIDER_TYPES.has(entry.providerType), + ); + } + if (def.modality === "document") { + // Document reads call the one picked provider, no cascade. + const pick = documentPick(def, provider); + return pick !== null && pick.providerType !== LOCAL_PROVIDER_TYPE; + } + // Text under the document rule cascades like a snapshot does, so any entry + // that leaves the machine needs the receipt. + return provider.entries.some( + (entry) => entry.providerType !== LOCAL_PROVIDER_TYPE, + ); +} + +function consentReason( + def: AiCapabilityDefinition, + provider: ProviderPresence, + activeKinds: ReadonlySet, +): AiUnavailableReason | null { + if (!consentNeeded(def, provider)) return null; + return def.consent.kinds.some((kind) => activeKinds.has(kind)) + ? null + : "consent_required"; +} + +function stateFor(reason: AiUnavailableReason | null): AiCapabilityState { + if (reason === null) { + return { available: true, reason: null, onDeviceAllowed: true }; + } + return { + available: false, + reason, + onDeviceAllowed: ON_DEVICE_ALLOWED_REASONS.has(reason), + }; +} + +/** Resolve one capability. `null` inputs mean the loader failed. */ +export function resolveAiCapability( + key: AiCapabilityKey, + inputs: AiCapabilityInputs | null, +): AiCapabilityState { + if (inputs === null) return stateFor("check_failed"); + const def = AI_CAPABILITIES[key]; + const { switches } = inputs; + return stateFor( + outermost([ + !switches.enabled || !switches[def.operatorSwitch] + ? "operator_disabled" + : null, + inputs.providerWorkAdmitted ? null : "not_permitted_for_record", + modulesReason(def, inputs.moduleAccess), + providerReason(def, inputs.provider), + consentReason(def, inputs.provider, inputs.activeConsentKinds), + ]), + ); +} + +/** Resolve every capability. `null` inputs resolve all to `check_failed`. */ +export function resolveAiCapabilities( + inputs: AiCapabilityInputs | null, +): Record { + const out = {} as Record; + for (const key of AI_CAPABILITY_KEYS) { + out[key] = resolveAiCapability(key, inputs); + } + return out; +} + +/** The account-level provider block. */ +export function resolveAiProviderState( + inputs: AiCapabilityInputs | null, +): AiProviderState { + if (inputs === null) { + return { configured: false, managedBy: null, canConfigure: false }; + } + return { + configured: inputs.provider.entries.length > 0, + managedBy: inputs.provider.managedBy, + canConfigure: inputs.recordKind === "self" && inputs.switches.enabled, + }; +} + +/** The whole `ai` block. */ +export function resolveAiBlock( + inputs: AiCapabilityInputs | null, +): AiCapabilities { + return { + capabilities: resolveAiCapabilities(inputs), + provider: resolveAiProviderState(inputs), + }; +} diff --git a/src/lib/documents/provider-order.ts b/src/lib/documents/provider-order.ts index 388e51edc..5027c490e 100644 --- a/src/lib/documents/provider-order.ts +++ b/src/lib/documents/provider-order.ts @@ -25,6 +25,7 @@ * external egress from happening without an active receipt. */ import { isExternalDocumentEgress } from "@/lib/ai/consent-guard"; +import { documentProviderRank } from "@/lib/documents/provider-rank"; import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf"; import { resolveTextProvider, @@ -37,38 +38,6 @@ import type { DocumentEgressClass, } from "@/lib/validations/inbound-documents"; -/** - * Preference rank for a provider when the payload is a DOCUMENT. Lower wins. - * Local keeps the document on the machine (rank 0); BYOK no-train API keys are - * next (rank 1, including the user's own OpenAI-compatible gateway); the - * operator's shared no-train key follows (rank 2); the - * ChatGPT-subscription OAuth paths are LAST (rank 3) — the user's own `codex` - * AND the operator's shared `admin-codex`, both train on consumer content by - * default and cannot be verified opted-out from here. - */ -function documentProviderRank(providerType: string): number { - switch (providerType) { - case "local": - return 0; - case "openai": - case "anthropic": - case "admin-key": - // The user's own gateway (LiteLLM / OpenRouter / vLLM) is a BYO endpoint - // under their own contract. It may well be on their own network, but - // HealthLog cannot tell that from the URL, so it ranks with the BYO API - // keys rather than with `local` — the conservative side. - case "openai-compatible": - return 1; - case "admin-openai": - return 2; - case "codex": - case "admin-codex": - return 3; - default: - return 2; - } -} - /** * Reorder a resolved chain for the document class: stable sort by * `documentProviderRank`, so within a rank tier the user's own chain order is diff --git a/src/lib/documents/provider-rank.ts b/src/lib/documents/provider-rank.ts new file mode 100644 index 000000000..d6a1e9a10 --- /dev/null +++ b/src/lib/documents/provider-rank.ts @@ -0,0 +1,42 @@ +/** + * The document-class provider preference, on its own so the capability + * resolver can read it without the provider resolution around it. + * + * `provider-order.ts` sorts a resolved chain by it before a document read picks + * a provider; the AI capability resolver sorts a presence-only chain by it to + * know whether that pick would leave the machine. One rank, two readers, so + * the answer published to a client and the provider a route picks cannot + * disagree about which entry comes first. + */ + +/** + * Preference rank for a provider when the payload is a DOCUMENT. Lower wins. + * Local keeps the document on the machine (rank 0); BYOK no-train API keys are + * next (rank 1, including the user's own OpenAI-compatible gateway); the + * operator's shared no-train key follows (rank 2); the + * ChatGPT-subscription OAuth paths are LAST (rank 3) — the user's own `codex` + * AND the operator's shared `admin-codex`, both train on consumer content by + * default and cannot be verified opted-out from here. + */ +export function documentProviderRank(providerType: string): number { + switch (providerType) { + case "local": + return 0; + case "openai": + case "anthropic": + case "admin-key": + // The user's own gateway (LiteLLM / OpenRouter / vLLM) is a BYO endpoint + // under their own contract. It may well be on their own network, but + // HealthLog cannot tell that from the URL, so it ranks with the BYO API + // keys rather than with `local` — the conservative side. + case "openai-compatible": + return 1; + case "admin-openai": + return 2; + case "codex": + case "admin-codex": + return 3; + default: + return 2; + } +}