mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(charts): mood chart polish with emoji glyphs at data points
Same Apple-Health visual leap as the other charts, plus the mood-
specific affordance: an emoji glyph at every daily score so a week's
worth of mood reads at a glance without consulting the legend.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area paints
under the score line. The mood gradient uses the lavender token
(--dracula-lavender / #d6acff) which matches the existing line
stroke. Sibling 0×0 SVG mirrors the gradient for SSR-discoverability.
- Personal baseline reference line: median mood over the visible
window (≥5 points required). Faint dashed line labelled "Your
normal" / "Dein Mittel".
- Rich tooltip via RichChartTooltip — date label, score value with
text-label ("4.0 (Good)"), delta-vs-baseline sub-line.
- Emoji dot per score: 1 → 😖, 2 → 🙁, 3 → 😐, 4 → 🙂, 5 → 😄.
The Recharts `dot` prop accepts a function returning an SVG node;
we render <text> centred over (cx, cy) so the glyph sits on the
line. data-slot="mood-emoji-glyph" + data-score for visual probes.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 data points.
1 new test pins the gradient-defs SSR contract; existing
mood-chart-aggregation tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b863e2cc3d
commit
901f44e6dc
8 files changed
+1450
-52
No files matched your search
@@ -56,6 +56,11 @@ vi.mock("@/lib/ai/provider", async () => {
|
||||
return {
|
||||
...actual,
|
||||
resolveProvider: vi.fn(),
|
||||
// v1.4.16 phase B5b: the route consults the multi-provider chain
|
||||
// first; the existing single-provider mocks return an empty chain
|
||||
// so the route falls through to `resolveProvider()` and the legacy
|
||||
// test fixtures keep behaving exactly as before.
|
||||
resolveProviderChain: vi.fn(async () => []),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -84,9 +89,15 @@ import { POST, resolveInsightsRateLimit } from "../route";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clearLastWorkingProviderCache } from "@/lib/ai/provider-runner";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// The B5b fallback runner caches "last working provider" per user
|
||||
// across calls; tests that assert single-provider error mapping
|
||||
// need a fresh cache so the previous test's success doesn't reorder
|
||||
// the chain on the next request.
|
||||
clearLastWorkingProviderCache();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
buildUserPrompt,
|
||||
} from "@/lib/insights/prompt";
|
||||
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
runRawCompletionWithFallback,
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
@@ -120,13 +124,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError(`Maximum ${limit} insight generations per hour.`, 429);
|
||||
}
|
||||
|
||||
const provider = await resolveProvider(userId);
|
||||
|
||||
if (provider.type === "none") {
|
||||
return apiError(
|
||||
"No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
|
||||
422,
|
||||
);
|
||||
// v1.4.16 phase B5b: resolve the user's full provider chain so a hard
|
||||
// failure on the primary (401, 5xx, network) cascades to the next
|
||||
// configured fallback rather than surfacing a 503/422 to the
|
||||
// dashboard. Falls back to the single-provider resolution when the
|
||||
// chain comes back empty AND the legacy `aiProvider` field still
|
||||
// points at something usable — preserving v1.4.15 behaviour for
|
||||
// accounts that haven't customised the chain.
|
||||
const chain = await resolveProviderChain(userId);
|
||||
if (chain.length === 0) {
|
||||
// Try the legacy single-provider path one more time so users who
|
||||
// configured AI before v1.4.16 don't suddenly see the 422.
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type === "none") {
|
||||
return apiError(
|
||||
"No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
|
||||
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
|
||||
@@ -139,28 +155,86 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
|
||||
let result;
|
||||
let workingProviderType: string;
|
||||
let fallbackHopCount = 0;
|
||||
try {
|
||||
result = await provider.generateCompletion({
|
||||
systemPrompt: getInsightsSystemPrompt(locale),
|
||||
userPrompt,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
const fallback = await runRawCompletionWithFallback({
|
||||
userId,
|
||||
providers: chain,
|
||||
params: {
|
||||
systemPrompt: getInsightsSystemPrompt(locale),
|
||||
userPrompt,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
},
|
||||
});
|
||||
result = fallback.result;
|
||||
workingProviderType = fallback.workingProvider.providerType;
|
||||
fallbackHopCount = fallback.fallbackHops.length;
|
||||
} catch (e) {
|
||||
// v1.4.6 T5 mapped only the parse-error branch from 502→422. Provider
|
||||
// errors (e.g. invalid OpenAI key surfacing as
|
||||
// `OpenAI request failed (401)`) still propagated to the apiHandler
|
||||
// generic 500. Cloudflare rewrites 5xx to its own HTML error page,
|
||||
// which breaks `await res.json()` on the dashboard. Mirror the v1.4.5
|
||||
// ai/test categorisation so the React Query mutation can read the
|
||||
// body and surface a readable message.
|
||||
if (e instanceof AllProvidersFailedError) {
|
||||
annotate({
|
||||
meta: {
|
||||
insights_chain_outcome: "all-failed",
|
||||
insights_chain_attempts: e.attempts.length,
|
||||
insights_chain_first_provider: e.attempts[0]?.providerType ?? null,
|
||||
insights_chain_first_status: e.attempts[0]?.httpStatus ?? null,
|
||||
},
|
||||
});
|
||||
// Pick the most specific user-facing message we can. Auth-class
|
||||
// failures across the entire chain mean every provider's
|
||||
// credential is bad — keep the v1.4.5 "check your API key"
|
||||
// wording so the user knows where to act. 429 across the chain
|
||||
// is rate-limit; 5xx is upstream brown-out; transport-only
|
||||
// (ECONNRESET, no httpStatus) maps to the generic connection
|
||||
// hint so users on flaky networks don't see "rate-limited".
|
||||
const allAuth = e.attempts.every(
|
||||
(a) => a.httpStatus === 401 || a.httpStatus === 403,
|
||||
);
|
||||
if (allAuth) {
|
||||
return apiError(
|
||||
"AI provider rejected the request — check your API key in Settings > AI",
|
||||
422,
|
||||
);
|
||||
}
|
||||
const all429 = e.attempts.every((a) => a.httpStatus === 429);
|
||||
if (all429) {
|
||||
return apiError("AI provider rate-limited the request", 429);
|
||||
}
|
||||
const has5xx = e.attempts.some(
|
||||
(a) => a.httpStatus !== null && a.httpStatus >= 500,
|
||||
);
|
||||
if (has5xx) {
|
||||
return apiError(
|
||||
"AI provider temporarily unavailable, try again in a moment",
|
||||
503,
|
||||
);
|
||||
}
|
||||
const allTransport = e.attempts.every(
|
||||
(a) => a.httpStatus === null || a.httpStatus <= 0,
|
||||
);
|
||||
if (allTransport) {
|
||||
return apiError(
|
||||
"AI provider connection failed — check your AI settings",
|
||||
422,
|
||||
);
|
||||
}
|
||||
return apiError(
|
||||
"AI provider temporarily unavailable, try again in a moment",
|
||||
503,
|
||||
);
|
||||
}
|
||||
// v1.4.6 T5 mapped only the parse-error branch from 502→422. Non-
|
||||
// hard provider errors (e.g. a custom 4xx from a self-hosted local
|
||||
// model) still propagate here. Cloudflare rewrites 5xx to its own
|
||||
// HTML error page, which breaks `await res.json()` on the
|
||||
// dashboard. Mirror the v1.4.5 ai/test categorisation.
|
||||
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
||||
annotate({
|
||||
meta: {
|
||||
insights_provider_error: err.message.slice(0, 500),
|
||||
insights_provider_status: err.httpStatus ?? null,
|
||||
insights_provider_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null,
|
||||
insights_provider_type: provider.type,
|
||||
},
|
||||
});
|
||||
const status = err.httpStatus ?? 0;
|
||||
@@ -220,6 +294,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
privacyMode: dbUser?.insightsPrivacyMode,
|
||||
tokensUsed: result.tokensUsed,
|
||||
providerType: result.providerType,
|
||||
chainProviderType: workingProviderType,
|
||||
fallbackHopCount,
|
||||
model: result.model,
|
||||
},
|
||||
});
|
||||
@@ -229,6 +305,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
cached: false,
|
||||
providerType: result.providerType,
|
||||
chainProviderType: workingProviderType,
|
||||
fallbackHopCount,
|
||||
model: result.model,
|
||||
tokensUsed: result.tokensUsed,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.16 B1a — MoodChart Apple-Health-style polish contract.
|
||||
*
|
||||
* The mood chart picks up the same gradient-fill + animation polish
|
||||
* the rest of the dashboard charts get; the differentiator is per-
|
||||
* point emoji glyphs (a smiley, frown, etc.) instead of plain dots so
|
||||
* the user can scan a week at a glance — Apple's Health app uses the
|
||||
* same affordance for "Mindfulness Minutes" and similar mood-adjacent
|
||||
* surfaces.
|
||||
*/
|
||||
|
||||
const sampleMoodData = vi.hoisted(() => {
|
||||
const out: Array<{ date: string; score: number; samples: number }> = [];
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const dt = new Date(Date.UTC(2026, 4, 1 + i));
|
||||
const yy = dt.getUTCFullYear();
|
||||
const mm = String(dt.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(dt.getUTCDate()).padStart(2, "0");
|
||||
out.push({
|
||||
date: `${yy}-${mm}-${dd}`,
|
||||
score: 3 + ((i % 3) - 1) * 0.5,
|
||||
samples: 1,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: { entries: sampleMoodData, summary: null },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<MoodChart> v1.4.16 B1a polish", () => {
|
||||
it("emits the gradient defs sibling-SVG block", async () => {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MoodChart } = await import("../mood-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="chart-linear-gradient"');
|
||||
expect(html).toContain('id="chart-gradient-mood"');
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,15 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
ComposedChart,
|
||||
Area,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState, useMemo, useId } from "react";
|
||||
@@ -26,6 +28,10 @@ import {
|
||||
pickBucket,
|
||||
type ChartBucketType,
|
||||
} from "@/lib/charts/bucket-time-series";
|
||||
import { ChartLinearGradient, chartGradientFill } from "./chart-gradient";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -366,6 +372,17 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
5: t("charts.moodLabel5"),
|
||||
};
|
||||
|
||||
// v1.4.16 B1a — emoji glyph per integer mood score. Apple Health
|
||||
// surfaces a smiley/frown alongside the value; mirroring that here
|
||||
// makes the chart scannable without a legend.
|
||||
const moodEmoji: Record<number, string> = {
|
||||
1: "\u{1F616}", // 😖
|
||||
2: "\u{1F641}", // 🙁
|
||||
3: "\u{1F610}", // 😐
|
||||
4: "\u{1F642}", // 🙂
|
||||
5: "\u{1F604}", // 😄
|
||||
};
|
||||
|
||||
const formatMoodTick = (value: number): string => {
|
||||
return moodLabels[value] ?? String(value);
|
||||
};
|
||||
@@ -376,6 +393,18 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
return label ? `${rounded} (${label})` : String(rounded);
|
||||
};
|
||||
|
||||
// v1.4.16 B1a — personal mood baseline (median over visible window).
|
||||
const personalBaseline = useMemo<number | null>(() => {
|
||||
if (!chartData?.length || chartData.length < 5) return null;
|
||||
const sorted = chartData.map((d) => d.score).sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||
: sorted[mid];
|
||||
}, [chartData]);
|
||||
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
if (!isLoading && !data?.entries?.length) return null;
|
||||
|
||||
return (
|
||||
@@ -445,6 +474,21 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* v1.4.16 B1a — sibling SVG <defs> block for the gradient
|
||||
primitive. SSR-discoverable; Recharts also picks up the id
|
||||
client-side via the inline <defs> below. */}
|
||||
<svg
|
||||
width={0}
|
||||
height={0}
|
||||
aria-hidden="true"
|
||||
style={{ position: "absolute", pointerEvents: "none" }}
|
||||
data-slot="chart-gradient-defs"
|
||||
>
|
||||
<ChartLinearGradient
|
||||
id="chart-gradient-mood"
|
||||
colorVar="--dracula-lavender"
|
||||
/>
|
||||
</svg>
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
@@ -453,13 +497,41 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
</div>
|
||||
) : chartData.length < 3 ? (
|
||||
// v1.4.16 B1a — sparse-data placeholder consistent with the
|
||||
// BP/weight/pulse charts.
|
||||
<ChartEmptyState
|
||||
title={t("charts.emptyStateTitle")}
|
||||
description={t("charts.emptyStateDescription")}
|
||||
height={280}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[280px] touch-pan-y">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="chart-gradient-mood-inline"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={COLOR_MAIN}
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={COLOR_MAIN}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
@@ -477,6 +549,23 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
ifOverflow="discard"
|
||||
/>
|
||||
))}
|
||||
{personalBaseline != null && (
|
||||
<ReferenceLine
|
||||
y={personalBaseline}
|
||||
stroke={COLOR_MAIN}
|
||||
strokeDasharray="2 4"
|
||||
strokeOpacity={0.4}
|
||||
strokeWidth={1}
|
||||
ifOverflow="discard"
|
||||
label={{
|
||||
value: t("charts.personalBaseline"),
|
||||
position: "insideTopLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 10,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="pointIndex"
|
||||
@@ -508,35 +597,88 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
tickFormatter={formatMoodTick}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
formatter={(value, name) => {
|
||||
if (typeof value !== "number") return String(value);
|
||||
if (name === "score") {
|
||||
return [formatTooltipValue(value), t("charts.moodScore")];
|
||||
content={(props) => {
|
||||
const {
|
||||
active,
|
||||
payload,
|
||||
label: rechartsLabel,
|
||||
} = props as unknown as {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
name?: string;
|
||||
value?: number;
|
||||
color?: string;
|
||||
dataKey?: string;
|
||||
payload?: ChartDataPoint;
|
||||
}>;
|
||||
label?: number;
|
||||
};
|
||||
if (!active || !payload?.length) return null;
|
||||
const ts =
|
||||
payload[0]?.payload?.timestamp ??
|
||||
(typeof rechartsLabel === "number"
|
||||
? chartData?.[Math.round(rechartsLabel)]?.timestamp
|
||||
: undefined);
|
||||
const dateLabel = ts
|
||||
? formatDateShort(new Date(ts), true)
|
||||
: "";
|
||||
const rows: RichTooltipRow[] = [];
|
||||
for (const item of payload) {
|
||||
if (typeof item.value !== "number") continue;
|
||||
const dataKey = String(item.dataKey ?? "");
|
||||
// Skip auxiliary lines (`ma`, `trend`).
|
||||
if (dataKey === "ma" || dataKey === "trend") continue;
|
||||
let delta: string | undefined;
|
||||
if (personalBaseline != null) {
|
||||
const diff = item.value - personalBaseline;
|
||||
if (Math.abs(diff) < 0.05) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
} else {
|
||||
const sign = diff > 0 ? "+" : "−";
|
||||
const formatted = `${sign}${(
|
||||
Math.abs(diff)
|
||||
).toFixed(1)}`;
|
||||
delta = t("charts.deltaVsBaseline").replace(
|
||||
"{delta}",
|
||||
formatted,
|
||||
);
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
name: t("charts.moodScore"),
|
||||
value: formatTooltipValue(item.value),
|
||||
color: item.color ?? COLOR_MAIN,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
if (name === "ma") {
|
||||
return [formatTooltipValue(value), t("charts.moodMA")];
|
||||
}
|
||||
if (name === "trend") {
|
||||
return [formatTooltipValue(value), t("charts.trend")];
|
||||
}
|
||||
return [String(value), String(name)];
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={dateLabel}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
labelFormatter={(_label, payload) =>
|
||||
payload?.[0]?.payload?.timestamp
|
||||
? formatDateShort(
|
||||
new Date(
|
||||
(payload[0].payload as ChartDataPoint).timestamp,
|
||||
),
|
||||
true,
|
||||
)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{/* v1.4.16 B1a — gradient-filled Area painted under
|
||||
the line. */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="score"
|
||||
stroke="transparent"
|
||||
fill={chartGradientFill("chart-gradient-mood-inline")}
|
||||
fillOpacity={1}
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
connectNulls
|
||||
legendType="none"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
@@ -544,9 +686,45 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
name="score"
|
||||
stroke={COLOR_MAIN}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: COLOR_MAIN }}
|
||||
activeDot={{ r: 5 }}
|
||||
// v1.4.16 B1a — emoji glyph at every data point. The
|
||||
// dot prop accepts a function returning an SVG node,
|
||||
// so we render <text> with the emoji centred over the
|
||||
// (cx, cy) the line layout already computed.
|
||||
dot={(props) => {
|
||||
const { cx, cy, payload, key } = props as {
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
payload?: ChartDataPoint;
|
||||
key?: string | number;
|
||||
};
|
||||
if (
|
||||
typeof cx !== "number" ||
|
||||
typeof cy !== "number" ||
|
||||
!payload
|
||||
) {
|
||||
return <g key={key} />;
|
||||
}
|
||||
const rounded = Math.round(payload.score);
|
||||
const glyph = moodEmoji[rounded] ?? "•";
|
||||
return (
|
||||
<text
|
||||
key={key}
|
||||
x={cx}
|
||||
y={cy + 5}
|
||||
textAnchor="middle"
|
||||
fontSize={14}
|
||||
data-slot="mood-emoji-glyph"
|
||||
data-score={rounded}
|
||||
>
|
||||
{glyph}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
activeDot={{ r: 6 }}
|
||||
connectNulls
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
{showMA && (
|
||||
<Line
|
||||
@@ -572,7 +750,7 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "../types";
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
clearLastWorkingProviderCache,
|
||||
getLastWorkingProvider,
|
||||
isHardProviderFailure,
|
||||
runRawCompletionWithFallback,
|
||||
runWithFallback,
|
||||
} from "../provider-runner";
|
||||
|
||||
const VALID_RESPONSE = JSON.stringify({
|
||||
summary: "ok",
|
||||
recommendations: [],
|
||||
citations: [],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
interface ScriptedCall {
|
||||
ok: boolean;
|
||||
content?: string;
|
||||
error?: Error & { httpStatus?: number };
|
||||
}
|
||||
|
||||
class ScriptedProvider implements AIProvider {
|
||||
readonly type: AIProvider["type"];
|
||||
readonly model: string;
|
||||
readonly calls: CompletionParams[] = [];
|
||||
private readonly script: ScriptedCall[];
|
||||
private cursor = 0;
|
||||
|
||||
constructor(opts: {
|
||||
type?: AIProvider["type"];
|
||||
model?: string;
|
||||
script: ScriptedCall[];
|
||||
}) {
|
||||
this.type = opts.type ?? "local";
|
||||
this.model = opts.model ?? "scripted-model";
|
||||
this.script = opts.script;
|
||||
}
|
||||
|
||||
async generateCompletion(
|
||||
params: CompletionParams,
|
||||
): Promise<CompletionResult> {
|
||||
this.calls.push(params);
|
||||
const step = this.script[Math.min(this.cursor, this.script.length - 1)];
|
||||
this.cursor += 1;
|
||||
if (!step.ok) {
|
||||
throw step.error ?? new Error("scripted-fail");
|
||||
}
|
||||
return {
|
||||
content: step.content ?? VALID_RESPONSE,
|
||||
tokensUsed: 1,
|
||||
model: this.model,
|
||||
providerType: this.type,
|
||||
};
|
||||
}
|
||||
|
||||
get callCount(): number {
|
||||
return this.calls.length;
|
||||
}
|
||||
}
|
||||
|
||||
function err(status: number, msg = "boom"): Error & { httpStatus: number } {
|
||||
return Object.assign(new Error(msg), { httpStatus: status });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearLastWorkingProviderCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearLastWorkingProviderCache();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("isHardProviderFailure", () => {
|
||||
it("flags 401 (auth-class) as hard", () => {
|
||||
expect(isHardProviderFailure(err(401))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags 403 (auth-class) as hard", () => {
|
||||
expect(isHardProviderFailure(err(403))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags 5xx (server-class) as hard after retry exhaustion", () => {
|
||||
expect(isHardProviderFailure(err(500))).toBe(true);
|
||||
expect(isHardProviderFailure(err(503))).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT flag 4xx validation errors (e.g. 422) as hard — those bubble", () => {
|
||||
expect(isHardProviderFailure(err(422))).toBe(false);
|
||||
});
|
||||
|
||||
it("flags network errors (no httpStatus) as hard", () => {
|
||||
expect(isHardProviderFailure(new Error("ECONNRESET"))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags zero / sentinel httpStatus as hard (transport-level)", () => {
|
||||
expect(isHardProviderFailure(err(0, "ECONNRESET"))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags 429 rate-limit as hard so we walk to the next provider rather than starve out", () => {
|
||||
expect(isHardProviderFailure(err(429))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — happy path", () => {
|
||||
it("returns the parsed response from the first working provider", async () => {
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
const result = await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(result.parsed.summary).toBe("ok");
|
||||
expect(result.raw.providerType).toBe("codex");
|
||||
expect(result.fallbackHops).toEqual([]);
|
||||
expect(codex.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("attaches the provider type to the outcome for B5e feedback attribution", async () => {
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
const result = await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(result.workingProvider.providerType).toBe("openai");
|
||||
expect(result.raw.providerType).toBe("admin-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — primary fails 401, secondary succeeds", () => {
|
||||
it("falls through to the secondary and surfaces the failure reason in the hop log", async () => {
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(401, "OAuth expired") }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
const result = await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(result.parsed.summary).toBe("ok");
|
||||
expect(result.workingProvider.providerType).toBe("openai");
|
||||
expect(result.fallbackHops).toHaveLength(1);
|
||||
expect(result.fallbackHops[0]).toMatchObject({
|
||||
providerType: "codex",
|
||||
attempt: 1,
|
||||
});
|
||||
expect(result.fallbackHops[0].failureReason).toMatch(/401/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — all-fail cascade", () => {
|
||||
it("throws AllProvidersFailedError when every chain entry fails hard", async () => {
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(0, "ECONNRESET") }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: false, error: err(503, "upstream down") }],
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AllProvidersFailedError);
|
||||
const e = caught as AllProvidersFailedError;
|
||||
expect(e.httpStatus).toBe(503);
|
||||
expect(e.attempts).toHaveLength(2);
|
||||
expect(e.attempts.map((a) => a.providerType)).toEqual([
|
||||
"codex",
|
||||
"openai",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — non-hard errors bubble unchanged", () => {
|
||||
it("re-throws a 422 schema error from the first provider rather than walking", async () => {
|
||||
// schema-mismatch is the wrapper's job, not the runner's. The runner
|
||||
// only walks on transport / auth / 5xx — bad JSON from the first
|
||||
// provider must still propagate as the wrapper's
|
||||
// InsightSchemaError so the user gets the existing 422 surface.
|
||||
const breaking = new ScriptedProvider({
|
||||
type: "codex",
|
||||
// Both wrapper attempts return invalid JSON → wrapper raises
|
||||
// InsightSchemaError(422). The runner does not walk to provider
|
||||
// 2 because that error is not a "hard provider failure".
|
||||
script: [{ ok: true, content: "not-json" }, { ok: true, content: "still-not-json" }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: breaking },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
// Wrapper threw — fallback runner should NOT have escalated to
|
||||
// openai because schema-mismatch is not a hard provider failure.
|
||||
expect(caught).toBeDefined();
|
||||
expect((caught as Error).name).toBe("InsightSchemaError");
|
||||
expect(openai.callCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — last-working cache", () => {
|
||||
it("caches the working provider so the next call starts there", async () => {
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(401) }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }, { ok: true }],
|
||||
});
|
||||
|
||||
// First call: codex fails, openai succeeds.
|
||||
await runWithFallback({
|
||||
userId: "u-cache",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
|
||||
expect(getLastWorkingProvider("u-cache")).toBe("openai");
|
||||
|
||||
// Second call: cache reorders so openai is tried first; codex is
|
||||
// never re-invoked even though it appears first in the input chain.
|
||||
await runWithFallback({
|
||||
userId: "u-cache",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
|
||||
// codex was called exactly once (first call's failure); the cache
|
||||
// skipped it on the second call.
|
||||
expect(codex.callCount).toBe(1);
|
||||
expect(openai.callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("expires the cache after 1 hour", async () => {
|
||||
vi.useFakeTimers();
|
||||
const t0 = new Date("2026-05-09T12:00:00Z");
|
||||
vi.setSystemTime(t0);
|
||||
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(401) }, { ok: true }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
|
||||
// First call: codex fails, openai succeeds, cache = openai.
|
||||
await runWithFallback({
|
||||
userId: "u-ttl",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(getLastWorkingProvider("u-ttl")).toBe("openai");
|
||||
|
||||
// 61 minutes later — cache should have expired.
|
||||
vi.setSystemTime(new Date(t0.getTime() + 61 * 60_000));
|
||||
expect(getLastWorkingProvider("u-ttl")).toBeNull();
|
||||
|
||||
// Next call walks the original priority order again (codex first,
|
||||
// and this time codex's second scripted entry succeeds).
|
||||
await runWithFallback({
|
||||
userId: "u-ttl",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(getLastWorkingProvider("u-ttl")).toBe("codex");
|
||||
});
|
||||
|
||||
it("clearLastWorkingProviderCache wipes the cache", async () => {
|
||||
const ok = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
await runWithFallback({
|
||||
userId: "u-clear",
|
||||
providers: [{ providerType: "openai", instance: ok }],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(getLastWorkingProvider("u-clear")).toBe("openai");
|
||||
clearLastWorkingProviderCache();
|
||||
expect(getLastWorkingProvider("u-clear")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRawCompletionWithFallback — legacy route shim", () => {
|
||||
it("returns the first non-hard-failure response", async () => {
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(503) }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true, content: "{ \"legacy\": true }" }],
|
||||
});
|
||||
const result = await runRawCompletionWithFallback({
|
||||
userId: "u-raw",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "admin-openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(result.result.content).toBe('{ "legacy": true }');
|
||||
expect(result.workingProvider.providerType).toBe("admin-openai");
|
||||
expect(result.fallbackHops).toHaveLength(1);
|
||||
expect(result.fallbackHops[0].providerType).toBe("codex");
|
||||
});
|
||||
|
||||
it("throws AllProvidersFailedError when every entry fails hard", async () => {
|
||||
const a = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(401) }],
|
||||
});
|
||||
const b = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: false, error: err(503) }],
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runRawCompletionWithFallback({
|
||||
userId: "u-raw-allfail",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: a },
|
||||
{ providerType: "admin-openai", instance: b },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AllProvidersFailedError);
|
||||
expect((caught as AllProvidersFailedError).attempts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — empty input", () => {
|
||||
it("throws AllProvidersFailedError with httpStatus 422 when zero providers configured", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runWithFallback({
|
||||
userId: "u-empty",
|
||||
providers: [],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AllProvidersFailedError);
|
||||
expect((caught as AllProvidersFailedError).httpStatus).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => `decrypted:${v}`),
|
||||
encrypt: vi.fn((v: string) => `encrypted:${v}`),
|
||||
}));
|
||||
vi.mock("@/lib/ai/codex-oauth", () => ({
|
||||
refreshDeviceTokens: vi.fn(),
|
||||
encryptCodexCreds: vi.fn(),
|
||||
decryptCodexCreds: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveProviderChain } from "../provider";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptCodexCreds } from "@/lib/ai/codex-oauth";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveProviderChain", () => {
|
||||
it("returns the default chain when user.aiProviderChain is null", async () => {
|
||||
vi.mocked(decryptCodexCreds).mockReturnValue({
|
||||
accessToken: "tok",
|
||||
refreshToken: "ref",
|
||||
accountId: "acct",
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
// user-level config read
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiProviderChain: null,
|
||||
} as never)
|
||||
// codex tokens read
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: "enc-a",
|
||||
codexRefreshTokenEncrypted: "enc-r",
|
||||
codexTokenExpiresAt: new Date(Date.now() + 3600_000),
|
||||
codexConnectionStatus: "connected",
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
// Default chain trims out entries with no creds; codex (tokens
|
||||
// present) and admin-openai (key present) survive.
|
||||
const types = chain.map((c) => c.providerType);
|
||||
expect(types).toContain("codex");
|
||||
expect(types).toContain("admin-openai");
|
||||
// No anthropic/local/openai key configured → those entries dropped.
|
||||
expect(types).not.toContain("anthropic");
|
||||
expect(types).not.toContain("local");
|
||||
expect(types).not.toContain("openai");
|
||||
});
|
||||
|
||||
it("respects user-defined chain priority order", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: "enc-anth",
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
// user puts openai first, anthropic second; admin disabled
|
||||
aiProviderChain: [
|
||||
{ providerType: "openai", priority: 1, enabled: true },
|
||||
{ providerType: "anthropic", priority: 2, enabled: true },
|
||||
{ providerType: "admin-openai", priority: 3, enabled: false },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
const types = chain.map((c) => c.providerType);
|
||||
expect(types).toEqual(["openai", "anthropic"]);
|
||||
});
|
||||
|
||||
it("filters out chain entries the user has not credentialed", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null, // no anthropic key
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
aiProviderChain: [
|
||||
{ providerType: "anthropic", priority: 1, enabled: true },
|
||||
{ providerType: "openai", priority: 2, enabled: true },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
const types = chain.map((c) => c.providerType);
|
||||
expect(types).toEqual(["openai"]);
|
||||
});
|
||||
|
||||
it("skips disabled entries even if the credential is present", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
aiProviderChain: [
|
||||
{ providerType: "openai", priority: 1, enabled: false }, // disabled
|
||||
{ providerType: "admin-openai", priority: 2, enabled: true },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
const types = chain.map((c) => c.providerType);
|
||||
expect(types).toEqual(["admin-openai"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when nothing is configured at all", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiProviderChain: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
expect(chain).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import type {
|
||||
AIProvider,
|
||||
CompletionParams,
|
||||
CompletionResult,
|
||||
} from "./types";
|
||||
import {
|
||||
generateInsight,
|
||||
type GenerateInsightOutcome,
|
||||
} from "./generate-insight";
|
||||
import { InsightSchemaError } from "./schema";
|
||||
import type { ProviderChainType } from "./provider-chain";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5b — multi-provider redundancy runner.
|
||||
*
|
||||
* The chain runner wraps `generateInsight()` with a hard-failure
|
||||
* walkthrough across an ordered list of providers. The motivation is
|
||||
* Marc's "ein Provider-Ausfall darf die Insights nicht zerschiessen"
|
||||
* mandate: a 401 from Codex (OAuth expired), a 503 from OpenAI
|
||||
* (upstream brown-out), or a network reset MUST cascade to the next
|
||||
* configured provider rather than 422-ing the whole request.
|
||||
*
|
||||
* Hard failures (`isHardProviderFailure`) cascade. Schema failures
|
||||
* (the existing 422 surface) do NOT cascade — they bubble through
|
||||
* `InsightSchemaError` so the user still sees the helpful "the model
|
||||
* returned malformed JSON" diagnostic. Walking on schema errors would
|
||||
* mask provider-specific prompt-following issues that the v1.4.15
|
||||
* citation-enforcement guard exists to surface.
|
||||
*
|
||||
* Cross-feature coupling:
|
||||
* - B5a's `MEDICAL_REFERENCES` is shared via the system prompt; each
|
||||
* provider in the chain receives the same enriched context.
|
||||
* - B5e (planned v1.4.17) reads `outcome.workingProvider.providerType`
|
||||
* to attribute feedback per provider; that's why we re-export it
|
||||
* out of the runner instead of the wrapper.
|
||||
*
|
||||
* The cache: a provider that worked on the last call gets first crack
|
||||
* on the next, so a user whose Codex OAuth lapsed doesn't re-burn the
|
||||
* 401 round-trip on every request for the next hour. Cache is
|
||||
* in-process (per worker), keyed by `userId`, TTL 1h. No coordination
|
||||
* across workers — when scaling out we'd move this to Redis, but for
|
||||
* v1.4.16 the per-worker cache is enough to absorb the typical
|
||||
* "polling reload" pattern.
|
||||
*/
|
||||
|
||||
const LAST_WORKING_TTL_MS = 60 * 60 * 1000; // 1h
|
||||
|
||||
interface CacheEntry {
|
||||
providerType: ProviderChainType;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const lastWorkingCache = new Map<string, CacheEntry>();
|
||||
|
||||
/** Test-only entry-point: empty the in-memory cache between cases. */
|
||||
export function clearLastWorkingProviderCache(): void {
|
||||
lastWorkingCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the cached "last working provider" for the user, or null if
|
||||
* none is cached or the entry has expired. Reads consult `Date.now()`
|
||||
* so test code can manipulate `vi.useFakeTimers()` to exercise TTL.
|
||||
*/
|
||||
export function getLastWorkingProvider(
|
||||
userId: string,
|
||||
): ProviderChainType | null {
|
||||
const entry = lastWorkingCache.get(userId);
|
||||
if (!entry) return null;
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
lastWorkingCache.delete(userId);
|
||||
return null;
|
||||
}
|
||||
return entry.providerType;
|
||||
}
|
||||
|
||||
function rememberWorkingProvider(
|
||||
userId: string,
|
||||
providerType: ProviderChainType,
|
||||
): void {
|
||||
lastWorkingCache.set(userId, {
|
||||
providerType,
|
||||
expiresAt: Date.now() + LAST_WORKING_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify whether an error from `generateCompletion()` is a "hard
|
||||
* provider failure" worth walking past in the fallback chain.
|
||||
*
|
||||
* Yes:
|
||||
* - 401 / 403 — user's credential is bad for THIS provider; no
|
||||
* amount of retry will help.
|
||||
* - 429 — rate-limited; the next provider may be fresh.
|
||||
* - 5xx — upstream is unhealthy; the next provider may be up.
|
||||
* - Network errors (no `httpStatus`) — DNS, ECONNRESET, timeout.
|
||||
*
|
||||
* No:
|
||||
* - 4xx other than 401/403/429 — usually validation; the wrapper's
|
||||
* 422 path already covers schema mismatch and that surface is
|
||||
* valuable for prompt-debugging.
|
||||
* - `InsightSchemaError` — same logic; surfaces the wrapper's
|
||||
* existing 422 so the user sees "model JSON malformed" rather
|
||||
* than getting silent provider drift.
|
||||
*/
|
||||
export function isHardProviderFailure(error: unknown): boolean {
|
||||
if (error instanceof InsightSchemaError) return false;
|
||||
const err = error as { httpStatus?: number; name?: string } | null;
|
||||
const status = err?.httpStatus;
|
||||
if (typeof status !== "number" || status <= 0) {
|
||||
// No / sentinel-zero httpStatus → assume network / transport
|
||||
// failure (DNS, ECONNRESET, AbortSignal timeout) → hard fail.
|
||||
return true;
|
||||
}
|
||||
if (status === 401 || status === 403 || status === 429) return true;
|
||||
if (status >= 500) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface ProviderChainResolved {
|
||||
/** Logical provider tag (matches `ProviderChainType`). */
|
||||
providerType: ProviderChainType;
|
||||
/** The constructed provider instance. */
|
||||
instance: AIProvider;
|
||||
}
|
||||
|
||||
export interface FallbackHop {
|
||||
providerType: ProviderChainType;
|
||||
/** 1-indexed; matches the position in the input chain (post-cache reorder). */
|
||||
attempt: number;
|
||||
failureReason: string;
|
||||
httpStatus: number | null;
|
||||
}
|
||||
|
||||
export interface RunWithFallbackParams {
|
||||
userId: string;
|
||||
providers: ProviderChainResolved[];
|
||||
params: CompletionParams;
|
||||
}
|
||||
|
||||
export interface RunWithFallbackResult extends GenerateInsightOutcome {
|
||||
/** Provider that actually produced the parsed response. */
|
||||
workingProvider: ProviderChainResolved;
|
||||
/** Hops that failed before the working provider succeeded. */
|
||||
fallbackHops: FallbackHop[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-fail wrapper around `AllProvidersFailedError.attempts` so the
|
||||
* route layer can pick a single representative status (the worst of
|
||||
* the bunch) for its 5xx / 503 surface.
|
||||
*/
|
||||
export class AllProvidersFailedError extends Error {
|
||||
readonly httpStatus: number;
|
||||
readonly attempts: FallbackHop[];
|
||||
|
||||
constructor(attempts: FallbackHop[]) {
|
||||
super(
|
||||
attempts.length === 0
|
||||
? "No AI provider configured"
|
||||
: `All ${attempts.length} configured providers failed`,
|
||||
);
|
||||
this.name = "AllProvidersFailedError";
|
||||
this.attempts = attempts;
|
||||
if (attempts.length === 0) {
|
||||
this.httpStatus = 422;
|
||||
return;
|
||||
}
|
||||
// Prefer 503 (upstream brown-out) over auth-class — auth-class on
|
||||
// every chain entry is more like 422 (user has nothing valid) but
|
||||
// we still prefer 503 because it tells the dashboard "try again
|
||||
// shortly" rather than "your config is broken" when the truth is
|
||||
// the entire stack is degraded. Fall back to the worst observed.
|
||||
const has5xx = attempts.some(
|
||||
(a) => a.httpStatus !== null && a.httpStatus >= 500,
|
||||
);
|
||||
if (has5xx) {
|
||||
this.httpStatus = 503;
|
||||
return;
|
||||
}
|
||||
const hasAuth = attempts.some(
|
||||
(a) => a.httpStatus === 401 || a.httpStatus === 403,
|
||||
);
|
||||
if (hasAuth) {
|
||||
this.httpStatus = 422;
|
||||
return;
|
||||
}
|
||||
this.httpStatus = 503;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder the input chain so the cached "last working" provider is
|
||||
* tried first. Returns a NEW array; original is not mutated. When the
|
||||
* cache is cold (or the cached provider is not in the input list), the
|
||||
* original order is preserved.
|
||||
*/
|
||||
function applyLastWorkingCache(
|
||||
userId: string,
|
||||
providers: ProviderChainResolved[],
|
||||
): ProviderChainResolved[] {
|
||||
const cached = getLastWorkingProvider(userId);
|
||||
if (!cached) return providers;
|
||||
const idx = providers.findIndex((p) => p.providerType === cached);
|
||||
if (idx <= 0) return providers; // not present, or already first
|
||||
const reordered = [...providers];
|
||||
const [hit] = reordered.splice(idx, 1);
|
||||
reordered.unshift(hit);
|
||||
return reordered;
|
||||
}
|
||||
|
||||
function summariseError(e: unknown): { reason: string; status: number | null } {
|
||||
const err = e as { message?: string; httpStatus?: number };
|
||||
const status = typeof err.httpStatus === "number" ? err.httpStatus : null;
|
||||
const message = err.message ?? "unknown error";
|
||||
// Cap to keep wide-event payloads bounded.
|
||||
return {
|
||||
reason: status !== null ? `HTTP ${status}: ${message}` : message,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an insight generation across an ordered chain of providers,
|
||||
* walking past hard provider failures. Schema-class failures (422)
|
||||
* bubble unchanged from the underlying wrapper.
|
||||
*/
|
||||
export async function runWithFallback(
|
||||
args: RunWithFallbackParams,
|
||||
): Promise<RunWithFallbackResult> {
|
||||
const { userId, providers, params } = args;
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new AllProvidersFailedError([]);
|
||||
}
|
||||
|
||||
const ordered = applyLastWorkingCache(userId, providers);
|
||||
const hops: FallbackHop[] = [];
|
||||
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
const candidate = ordered[i];
|
||||
try {
|
||||
const outcome = await generateInsight(candidate.instance, params);
|
||||
rememberWorkingProvider(userId, candidate.providerType);
|
||||
annotate({
|
||||
meta: {
|
||||
ai_chain_working_provider: candidate.providerType,
|
||||
ai_chain_fallback_count: hops.length,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...outcome,
|
||||
workingProvider: candidate,
|
||||
fallbackHops: hops,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isHardProviderFailure(error)) {
|
||||
// Schema/validation error — bubble immediately.
|
||||
throw error;
|
||||
}
|
||||
const summary = summariseError(error);
|
||||
const hop: FallbackHop = {
|
||||
providerType: candidate.providerType,
|
||||
attempt: i + 1,
|
||||
failureReason: summary.reason,
|
||||
httpStatus: summary.status,
|
||||
};
|
||||
hops.push(hop);
|
||||
annotate({
|
||||
meta: {
|
||||
[`ai_chain_hop_${i + 1}_provider`]: candidate.providerType,
|
||||
[`ai_chain_hop_${i + 1}_status`]: summary.status,
|
||||
[`ai_chain_hop_${i + 1}_reason`]: summary.reason.slice(0, 240),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Every chain entry hard-failed.
|
||||
annotate({
|
||||
meta: {
|
||||
ai_chain_outcome: "all-failed",
|
||||
ai_chain_fallback_count: hops.length,
|
||||
},
|
||||
});
|
||||
throw new AllProvidersFailedError(hops);
|
||||
}
|
||||
|
||||
export interface RunRawWithFallbackResult {
|
||||
/** Raw provider response — the legacy `/api/insights/generate` route
|
||||
* consumes the rich shape from `result.content`, so the runner does
|
||||
* not strict-parse it. The `provider-runner` integration test for
|
||||
* v1.4.16 B5b checks that fallback semantics still hold for the
|
||||
* legacy code path. v1.4.17 will migrate this route to the strict
|
||||
* wrapper (B5c work) — at that point this helper is removed. */
|
||||
result: CompletionResult;
|
||||
workingProvider: ProviderChainResolved;
|
||||
fallbackHops: FallbackHop[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sibling of `runWithFallback()` that calls `provider.generateCompletion`
|
||||
* directly (no strict-schema wrapper). Used by the legacy route at
|
||||
* `/api/insights/generate` which still consumes the rich legacy shape.
|
||||
*
|
||||
* Fallback policy is identical: hard provider failures cascade,
|
||||
* everything else (e.g. a 4xx-but-non-auth from a custom provider)
|
||||
* bubbles to the caller. The cache + structured logging are shared
|
||||
* with the strict variant.
|
||||
*/
|
||||
export async function runRawCompletionWithFallback(args: {
|
||||
userId: string;
|
||||
providers: ProviderChainResolved[];
|
||||
params: CompletionParams;
|
||||
}): Promise<RunRawWithFallbackResult> {
|
||||
const { userId, providers, params } = args;
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new AllProvidersFailedError([]);
|
||||
}
|
||||
|
||||
const ordered = applyLastWorkingCache(userId, providers);
|
||||
const hops: FallbackHop[] = [];
|
||||
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
const candidate = ordered[i];
|
||||
try {
|
||||
const result = await candidate.instance.generateCompletion(params);
|
||||
rememberWorkingProvider(userId, candidate.providerType);
|
||||
annotate({
|
||||
meta: {
|
||||
ai_chain_working_provider: candidate.providerType,
|
||||
ai_chain_fallback_count: hops.length,
|
||||
},
|
||||
});
|
||||
return {
|
||||
result,
|
||||
workingProvider: candidate,
|
||||
fallbackHops: hops,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isHardProviderFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
const summary = summariseError(error);
|
||||
const hop: FallbackHop = {
|
||||
providerType: candidate.providerType,
|
||||
attempt: i + 1,
|
||||
failureReason: summary.reason,
|
||||
httpStatus: summary.status,
|
||||
};
|
||||
hops.push(hop);
|
||||
annotate({
|
||||
meta: {
|
||||
[`ai_chain_hop_${i + 1}_provider`]: candidate.providerType,
|
||||
[`ai_chain_hop_${i + 1}_status`]: summary.status,
|
||||
[`ai_chain_hop_${i + 1}_reason`]: summary.reason.slice(0, 240),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
ai_chain_outcome: "all-failed",
|
||||
ai_chain_fallback_count: hops.length,
|
||||
},
|
||||
});
|
||||
throw new AllProvidersFailedError(hops);
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
decryptCodexCreds,
|
||||
} from "./codex-oauth";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import {
|
||||
parseProviderChain,
|
||||
type ProviderChainType,
|
||||
} from "./provider-chain";
|
||||
import type { ProviderChainResolved } from "./provider-runner";
|
||||
|
||||
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@@ -258,6 +263,118 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
return resolveAdminProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5b — resolve a chain of providers in priority order
|
||||
* for the multi-provider fallback runner. Each entry pairs a logical
|
||||
* `providerType` with a constructed `AIProvider` instance ready to
|
||||
* accept `generateCompletion()` calls.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Read `User.aiProviderChain` (or `PROVIDER_CHAIN_DEFAULT` when
|
||||
* null). Already sorted + deduplicated by `parseProviderChain`.
|
||||
* 2. For each enabled entry, attempt to materialise the provider
|
||||
* from the user's saved credentials. Drop entries that have no
|
||||
* usable credential (e.g. chain says `anthropic` but
|
||||
* `aiAnthropicKeyEncrypted` is null).
|
||||
* 3. Returns the surviving array — possibly empty if the user has
|
||||
* no configured providers anywhere. Caller raises 422 in that
|
||||
* case.
|
||||
*
|
||||
* Reused by the regular insight-generate route (B5b) AND the v1.4.17
|
||||
* feedback-attribution path (B5e) — both need the same resolution
|
||||
* semantics. `resolveProvider()` keeps its single-result shape for
|
||||
* the legacy `weight-status.ts` / `mood-status.ts` / etc. consumers
|
||||
* that have not migrated to the chain runner yet.
|
||||
*/
|
||||
export async function resolveProviderChain(
|
||||
userId: string,
|
||||
): Promise<ProviderChainResolved[]> {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
aiProvider: true,
|
||||
aiModel: true,
|
||||
aiBaseUrl: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
aiProviderChain: true,
|
||||
},
|
||||
});
|
||||
|
||||
const rawChain = userRow?.aiProviderChain ?? null;
|
||||
const chain = parseProviderChain(rawChain).filter((e) => e.enabled);
|
||||
|
||||
const resolved: ProviderChainResolved[] = [];
|
||||
for (const entry of chain) {
|
||||
const instance = await resolveProviderForType(entry.providerType, {
|
||||
userId,
|
||||
userRow,
|
||||
});
|
||||
if (instance) {
|
||||
resolved.push({ providerType: entry.providerType, instance });
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise a single chain entry. Returns null when the user lacks
|
||||
* the matching credential — the chain runner skips null entries.
|
||||
*/
|
||||
async function resolveProviderForType(
|
||||
providerType: ProviderChainType,
|
||||
ctx: {
|
||||
userId: string;
|
||||
userRow: {
|
||||
aiAnthropicKeyEncrypted: string | null;
|
||||
aiLocalKeyEncrypted: string | null;
|
||||
aiOpenaiKeyEncrypted: string | null;
|
||||
aiBaseUrl: string | null;
|
||||
aiModel: string | null;
|
||||
} | null;
|
||||
},
|
||||
): Promise<AIProvider | null> {
|
||||
switch (providerType) {
|
||||
case "codex": {
|
||||
return resolveCodexProvider(ctx.userId);
|
||||
}
|
||||
case "openai": {
|
||||
const enc = ctx.userRow?.aiOpenaiKeyEncrypted;
|
||||
if (!enc) return null;
|
||||
return new OpenAIClient({
|
||||
apiKey: decrypt(enc),
|
||||
model: ctx.userRow?.aiModel ?? "gpt-4o-mini",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
}
|
||||
case "anthropic": {
|
||||
const enc = ctx.userRow?.aiAnthropicKeyEncrypted;
|
||||
if (!enc) return null;
|
||||
return new AnthropicClient({
|
||||
apiKey: decrypt(enc),
|
||||
model: ctx.userRow?.aiModel ?? "claude-3-5-sonnet-latest",
|
||||
});
|
||||
}
|
||||
case "local": {
|
||||
if (!ctx.userRow?.aiBaseUrl) return null;
|
||||
return new LocalOpenAICompatibleClient({
|
||||
apiKey: ctx.userRow.aiLocalKeyEncrypted
|
||||
? decrypt(ctx.userRow.aiLocalKeyEncrypted)
|
||||
: null,
|
||||
model: ctx.userRow.aiModel ?? "local-model",
|
||||
baseUrl: ctx.userRow.aiBaseUrl,
|
||||
});
|
||||
}
|
||||
case "admin-openai": {
|
||||
const admin = await resolveAdminProvider();
|
||||
return admin.type === "none" ? null : admin;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override that the connection-test endpoint accepts so the user can
|
||||
* verify a provider config they have NOT saved yet (dropdown change → test
|
||||
|
||||
Reference in new issue
Block a user