Files
HealthLog/src/lib/ai/provider-chain.ts
T
Marc-André BombeckandClaude Opus 4.7 3048dd6637 style(prettier): sweep formatting across the tree
Pre-existing format drift accumulated across v1.4.x; running
prettier brings the whole tree back to baseline so future diffs
read clean. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:05:40 +02:00

136 lines
4.7 KiB
TypeScript

/**
* v1.4.16 phase B5b — multi-provider redundancy.
*
* The persisted shape on `User.aiProviderChain` is a JSON array of
* entries describing **which** providers to try and **in what order**.
* Credentials live in the dedicated encrypted columns
* (`codex*Encrypted`, `aiOpenaiKeyEncrypted`, `aiAnthropicKeyEncrypted`,
* `aiLocalKeyEncrypted`); the chain is pure metadata so AES-256-GCM key
* rotation via `scripts/rotate-encryption-key.ts` keeps working
* unchanged.
*
* Why a separate module rather than inlining the parser into
* `provider.ts`: the chain feeds three call sites (the runner, the
* Settings UI summary endpoint, and the integration test fixture),
* and a defensive parser-with-default keeps each call site short. A
* malformed chain on disk (manual SQL edit, half-applied migration)
* MUST never break insight generation — it falls back to the
* `PROVIDER_CHAIN_DEFAULT`.
*/
/**
* Provider tags used in the chain. `admin-openai` is the legacy
* `AppSettings.adminAiKeyEncrypted` fallback (so misconfigured users
* still see insights via the operator's key); the rest map 1:1 onto
* the per-user encrypted-credential columns.
*
* Distinct from `ProviderType` (in `types.ts`) because that enum tags
* the **runtime** provider instance — which has no notion of "admin
* OpenAI" vs "user OpenAI" at the wire level (both are
* `OpenAIClient`). The chain operates at the **resolution** layer
* where that distinction matters.
*/
export const PROVIDER_CHAIN_TYPES = [
"codex",
"openai",
"anthropic",
"local",
"admin-openai",
] as const;
export type ProviderChainType = (typeof PROVIDER_CHAIN_TYPES)[number];
export interface ProviderChainEntry {
providerType: ProviderChainType;
priority: number;
enabled: boolean;
}
/**
* Default chain when the user has not customised one. Order reflects
* the cheapest-first preference users have voiced in v1.4.x feedback:
* 1. codex — covered by ChatGPT Pro/Plus subscription (no per-token
* cost on top of what the user already pays).
* 2. openai — user's own API key, billed per token.
* 3. anthropic — user's own API key.
* 4. local — self-hosted Ollama / LM Studio.
* 5. admin-openai — operator's shared key, last-ditch so a user with
* no personal config still sees insights.
*/
export const PROVIDER_CHAIN_DEFAULT: readonly ProviderChainEntry[] = [
{ providerType: "codex", priority: 1, enabled: true },
{ providerType: "openai", priority: 2, enabled: true },
{ providerType: "anthropic", priority: 3, enabled: true },
{ providerType: "local", priority: 4, enabled: true },
{ providerType: "admin-openai", priority: 5, enabled: true },
];
/**
* Parse the persisted JSON value into a sorted, deduplicated chain.
* Tolerant by design — anything malformed collapses to the default
* rather than 500-ing the request.
*
* Sort is stable (Array.prototype.sort guarantee since ES2019) so
* priority ties preserve insertion order, which matches the UI's
* drag-handle convention (admin-OpenAI vs user-OpenAI tied = whichever
* the user dropped first wins).
*/
export function parseProviderChain(
raw: unknown,
): readonly ProviderChainEntry[] {
if (!Array.isArray(raw) || raw.length === 0) {
return PROVIDER_CHAIN_DEFAULT;
}
const seen = new Set<ProviderChainType>();
const valid: ProviderChainEntry[] = [];
for (const item of raw) {
if (item == null || typeof item !== "object") continue;
const candidate = item as {
providerType?: unknown;
priority?: unknown;
enabled?: unknown;
};
const providerType = candidate.providerType;
if (typeof providerType !== "string") continue;
if (!PROVIDER_CHAIN_TYPES.includes(providerType as ProviderChainType)) {
continue;
}
if (seen.has(providerType as ProviderChainType)) continue;
seen.add(providerType as ProviderChainType);
const priority =
typeof candidate.priority === "number" &&
Number.isFinite(candidate.priority)
? candidate.priority
: valid.length + 1;
const enabled =
typeof candidate.enabled === "boolean" ? candidate.enabled : true;
valid.push({
providerType: providerType as ProviderChainType,
priority,
enabled,
});
}
if (valid.length === 0) return PROVIDER_CHAIN_DEFAULT;
return valid.sort((a, b) => a.priority - b.priority);
}
/**
* Inverse of `parseProviderChain` — produces the JSON the database
* column stores. Kept as a helper so the writers can't accidentally
* persist a non-canonical key order.
*/
export function serializeProviderChain(
chain: readonly ProviderChainEntry[],
): string {
return JSON.stringify(
chain.map((e) => ({
providerType: e.providerType,
priority: e.priority,
enabled: e.enabled,
})),
);
}