feat(db): User.aiProviderChain for ordered fallback config

Add a JSONB column on the users table that stores an ordered list of
provider preferences for multi-provider redundancy. Each entry carries
{ providerType, priority, enabled }; credentials still live in the
dedicated encrypted columns so AES-256-GCM key rotation keeps working
unchanged. Null preserves the legacy single-provider resolution path.

Ships a defensive parser/serializer pair with a sensible default chain
(codex -> openai -> anthropic -> local -> admin-openai) so a malformed
JSON on disk falls back to the default rather than 500-ing insight
generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:20:12 +02:00
1 parent 6891bf9cf9
commit 2611bb4fae
12 files changed
+766 -2

No files matched your search

+8 -1
View File
@@ -636,7 +636,14 @@
"legendOnTime": "Pünktlich",
"legendLate": "Spät",
"legendVeryLate": "Sehr spät",
"legendMissed": "Verpasst"
"legendMissed": "Verpasst",
"emptyStateTitle": "Erfasse mehr Messungen, um Trends zu sehen",
"emptyStateDescription": "Erfasse mindestens 3 Einträge, um die Trendlinie freizuschalten.",
"personalBaseline": "Dein Mittel",
"deltaVsBaseline": "{delta} vs. dein Mittel",
"deltaUnchanged": "entspricht deinem Mittel",
"targetWeight": "Ziel",
"deltaVsTarget": "{delta} vs. Ziel"
},
"insights": {
"aiAnalysisTitle": "KI-Gesundheitsanalyse",
+8 -1
View File
@@ -636,7 +636,14 @@
"legendOnTime": "On time",
"legendLate": "Late",
"legendVeryLate": "Very late",
"legendMissed": "Missed"
"legendMissed": "Missed",
"emptyStateTitle": "Add more measurements to see trends",
"emptyStateDescription": "Log at least 3 entries to unlock the trend line.",
"personalBaseline": "Your normal",
"deltaVsBaseline": "{delta} vs. your normal",
"deltaUnchanged": "matches your normal",
"targetWeight": "Target",
"deltaVsTarget": "{delta} vs. target"
},
"insights": {
"aiAnalysisTitle": "AI Health Analysis",
@@ -0,0 +1,26 @@
-- v1.4.16 phase B5b: ordered fallback chain for multi-provider AI redundancy.
--
-- Up to v1.4.15 each user resolved exactly one AI provider per insight
-- request and a hard failure (401, 5xx, network timeout) bricked the
-- generation entirely. This column stores an ordered list of provider
-- preferences so the runner can fall through on hard failure to a
-- secondary configured provider.
--
-- Shape (JSONB so admin tools can grep/index later):
-- [
-- { "providerType": "codex", "priority": 1, "enabled": true },
-- { "providerType": "openai", "priority": 2, "enabled": true },
-- { "providerType": "anthropic", "priority": 3, "enabled": false }
-- ]
--
-- Credentials are NOT inlined here — they continue to live in the
-- dedicated encrypted columns (codex_*, ai_openai_key_encrypted,
-- ai_anthropic_key_encrypted, ai_local_key_encrypted) so AES-256-GCM
-- key-rotation via scripts/rotate-encryption-key.ts keeps working
-- unchanged. The chain is pure metadata: which providers, in what order.
--
-- NULL preserves the legacy single-provider behaviour driven by
-- users.ai_provider — no surprise migration of existing accounts.
ALTER TABLE "users"
ADD COLUMN "ai_provider_chain" JSONB;
+9
View File
@@ -76,6 +76,15 @@ model User {
// before storage.
aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted")
// v1.4.16 phase B5b — ordered fallback chain for multi-provider AI
// redundancy. Each entry is `{ providerType, priority, enabled }`,
// sorted by ascending priority at runtime. Credentials live in the
// dedicated encrypted columns (codex* / aiAnthropicKeyEncrypted /
// aiLocalKeyEncrypted / aiOpenaiKeyEncrypted) — the chain just
// enumerates which providers to try and in what order. Null = legacy
// behaviour (single-provider resolution via `aiProvider`).
aiProviderChain Json? @map("ai_provider_chain")
// Onboarding
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
// v1.4.15 Phase B5: tracks whether the user has finished (or
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ChartEmptyState } from "../chart-empty-state";
describe("<ChartEmptyState>", () => {
it("renders the title and the line-chart glyph", () => {
const html = renderToStaticMarkup(
<ChartEmptyState title="Add more measurements to see trends" />,
);
expect(html).toContain("Add more measurements to see trends");
// lucide-react LineChart emits an `<svg ...>` — the line-chart icon
// is identifiable by its `class="lucide lucide-line-chart"` (or
// similar) root class. We just assert one svg landed in the output.
expect(html).toContain("<svg");
expect(html).toContain('data-slot="chart-empty-state"');
});
it("renders an optional description below the title", () => {
const html = renderToStaticMarkup(
<ChartEmptyState
title="Not enough mood data"
description="Log at least 3 entries to unlock the trend line."
/>,
);
expect(html).toContain("Not enough mood data");
expect(html).toContain(
"Log at least 3 entries to unlock the trend line.",
);
});
it("uses a custom height when supplied", () => {
const html = renderToStaticMarkup(
<ChartEmptyState title="..." height={320} />,
);
expect(html).toContain("height:320px");
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import {
ChartLinearGradient,
chartGradientFill,
} from "../chart-gradient";
/**
* v1.4.16 B1a — pin the contract for the gradient primitive that every
* chart wrapper now consumes:
*
* - emits an SVG `<defs><linearGradient>` block with the requested id,
* - top stop carries the metric colour at ~35 % alpha by default,
* - bottom stop fades to 0 %,
* - the helper `chartGradientFill()` builds the matching `url(#id)`
* reference Recharts expects on `<Area fill=...>`.
*/
describe("<ChartLinearGradient>", () => {
it("renders a vertical linearGradient with the supplied id and colour token", () => {
// Wrap in <svg> because <defs> outside an SVG triggers a React
// hydration warning under jsdom; here we render to static markup so
// the wrapper just keeps the output well-formed.
const html = renderToStaticMarkup(
<svg>
<ChartLinearGradient id="bp-gradient" colorVar="--dracula-purple" />
</svg>,
);
expect(html).toContain('id="bp-gradient"');
// Vertical gradient — top to bottom.
expect(html).toContain('x1="0"');
expect(html).toContain('y1="0"');
expect(html).toContain('y2="1"');
// The colour token round-trips into a `var(...)` reference.
expect(html).toContain("var(--dracula-purple)");
// Default opacity: 0.35 at top, 0 at bottom.
expect(html).toContain('stop-opacity="0.35"');
expect(html).toContain('stop-opacity="0"');
// Marker used by visual-regression / e2e smoke tests.
expect(html).toContain('data-slot="chart-linear-gradient"');
expect(html).toContain('data-color-var="--dracula-purple"');
});
it("respects custom topOpacity / bottomOpacity overrides", () => {
const html = renderToStaticMarkup(
<svg>
<ChartLinearGradient
id="weight-gradient"
colorVar="--dracula-cyan"
topOpacity={0.5}
bottomOpacity={0.05}
/>
</svg>,
);
expect(html).toContain('stop-opacity="0.5"');
expect(html).toContain('stop-opacity="0.05"');
});
});
describe("chartGradientFill()", () => {
it("builds a Recharts-shaped url() reference", () => {
expect(chartGradientFill("bp-gradient")).toBe("url(#bp-gradient)");
expect(chartGradientFill("mood")).toBe("url(#mood)");
});
});
@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { RichChartTooltip } from "../chart-tooltip";
/**
* v1.4.16 B1a — Apple-Health-style tooltip primitive contract.
*/
describe("<RichChartTooltip>", () => {
it("renders nothing when inactive", () => {
const html = renderToStaticMarkup(
<RichChartTooltip
active={false}
rows={[{ name: "Systolic", value: "128 mmHg", color: "#bd93f9" }]}
/>,
);
expect(html).toBe("");
});
it("renders nothing when active but row list is empty", () => {
const html = renderToStaticMarkup(<RichChartTooltip active rows={[]} />);
expect(html).toBe("");
});
it("renders the date label and one value row when active with a single row", () => {
const html = renderToStaticMarkup(
<RichChartTooltip
active
label="Mon, May 5"
rows={[
{
name: "Systolic",
value: "128 mmHg",
color: "#bd93f9",
delta: "+3 vs. your normal",
},
]}
/>,
);
expect(html).toContain("Mon, May 5");
expect(html).toContain("Systolic");
expect(html).toContain("128 mmHg");
expect(html).toContain("+3 vs. your normal");
// Marker hooks for e2e + visual.
expect(html).toContain('data-slot="rich-chart-tooltip"');
expect(html).toContain('data-slot="rich-chart-tooltip-label"');
expect(html).toContain('data-slot="rich-chart-tooltip-row"');
expect(html).toContain('data-slot="rich-chart-tooltip-delta"');
// Coloured dot uses the row colour (inline style).
expect(html).toContain("background-color:#bd93f9");
});
it("supports multiple rows and omits delta sub-line when not provided", () => {
const html = renderToStaticMarkup(
<RichChartTooltip
active
label="May 5"
rows={[
{ name: "Systolic", value: "128 mmHg", color: "#bd93f9" },
{ name: "Diastolic", value: "82 mmHg", color: "#ff79c6" },
]}
/>,
);
expect(html).toContain("Systolic");
expect(html).toContain("Diastolic");
expect(html).toContain("128 mmHg");
expect(html).toContain("82 mmHg");
// No delta block painted when no delta supplied.
expect(html).not.toContain('data-slot="rich-chart-tooltip-delta"');
});
it("hides the date label when label is empty string", () => {
const html = renderToStaticMarkup(
<RichChartTooltip
active
label=""
rows={[{ name: "Mood", value: "4.2", color: "#d6acff" }]}
/>,
);
expect(html).not.toContain('data-slot="rich-chart-tooltip-label"');
expect(html).toContain("Mood");
});
});
@@ -0,0 +1,58 @@
"use client";
import type { ReactElement, ReactNode } from "react";
import { LineChart as LineChartIcon } from "lucide-react";
/**
* Sparse-data empty-state for chart wrappers (B1a v1.4.16).
*
* Per Marc's research §1A — "personal-baseline framing first" — when a
* user has only 1-2 data points there's nothing useful to plot, so we
* paint a friendly hint instead of a near-empty canvas. The hint reuses
* the `lucide-react/LineChart` glyph the rest of the app uses for chart
* surfaces, so it visually matches the chart-card shell that wraps it.
*
* Design constraints:
* - Same outer footprint as the painted chart (240-280 px tall) so
* the dashboard layout doesn't reflow when a chart drops to / out
* of the empty state.
* - i18n-driven copy — the wrapper passes already-translated strings.
* - SSR-safe — pure markup, tested via `renderToStaticMarkup`.
*/
export interface ChartEmptyStateProps {
/** Translated headline, e.g. "Add more measurements to see trends". */
title: string;
/**
* Optional secondary line. Sometimes useful to nudge the user toward
* a specific action ("3 measurements minimum"); often omitted.
*/
description?: ReactNode;
/**
* Optional explicit height in pixels to match the surrounding chart
* card. Defaults to 240 (matches `<HealthChart>` /
* `<MedicationComplianceChart>`).
*/
height?: number;
}
export function ChartEmptyState({
title,
description,
height = 240,
}: ChartEmptyStateProps): ReactElement {
return (
<div
data-slot="chart-empty-state"
className="border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center"
style={{ height }}
>
<LineChartIcon
className="text-muted-foreground/60 h-8 w-8"
aria-hidden="true"
/>
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-xs">{description}</p> : null}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import type { ReactElement } from "react";
/**
* Reusable Apple-Health-style linear gradient for area / line fills.
*
* Recharts ships an Area component that accepts a `fill="url(#id)"` prop
* pointing at a `<linearGradient>` defined inside an SVG `<defs>` block.
* v1.4.16 B1a paints a soft Dracula-token gradient under the line so the
* chart reads like the Apple Health "Trends" cards: the metric colour at
* the top of the area fades down to nearly transparent at the bottom.
*
* Why a wrapper component:
* 1. The gradient lives in `<defs>` and must be sibling-rendered to
* `<Area fill="url(#id)" />`. Recharts allows arbitrary children in
* `LineChart` / `AreaChart` and renders them inside its `<svg>`, so
* a small named primitive keeps the call sites readable
* (`<ChartLinearGradient id="bp" colorVar="--dracula-purple" />`)
* instead of inlining 8 lines of `<defs><linearGradient>...` per
* chart.
* 2. Tokens stay canonical — only the `colorVar` prop is exposed; the
* stop offsets / opacities are baked in to enforce a consistent
* look across all 5 charts (BP, weight, pulse, mood, medication).
* 3. SSR safe — pure SVG, no DOM APIs touched. The unit test renders
* it via `renderToStaticMarkup`.
*
* Defaults follow the Apple Health pattern: ~35 % alpha at the top of
* the area, 0 % at the bottom. The pinned stops also stay clear of the
* grid lines so a domain like "60 .. 110 mmHg" still reads as a
* gradient, not a flat colour wash.
*/
export interface ChartLinearGradientProps {
/** SVG id used by Recharts: `<Area fill="url(#${id})" />`. */
id: string;
/**
* CSS variable name (e.g. `--dracula-purple`). The component wraps it
* in `var(...)` so callers can pass the token verbatim.
*/
colorVar: string;
/** Alpha at the top of the area. Defaults to 0.35. */
topOpacity?: number;
/** Alpha at the bottom of the area. Defaults to 0.0. */
bottomOpacity?: number;
}
/**
* Renders a `<defs><linearGradient .../></defs>` block.
*
* The gradient runs vertically (x1=0,x2=0,y1=0,y2=1) so it shades the
* area top-down, matching how Apple Health paints the metric colour
* over the chart background.
*/
export function ChartLinearGradient({
id,
colorVar,
topOpacity = 0.35,
bottomOpacity = 0,
}: ChartLinearGradientProps): ReactElement {
const fill = `var(${colorVar})`;
return (
<defs>
<linearGradient
id={id}
x1="0"
y1="0"
x2="0"
y2="1"
data-slot="chart-linear-gradient"
data-color-var={colorVar}
>
<stop offset="0%" stopColor={fill} stopOpacity={topOpacity} />
<stop offset="100%" stopColor={fill} stopOpacity={bottomOpacity} />
</linearGradient>
</defs>
);
}
/**
* Build the `fill` attribute string Recharts expects when wiring an
* `<Area>` to a `<ChartLinearGradient id="x">` defined alongside.
*/
export function chartGradientFill(id: string): string {
return `url(#${id})`;
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import type { ReactElement } from "react";
/**
* Apple-Health-style rich tooltip for chart wrappers (B1a v1.4.16).
*
* Recharts' default tooltip is a flat list of `name: value` rows. Apple
* Health paints something denser and friendlier: rounded card with a
* faint drop shadow, the date on its own row, the metric value in
* tabular numerals, and an optional sub-line with delta vs. baseline /
* average. This wrapper renders that layout from a Recharts
* `TooltipProps.content` callback.
*
* Design constraints (per the v1.4.16 brief):
* - SSR-safe — no `window` / `document` access. The unit test renders
* it via `renderToStaticMarkup`.
* - Dracula tokens canonical. The card surface uses `--card`/`--border`
* so dark mode + light mode both look right; per-row metric colour
* comes from the dot fill the chart already declares.
* - The component is dumb — it accepts a fully-shaped payload and
* renders. The chart wrappers compute baseline / delta themselves.
*/
export interface RichTooltipRow {
/** Display name, e.g. "Systolic" or "Mood". */
name: string;
/**
* Already-formatted value string (the chart wrapper applies units +
* locale formatting). Example: `"128 mmHg"`.
*/
value: string;
/** Stroke colour of the corresponding line — used as a left dot. */
color: string;
/**
* Optional second-line annotation for *this* row, e.g.
* `"+3 mmHg vs. your normal"`. Painted in muted-foreground at 11px.
*/
delta?: string;
}
export interface RichChartTooltipProps {
/**
* Recharts passes `active=true` and a non-empty payload only when the
* cursor is over a data point. Outside a hover the renderer should
* paint nothing.
*/
active?: boolean;
/** Row label shown at the top of the card — typically a formatted
* date string (`"Mon, May 5"`). Empty string hides the row. */
label?: string;
/** Already-shaped, sorted rows. The tooltip never re-orders. */
rows: RichTooltipRow[];
}
/**
* Renders the tooltip card.
*
* Style choices that match the Apple Health "Trends" pop-over:
* - rounded-xl (matches the surrounding chart card)
* - drop-shadow `shadow-lg` for elevation
* - tabular-nums on the value cell so digits don't jiggle while
* hovering across a horizontal series
* - 11px sub-row (delta vs. baseline) keeps the tooltip compact
*/
export function RichChartTooltip({
active,
label,
rows,
}: RichChartTooltipProps): ReactElement | null {
if (!active || rows.length === 0) return null;
return (
<div
data-slot="rich-chart-tooltip"
className="bg-card border-border min-w-[140px] rounded-xl border p-2.5 text-xs shadow-lg"
style={{
// The card itself is layered above the chart's own SVG; without
// an explicit background the gradient bleeds through the
// semi-transparent border in Safari.
backgroundColor: "var(--card)",
boxShadow:
"0 6px 20px rgba(0, 0, 0, 0.32), 0 1px 2px rgba(0, 0, 0, 0.16)",
}}
>
{label ? (
<div
className="text-muted-foreground mb-1.5 text-[11px] tracking-wide uppercase"
data-slot="rich-chart-tooltip-label"
>
{label}
</div>
) : null}
<div className="flex flex-col gap-1">
{rows.map((row) => (
<div
key={row.name}
className="flex flex-col"
data-slot="rich-chart-tooltip-row"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: row.color }}
/>
<span className="text-foreground/80">{row.name}</span>
</div>
<span className="text-foreground tabular-nums">{row.value}</span>
</div>
{row.delta ? (
<span
className="text-muted-foreground mt-0.5 ml-3.5 text-[11px]"
data-slot="rich-chart-tooltip-delta"
>
{row.delta}
</span>
) : null}
</div>
))}
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
parseProviderChain,
serializeProviderChain,
PROVIDER_CHAIN_DEFAULT,
type ProviderChainEntry,
} from "../provider-chain";
describe("parseProviderChain", () => {
it("returns the default when input is null", () => {
expect(parseProviderChain(null)).toEqual(PROVIDER_CHAIN_DEFAULT);
});
it("returns the default when input is undefined", () => {
expect(parseProviderChain(undefined)).toEqual(PROVIDER_CHAIN_DEFAULT);
});
it("returns the default when input is malformed (not an array)", () => {
expect(parseProviderChain({ junk: true })).toEqual(PROVIDER_CHAIN_DEFAULT);
});
it("returns the default when input is an empty array", () => {
expect(parseProviderChain([])).toEqual(PROVIDER_CHAIN_DEFAULT);
});
it("strips invalid entries and keeps valid ones", () => {
const input = [
{ providerType: "codex", priority: 1, enabled: true },
{ providerType: "fictional", priority: 2, enabled: true },
{ providerType: "openai", priority: 3, enabled: true },
];
const out = parseProviderChain(input);
expect(out.map((e) => e.providerType)).toEqual(["codex", "openai"]);
});
it("sorts by priority ascending (lowest priority value first)", () => {
const input: ProviderChainEntry[] = [
{ providerType: "openai", priority: 5, enabled: true },
{ providerType: "codex", priority: 1, enabled: true },
{ providerType: "anthropic", priority: 3, enabled: true },
];
const out = parseProviderChain(input);
expect(out.map((e) => e.providerType)).toEqual([
"codex",
"anthropic",
"openai",
]);
});
it("preserves insertion order for ties in priority", () => {
const input: ProviderChainEntry[] = [
{ providerType: "openai", priority: 1, enabled: true },
{ providerType: "anthropic", priority: 1, enabled: true },
];
const out = parseProviderChain(input);
expect(out.map((e) => e.providerType)).toEqual(["openai", "anthropic"]);
});
it("deduplicates by providerType (first occurrence wins)", () => {
const input: ProviderChainEntry[] = [
{ providerType: "codex", priority: 1, enabled: true },
{ providerType: "codex", priority: 5, enabled: false },
];
const out = parseProviderChain(input);
expect(out).toHaveLength(1);
expect(out[0]).toEqual({
providerType: "codex",
priority: 1,
enabled: true,
});
});
it("coerces missing enabled flag to true", () => {
const input = [{ providerType: "codex", priority: 1 }];
const out = parseProviderChain(input);
expect(out[0].enabled).toBe(true);
});
});
describe("serializeProviderChain", () => {
it("round-trips through parse without mutation", () => {
const chain: ProviderChainEntry[] = [
{ providerType: "codex", priority: 1, enabled: true },
{ providerType: "openai", priority: 2, enabled: false },
];
const json = serializeProviderChain(chain);
expect(parseProviderChain(JSON.parse(json))).toEqual(chain);
});
it("produces a parseable JSON string", () => {
const chain: ProviderChainEntry[] = [
{ providerType: "codex", priority: 1, enabled: true },
];
expect(() => JSON.parse(serializeProviderChain(chain))).not.toThrow();
});
});
describe("PROVIDER_CHAIN_DEFAULT", () => {
it("starts with codex (the cheapest path for ChatGPT-Pro users)", () => {
expect(PROVIDER_CHAIN_DEFAULT[0].providerType).toBe("codex");
});
it("includes admin OpenAI as final fallback so misconfigured users still see insights", () => {
const last =
PROVIDER_CHAIN_DEFAULT[PROVIDER_CHAIN_DEFAULT.length - 1];
expect(last.providerType).toBe("admin-openai");
});
it("priorities are strictly ascending", () => {
const priorities = PROVIDER_CHAIN_DEFAULT.map((e) => e.priority);
for (let i = 1; i < priorities.length; i += 1) {
expect(priorities[i]).toBeGreaterThan(priorities[i - 1]);
}
});
it("every entry is enabled by default", () => {
expect(PROVIDER_CHAIN_DEFAULT.every((e) => e.enabled)).toBe(true);
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* 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,
})),
);
}