mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
6891bf9cf9
commit
2611bb4fae
12 files changed
+766
-2
No files matched your search
+8
-1
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user