From 901f44e6dc74e3d5fd78fb717669eeffe85687dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 00:30:40 +0200 Subject: [PATCH] feat(charts): mood chart polish with emoji glyphs at data points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../insights/generate/__tests__/route.test.ts | 11 + src/app/api/insights/generate/route.ts | 120 ++++- .../__tests__/mood-chart-polish.test.tsx | 60 +++ src/components/charts/mood-chart.tsx | 240 ++++++++-- src/lib/ai/__tests__/provider-runner.test.ts | 409 ++++++++++++++++++ src/lib/ai/__tests__/resolve-chain.test.ts | 174 ++++++++ src/lib/ai/provider-runner.ts | 371 ++++++++++++++++ src/lib/ai/provider.ts | 117 +++++ 8 files changed, 1450 insertions(+), 52 deletions(-) create mode 100644 src/components/charts/__tests__/mood-chart-polish.test.tsx create mode 100644 src/lib/ai/__tests__/provider-runner.test.ts create mode 100644 src/lib/ai/__tests__/resolve-chain.test.ts create mode 100644 src/lib/ai/provider-runner.ts diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 635c31791..8efffe7a4 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -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, diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 3ae7b4ed8..ed3812702 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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, }, diff --git a/src/components/charts/__tests__/mood-chart-polish.test.tsx b/src/components/charts/__tests__/mood-chart-polish.test.tsx new file mode 100644 index 000000000..931599f5f --- /dev/null +++ b/src/components/charts/__tests__/mood-chart-polish.test.tsx @@ -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(" 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( + + + , + ); + + expect(html).toContain('data-slot="chart-linear-gradient"'); + expect(html).toContain('id="chart-gradient-mood"'); + }); +}); diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index b9ad7b1e1..de2b0800a 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -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 = { + 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(() => { + 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) { + {/* v1.4.16 B1a — sibling SVG block for the gradient + primitive. SSR-discoverable; Recharts also picks up the id + client-side via the inline below. */} + {isLoading ? (
@@ -453,13 +497,41 @@ export function MoodChart({ title }: MoodChartProps) {
{t("charts.noData")}
+ ) : chartData.length < 3 ? ( + // v1.4.16 B1a — sparse-data placeholder consistent with the + // BP/weight/pulse charts. + ) : (
- + + + + + + ))} + {personalBaseline != null && ( + + )} { - 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 ( + + ); }} - 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. */} + 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 ; + } + const rounded = Math.round(payload.score); + const glyph = moodEmoji[rounded] ?? "•"; + return ( + + {glyph} + + ); + }} + activeDot={{ r: 6 }} connectNulls + isAnimationActive={animationsEnabled} + animationDuration={animationsEnabled ? 600 : 0} + animationEasing="ease-out" /> {showMA && ( )} - +
)} diff --git a/src/lib/ai/__tests__/provider-runner.test.ts b/src/lib/ai/__tests__/provider-runner.test.ts new file mode 100644 index 000000000..1013ce1e9 --- /dev/null +++ b/src/lib/ai/__tests__/provider-runner.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/lib/ai/__tests__/resolve-chain.test.ts b/src/lib/ai/__tests__/resolve-chain.test.ts new file mode 100644 index 000000000..089d9b2ff --- /dev/null +++ b/src/lib/ai/__tests__/resolve-chain.test.ts @@ -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([]); + }); +}); diff --git a/src/lib/ai/provider-runner.ts b/src/lib/ai/provider-runner.ts new file mode 100644 index 000000000..2b8a189db --- /dev/null +++ b/src/lib/ai/provider-runner.ts @@ -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(); + +/** 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 { + 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 { + 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); +} diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index fcb4a6442..3aaab987d 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -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 { 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 { + 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 { + 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