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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:30:40 +02:00
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,
+99 -21
View File
@@ -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"');
});
});
+209 -31
View File
@@ -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);
});
});
+174
View File
@@ -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([]);
});
});
+371
View File
@@ -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);
}
+117
View File
@@ -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