mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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>
136 lines
4.7 KiB
TypeScript
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,
|
|
})),
|
|
);
|
|
}
|