mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(charts): mood chart polish with emoji glyphs at data points
Same Apple-Health visual leap as the other charts, plus the mood-
specific affordance: an emoji glyph at every daily score so a week's
worth of mood reads at a glance without consulting the legend.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area paints
under the score line. The mood gradient uses the lavender token
(--dracula-lavender / #d6acff) which matches the existing line
stroke. Sibling 0×0 SVG mirrors the gradient for SSR-discoverability.
- Personal baseline reference line: median mood over the visible
window (≥5 points required). Faint dashed line labelled "Your
normal" / "Dein Mittel".
- Rich tooltip via RichChartTooltip — date label, score value with
text-label ("4.0 (Good)"), delta-vs-baseline sub-line.
- Emoji dot per score: 1 → 😖, 2 → 🙁, 3 → 😐, 4 → 🙂, 5 → 😄.
The Recharts `dot` prop accepts a function returning an SVG node;
we render <text> centred over (cx, cy) so the glyph sits on the
line. data-slot="mood-emoji-glyph" + data-score for visual probes.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 data points.
1 new test pins the gradient-defs SSR contract; existing
mood-chart-aggregation tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b863e2cc3d
commit
901f44e6dc
8 files changed
+1450
-52
No files matched your search
@@ -0,0 +1,409 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "../types";
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
clearLastWorkingProviderCache,
|
||||
getLastWorkingProvider,
|
||||
isHardProviderFailure,
|
||||
runRawCompletionWithFallback,
|
||||
runWithFallback,
|
||||
} from "../provider-runner";
|
||||
|
||||
const VALID_RESPONSE = JSON.stringify({
|
||||
summary: "ok",
|
||||
recommendations: [],
|
||||
citations: [],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
interface ScriptedCall {
|
||||
ok: boolean;
|
||||
content?: string;
|
||||
error?: Error & { httpStatus?: number };
|
||||
}
|
||||
|
||||
class ScriptedProvider implements AIProvider {
|
||||
readonly type: AIProvider["type"];
|
||||
readonly model: string;
|
||||
readonly calls: CompletionParams[] = [];
|
||||
private readonly script: ScriptedCall[];
|
||||
private cursor = 0;
|
||||
|
||||
constructor(opts: {
|
||||
type?: AIProvider["type"];
|
||||
model?: string;
|
||||
script: ScriptedCall[];
|
||||
}) {
|
||||
this.type = opts.type ?? "local";
|
||||
this.model = opts.model ?? "scripted-model";
|
||||
this.script = opts.script;
|
||||
}
|
||||
|
||||
async generateCompletion(
|
||||
params: CompletionParams,
|
||||
): Promise<CompletionResult> {
|
||||
this.calls.push(params);
|
||||
const step = this.script[Math.min(this.cursor, this.script.length - 1)];
|
||||
this.cursor += 1;
|
||||
if (!step.ok) {
|
||||
throw step.error ?? new Error("scripted-fail");
|
||||
}
|
||||
return {
|
||||
content: step.content ?? VALID_RESPONSE,
|
||||
tokensUsed: 1,
|
||||
model: this.model,
|
||||
providerType: this.type,
|
||||
};
|
||||
}
|
||||
|
||||
get callCount(): number {
|
||||
return this.calls.length;
|
||||
}
|
||||
}
|
||||
|
||||
function err(status: number, msg = "boom"): Error & { httpStatus: number } {
|
||||
return Object.assign(new Error(msg), { httpStatus: status });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearLastWorkingProviderCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearLastWorkingProviderCache();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("isHardProviderFailure", () => {
|
||||
it("flags 401 (auth-class) as hard", () => {
|
||||
expect(isHardProviderFailure(err(401))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags 403 (auth-class) as hard", () => {
|
||||
expect(isHardProviderFailure(err(403))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags 5xx (server-class) as hard after retry exhaustion", () => {
|
||||
expect(isHardProviderFailure(err(500))).toBe(true);
|
||||
expect(isHardProviderFailure(err(503))).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT flag 4xx validation errors (e.g. 422) as hard — those bubble", () => {
|
||||
expect(isHardProviderFailure(err(422))).toBe(false);
|
||||
});
|
||||
|
||||
it("flags network errors (no httpStatus) as hard", () => {
|
||||
expect(isHardProviderFailure(new Error("ECONNRESET"))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags zero / sentinel httpStatus as hard (transport-level)", () => {
|
||||
expect(isHardProviderFailure(err(0, "ECONNRESET"))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags 429 rate-limit as hard so we walk to the next provider rather than starve out", () => {
|
||||
expect(isHardProviderFailure(err(429))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — happy path", () => {
|
||||
it("returns the parsed response from the first working provider", async () => {
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
const result = await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(result.parsed.summary).toBe("ok");
|
||||
expect(result.raw.providerType).toBe("codex");
|
||||
expect(result.fallbackHops).toEqual([]);
|
||||
expect(codex.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("attaches the provider type to the outcome for B5e feedback attribution", async () => {
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
const result = await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(result.workingProvider.providerType).toBe("openai");
|
||||
expect(result.raw.providerType).toBe("admin-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — primary fails 401, secondary succeeds", () => {
|
||||
it("falls through to the secondary and surfaces the failure reason in the hop log", async () => {
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(401, "OAuth expired") }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
const result = await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(result.parsed.summary).toBe("ok");
|
||||
expect(result.workingProvider.providerType).toBe("openai");
|
||||
expect(result.fallbackHops).toHaveLength(1);
|
||||
expect(result.fallbackHops[0]).toMatchObject({
|
||||
providerType: "codex",
|
||||
attempt: 1,
|
||||
});
|
||||
expect(result.fallbackHops[0].failureReason).toMatch(/401/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — all-fail cascade", () => {
|
||||
it("throws AllProvidersFailedError when every chain entry fails hard", async () => {
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(0, "ECONNRESET") }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: false, error: err(503, "upstream down") }],
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AllProvidersFailedError);
|
||||
const e = caught as AllProvidersFailedError;
|
||||
expect(e.httpStatus).toBe(503);
|
||||
expect(e.attempts).toHaveLength(2);
|
||||
expect(e.attempts.map((a) => a.providerType)).toEqual([
|
||||
"codex",
|
||||
"openai",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — non-hard errors bubble unchanged", () => {
|
||||
it("re-throws a 422 schema error from the first provider rather than walking", async () => {
|
||||
// schema-mismatch is the wrapper's job, not the runner's. The runner
|
||||
// only walks on transport / auth / 5xx — bad JSON from the first
|
||||
// provider must still propagate as the wrapper's
|
||||
// InsightSchemaError so the user gets the existing 422 surface.
|
||||
const breaking = new ScriptedProvider({
|
||||
type: "codex",
|
||||
// Both wrapper attempts return invalid JSON → wrapper raises
|
||||
// InsightSchemaError(422). The runner does not walk to provider
|
||||
// 2 because that error is not a "hard provider failure".
|
||||
script: [{ ok: true, content: "not-json" }, { ok: true, content: "still-not-json" }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runWithFallback({
|
||||
userId: "u1",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: breaking },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
// Wrapper threw — fallback runner should NOT have escalated to
|
||||
// openai because schema-mismatch is not a hard provider failure.
|
||||
expect(caught).toBeDefined();
|
||||
expect((caught as Error).name).toBe("InsightSchemaError");
|
||||
expect(openai.callCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — last-working cache", () => {
|
||||
it("caches the working provider so the next call starts there", async () => {
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(401) }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }, { ok: true }],
|
||||
});
|
||||
|
||||
// First call: codex fails, openai succeeds.
|
||||
await runWithFallback({
|
||||
userId: "u-cache",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
|
||||
expect(getLastWorkingProvider("u-cache")).toBe("openai");
|
||||
|
||||
// Second call: cache reorders so openai is tried first; codex is
|
||||
// never re-invoked even though it appears first in the input chain.
|
||||
await runWithFallback({
|
||||
userId: "u-cache",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
|
||||
// codex was called exactly once (first call's failure); the cache
|
||||
// skipped it on the second call.
|
||||
expect(codex.callCount).toBe(1);
|
||||
expect(openai.callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("expires the cache after 1 hour", async () => {
|
||||
vi.useFakeTimers();
|
||||
const t0 = new Date("2026-05-09T12:00:00Z");
|
||||
vi.setSystemTime(t0);
|
||||
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(401) }, { ok: true }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
|
||||
// First call: codex fails, openai succeeds, cache = openai.
|
||||
await runWithFallback({
|
||||
userId: "u-ttl",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(getLastWorkingProvider("u-ttl")).toBe("openai");
|
||||
|
||||
// 61 minutes later — cache should have expired.
|
||||
vi.setSystemTime(new Date(t0.getTime() + 61 * 60_000));
|
||||
expect(getLastWorkingProvider("u-ttl")).toBeNull();
|
||||
|
||||
// Next call walks the original priority order again (codex first,
|
||||
// and this time codex's second scripted entry succeeds).
|
||||
await runWithFallback({
|
||||
userId: "u-ttl",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(getLastWorkingProvider("u-ttl")).toBe("codex");
|
||||
});
|
||||
|
||||
it("clearLastWorkingProviderCache wipes the cache", async () => {
|
||||
const ok = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true }],
|
||||
});
|
||||
await runWithFallback({
|
||||
userId: "u-clear",
|
||||
providers: [{ providerType: "openai", instance: ok }],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(getLastWorkingProvider("u-clear")).toBe("openai");
|
||||
clearLastWorkingProviderCache();
|
||||
expect(getLastWorkingProvider("u-clear")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRawCompletionWithFallback — legacy route shim", () => {
|
||||
it("returns the first non-hard-failure response", async () => {
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(503) }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true, content: "{ \"legacy\": true }" }],
|
||||
});
|
||||
const result = await runRawCompletionWithFallback({
|
||||
userId: "u-raw",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "admin-openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(result.result.content).toBe('{ "legacy": true }');
|
||||
expect(result.workingProvider.providerType).toBe("admin-openai");
|
||||
expect(result.fallbackHops).toHaveLength(1);
|
||||
expect(result.fallbackHops[0].providerType).toBe("codex");
|
||||
});
|
||||
|
||||
it("throws AllProvidersFailedError when every entry fails hard", async () => {
|
||||
const a = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(401) }],
|
||||
});
|
||||
const b = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: false, error: err(503) }],
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runRawCompletionWithFallback({
|
||||
userId: "u-raw-allfail",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: a },
|
||||
{ providerType: "admin-openai", instance: b },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AllProvidersFailedError);
|
||||
expect((caught as AllProvidersFailedError).attempts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — empty input", () => {
|
||||
it("throws AllProvidersFailedError with httpStatus 422 when zero providers configured", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runWithFallback({
|
||||
userId: "u-empty",
|
||||
providers: [],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AllProvidersFailedError);
|
||||
expect((caught as AllProvidersFailedError).httpStatus).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => `decrypted:${v}`),
|
||||
encrypt: vi.fn((v: string) => `encrypted:${v}`),
|
||||
}));
|
||||
vi.mock("@/lib/ai/codex-oauth", () => ({
|
||||
refreshDeviceTokens: vi.fn(),
|
||||
encryptCodexCreds: vi.fn(),
|
||||
decryptCodexCreds: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveProviderChain } from "../provider";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptCodexCreds } from "@/lib/ai/codex-oauth";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveProviderChain", () => {
|
||||
it("returns the default chain when user.aiProviderChain is null", async () => {
|
||||
vi.mocked(decryptCodexCreds).mockReturnValue({
|
||||
accessToken: "tok",
|
||||
refreshToken: "ref",
|
||||
accountId: "acct",
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
// user-level config read
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiProviderChain: null,
|
||||
} as never)
|
||||
// codex tokens read
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: "enc-a",
|
||||
codexRefreshTokenEncrypted: "enc-r",
|
||||
codexTokenExpiresAt: new Date(Date.now() + 3600_000),
|
||||
codexConnectionStatus: "connected",
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
// Default chain trims out entries with no creds; codex (tokens
|
||||
// present) and admin-openai (key present) survive.
|
||||
const types = chain.map((c) => c.providerType);
|
||||
expect(types).toContain("codex");
|
||||
expect(types).toContain("admin-openai");
|
||||
// No anthropic/local/openai key configured → those entries dropped.
|
||||
expect(types).not.toContain("anthropic");
|
||||
expect(types).not.toContain("local");
|
||||
expect(types).not.toContain("openai");
|
||||
});
|
||||
|
||||
it("respects user-defined chain priority order", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: "enc-anth",
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
// user puts openai first, anthropic second; admin disabled
|
||||
aiProviderChain: [
|
||||
{ providerType: "openai", priority: 1, enabled: true },
|
||||
{ providerType: "anthropic", priority: 2, enabled: true },
|
||||
{ providerType: "admin-openai", priority: 3, enabled: false },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
const types = chain.map((c) => c.providerType);
|
||||
expect(types).toEqual(["openai", "anthropic"]);
|
||||
});
|
||||
|
||||
it("filters out chain entries the user has not credentialed", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null, // no anthropic key
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
aiProviderChain: [
|
||||
{ providerType: "anthropic", priority: 1, enabled: true },
|
||||
{ providerType: "openai", priority: 2, enabled: true },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
const types = chain.map((c) => c.providerType);
|
||||
expect(types).toEqual(["openai"]);
|
||||
});
|
||||
|
||||
it("skips disabled entries even if the credential is present", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
aiProviderChain: [
|
||||
{ providerType: "openai", priority: 1, enabled: false }, // disabled
|
||||
{ providerType: "admin-openai", priority: 2, enabled: true },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
const types = chain.map((c) => c.providerType);
|
||||
expect(types).toEqual(["admin-openai"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when nothing is configured at all", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiProviderChain: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
expect(chain).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import type {
|
||||
AIProvider,
|
||||
CompletionParams,
|
||||
CompletionResult,
|
||||
} from "./types";
|
||||
import {
|
||||
generateInsight,
|
||||
type GenerateInsightOutcome,
|
||||
} from "./generate-insight";
|
||||
import { InsightSchemaError } from "./schema";
|
||||
import type { ProviderChainType } from "./provider-chain";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5b — multi-provider redundancy runner.
|
||||
*
|
||||
* The chain runner wraps `generateInsight()` with a hard-failure
|
||||
* walkthrough across an ordered list of providers. The motivation is
|
||||
* Marc's "ein Provider-Ausfall darf die Insights nicht zerschiessen"
|
||||
* mandate: a 401 from Codex (OAuth expired), a 503 from OpenAI
|
||||
* (upstream brown-out), or a network reset MUST cascade to the next
|
||||
* configured provider rather than 422-ing the whole request.
|
||||
*
|
||||
* Hard failures (`isHardProviderFailure`) cascade. Schema failures
|
||||
* (the existing 422 surface) do NOT cascade — they bubble through
|
||||
* `InsightSchemaError` so the user still sees the helpful "the model
|
||||
* returned malformed JSON" diagnostic. Walking on schema errors would
|
||||
* mask provider-specific prompt-following issues that the v1.4.15
|
||||
* citation-enforcement guard exists to surface.
|
||||
*
|
||||
* Cross-feature coupling:
|
||||
* - B5a's `MEDICAL_REFERENCES` is shared via the system prompt; each
|
||||
* provider in the chain receives the same enriched context.
|
||||
* - B5e (planned v1.4.17) reads `outcome.workingProvider.providerType`
|
||||
* to attribute feedback per provider; that's why we re-export it
|
||||
* out of the runner instead of the wrapper.
|
||||
*
|
||||
* The cache: a provider that worked on the last call gets first crack
|
||||
* on the next, so a user whose Codex OAuth lapsed doesn't re-burn the
|
||||
* 401 round-trip on every request for the next hour. Cache is
|
||||
* in-process (per worker), keyed by `userId`, TTL 1h. No coordination
|
||||
* across workers — when scaling out we'd move this to Redis, but for
|
||||
* v1.4.16 the per-worker cache is enough to absorb the typical
|
||||
* "polling reload" pattern.
|
||||
*/
|
||||
|
||||
const LAST_WORKING_TTL_MS = 60 * 60 * 1000; // 1h
|
||||
|
||||
interface CacheEntry {
|
||||
providerType: ProviderChainType;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const lastWorkingCache = new Map<string, CacheEntry>();
|
||||
|
||||
/** Test-only entry-point: empty the in-memory cache between cases. */
|
||||
export function clearLastWorkingProviderCache(): void {
|
||||
lastWorkingCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the cached "last working provider" for the user, or null if
|
||||
* none is cached or the entry has expired. Reads consult `Date.now()`
|
||||
* so test code can manipulate `vi.useFakeTimers()` to exercise TTL.
|
||||
*/
|
||||
export function getLastWorkingProvider(
|
||||
userId: string,
|
||||
): ProviderChainType | null {
|
||||
const entry = lastWorkingCache.get(userId);
|
||||
if (!entry) return null;
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
lastWorkingCache.delete(userId);
|
||||
return null;
|
||||
}
|
||||
return entry.providerType;
|
||||
}
|
||||
|
||||
function rememberWorkingProvider(
|
||||
userId: string,
|
||||
providerType: ProviderChainType,
|
||||
): void {
|
||||
lastWorkingCache.set(userId, {
|
||||
providerType,
|
||||
expiresAt: Date.now() + LAST_WORKING_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify whether an error from `generateCompletion()` is a "hard
|
||||
* provider failure" worth walking past in the fallback chain.
|
||||
*
|
||||
* Yes:
|
||||
* - 401 / 403 — user's credential is bad for THIS provider; no
|
||||
* amount of retry will help.
|
||||
* - 429 — rate-limited; the next provider may be fresh.
|
||||
* - 5xx — upstream is unhealthy; the next provider may be up.
|
||||
* - Network errors (no `httpStatus`) — DNS, ECONNRESET, timeout.
|
||||
*
|
||||
* No:
|
||||
* - 4xx other than 401/403/429 — usually validation; the wrapper's
|
||||
* 422 path already covers schema mismatch and that surface is
|
||||
* valuable for prompt-debugging.
|
||||
* - `InsightSchemaError` — same logic; surfaces the wrapper's
|
||||
* existing 422 so the user sees "model JSON malformed" rather
|
||||
* than getting silent provider drift.
|
||||
*/
|
||||
export function isHardProviderFailure(error: unknown): boolean {
|
||||
if (error instanceof InsightSchemaError) return false;
|
||||
const err = error as { httpStatus?: number; name?: string } | null;
|
||||
const status = err?.httpStatus;
|
||||
if (typeof status !== "number" || status <= 0) {
|
||||
// No / sentinel-zero httpStatus → assume network / transport
|
||||
// failure (DNS, ECONNRESET, AbortSignal timeout) → hard fail.
|
||||
return true;
|
||||
}
|
||||
if (status === 401 || status === 403 || status === 429) return true;
|
||||
if (status >= 500) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface ProviderChainResolved {
|
||||
/** Logical provider tag (matches `ProviderChainType`). */
|
||||
providerType: ProviderChainType;
|
||||
/** The constructed provider instance. */
|
||||
instance: AIProvider;
|
||||
}
|
||||
|
||||
export interface FallbackHop {
|
||||
providerType: ProviderChainType;
|
||||
/** 1-indexed; matches the position in the input chain (post-cache reorder). */
|
||||
attempt: number;
|
||||
failureReason: string;
|
||||
httpStatus: number | null;
|
||||
}
|
||||
|
||||
export interface RunWithFallbackParams {
|
||||
userId: string;
|
||||
providers: ProviderChainResolved[];
|
||||
params: CompletionParams;
|
||||
}
|
||||
|
||||
export interface RunWithFallbackResult extends GenerateInsightOutcome {
|
||||
/** Provider that actually produced the parsed response. */
|
||||
workingProvider: ProviderChainResolved;
|
||||
/** Hops that failed before the working provider succeeded. */
|
||||
fallbackHops: FallbackHop[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-fail wrapper around `AllProvidersFailedError.attempts` so the
|
||||
* route layer can pick a single representative status (the worst of
|
||||
* the bunch) for its 5xx / 503 surface.
|
||||
*/
|
||||
export class AllProvidersFailedError extends Error {
|
||||
readonly httpStatus: number;
|
||||
readonly attempts: FallbackHop[];
|
||||
|
||||
constructor(attempts: FallbackHop[]) {
|
||||
super(
|
||||
attempts.length === 0
|
||||
? "No AI provider configured"
|
||||
: `All ${attempts.length} configured providers failed`,
|
||||
);
|
||||
this.name = "AllProvidersFailedError";
|
||||
this.attempts = attempts;
|
||||
if (attempts.length === 0) {
|
||||
this.httpStatus = 422;
|
||||
return;
|
||||
}
|
||||
// Prefer 503 (upstream brown-out) over auth-class — auth-class on
|
||||
// every chain entry is more like 422 (user has nothing valid) but
|
||||
// we still prefer 503 because it tells the dashboard "try again
|
||||
// shortly" rather than "your config is broken" when the truth is
|
||||
// the entire stack is degraded. Fall back to the worst observed.
|
||||
const has5xx = attempts.some(
|
||||
(a) => a.httpStatus !== null && a.httpStatus >= 500,
|
||||
);
|
||||
if (has5xx) {
|
||||
this.httpStatus = 503;
|
||||
return;
|
||||
}
|
||||
const hasAuth = attempts.some(
|
||||
(a) => a.httpStatus === 401 || a.httpStatus === 403,
|
||||
);
|
||||
if (hasAuth) {
|
||||
this.httpStatus = 422;
|
||||
return;
|
||||
}
|
||||
this.httpStatus = 503;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder the input chain so the cached "last working" provider is
|
||||
* tried first. Returns a NEW array; original is not mutated. When the
|
||||
* cache is cold (or the cached provider is not in the input list), the
|
||||
* original order is preserved.
|
||||
*/
|
||||
function applyLastWorkingCache(
|
||||
userId: string,
|
||||
providers: ProviderChainResolved[],
|
||||
): ProviderChainResolved[] {
|
||||
const cached = getLastWorkingProvider(userId);
|
||||
if (!cached) return providers;
|
||||
const idx = providers.findIndex((p) => p.providerType === cached);
|
||||
if (idx <= 0) return providers; // not present, or already first
|
||||
const reordered = [...providers];
|
||||
const [hit] = reordered.splice(idx, 1);
|
||||
reordered.unshift(hit);
|
||||
return reordered;
|
||||
}
|
||||
|
||||
function summariseError(e: unknown): { reason: string; status: number | null } {
|
||||
const err = e as { message?: string; httpStatus?: number };
|
||||
const status = typeof err.httpStatus === "number" ? err.httpStatus : null;
|
||||
const message = err.message ?? "unknown error";
|
||||
// Cap to keep wide-event payloads bounded.
|
||||
return {
|
||||
reason: status !== null ? `HTTP ${status}: ${message}` : message,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an insight generation across an ordered chain of providers,
|
||||
* walking past hard provider failures. Schema-class failures (422)
|
||||
* bubble unchanged from the underlying wrapper.
|
||||
*/
|
||||
export async function runWithFallback(
|
||||
args: RunWithFallbackParams,
|
||||
): Promise<RunWithFallbackResult> {
|
||||
const { userId, providers, params } = args;
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new AllProvidersFailedError([]);
|
||||
}
|
||||
|
||||
const ordered = applyLastWorkingCache(userId, providers);
|
||||
const hops: FallbackHop[] = [];
|
||||
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
const candidate = ordered[i];
|
||||
try {
|
||||
const outcome = await generateInsight(candidate.instance, params);
|
||||
rememberWorkingProvider(userId, candidate.providerType);
|
||||
annotate({
|
||||
meta: {
|
||||
ai_chain_working_provider: candidate.providerType,
|
||||
ai_chain_fallback_count: hops.length,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...outcome,
|
||||
workingProvider: candidate,
|
||||
fallbackHops: hops,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isHardProviderFailure(error)) {
|
||||
// Schema/validation error — bubble immediately.
|
||||
throw error;
|
||||
}
|
||||
const summary = summariseError(error);
|
||||
const hop: FallbackHop = {
|
||||
providerType: candidate.providerType,
|
||||
attempt: i + 1,
|
||||
failureReason: summary.reason,
|
||||
httpStatus: summary.status,
|
||||
};
|
||||
hops.push(hop);
|
||||
annotate({
|
||||
meta: {
|
||||
[`ai_chain_hop_${i + 1}_provider`]: candidate.providerType,
|
||||
[`ai_chain_hop_${i + 1}_status`]: summary.status,
|
||||
[`ai_chain_hop_${i + 1}_reason`]: summary.reason.slice(0, 240),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Every chain entry hard-failed.
|
||||
annotate({
|
||||
meta: {
|
||||
ai_chain_outcome: "all-failed",
|
||||
ai_chain_fallback_count: hops.length,
|
||||
},
|
||||
});
|
||||
throw new AllProvidersFailedError(hops);
|
||||
}
|
||||
|
||||
export interface RunRawWithFallbackResult {
|
||||
/** Raw provider response — the legacy `/api/insights/generate` route
|
||||
* consumes the rich shape from `result.content`, so the runner does
|
||||
* not strict-parse it. The `provider-runner` integration test for
|
||||
* v1.4.16 B5b checks that fallback semantics still hold for the
|
||||
* legacy code path. v1.4.17 will migrate this route to the strict
|
||||
* wrapper (B5c work) — at that point this helper is removed. */
|
||||
result: CompletionResult;
|
||||
workingProvider: ProviderChainResolved;
|
||||
fallbackHops: FallbackHop[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sibling of `runWithFallback()` that calls `provider.generateCompletion`
|
||||
* directly (no strict-schema wrapper). Used by the legacy route at
|
||||
* `/api/insights/generate` which still consumes the rich legacy shape.
|
||||
*
|
||||
* Fallback policy is identical: hard provider failures cascade,
|
||||
* everything else (e.g. a 4xx-but-non-auth from a custom provider)
|
||||
* bubbles to the caller. The cache + structured logging are shared
|
||||
* with the strict variant.
|
||||
*/
|
||||
export async function runRawCompletionWithFallback(args: {
|
||||
userId: string;
|
||||
providers: ProviderChainResolved[];
|
||||
params: CompletionParams;
|
||||
}): Promise<RunRawWithFallbackResult> {
|
||||
const { userId, providers, params } = args;
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new AllProvidersFailedError([]);
|
||||
}
|
||||
|
||||
const ordered = applyLastWorkingCache(userId, providers);
|
||||
const hops: FallbackHop[] = [];
|
||||
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
const candidate = ordered[i];
|
||||
try {
|
||||
const result = await candidate.instance.generateCompletion(params);
|
||||
rememberWorkingProvider(userId, candidate.providerType);
|
||||
annotate({
|
||||
meta: {
|
||||
ai_chain_working_provider: candidate.providerType,
|
||||
ai_chain_fallback_count: hops.length,
|
||||
},
|
||||
});
|
||||
return {
|
||||
result,
|
||||
workingProvider: candidate,
|
||||
fallbackHops: hops,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isHardProviderFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
const summary = summariseError(error);
|
||||
const hop: FallbackHop = {
|
||||
providerType: candidate.providerType,
|
||||
attempt: i + 1,
|
||||
failureReason: summary.reason,
|
||||
httpStatus: summary.status,
|
||||
};
|
||||
hops.push(hop);
|
||||
annotate({
|
||||
meta: {
|
||||
[`ai_chain_hop_${i + 1}_provider`]: candidate.providerType,
|
||||
[`ai_chain_hop_${i + 1}_status`]: summary.status,
|
||||
[`ai_chain_hop_${i + 1}_reason`]: summary.reason.slice(0, 240),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
ai_chain_outcome: "all-failed",
|
||||
ai_chain_fallback_count: hops.length,
|
||||
},
|
||||
});
|
||||
throw new AllProvidersFailedError(hops);
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
decryptCodexCreds,
|
||||
} from "./codex-oauth";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import {
|
||||
parseProviderChain,
|
||||
type ProviderChainType,
|
||||
} from "./provider-chain";
|
||||
import type { ProviderChainResolved } from "./provider-runner";
|
||||
|
||||
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@@ -258,6 +263,118 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
return resolveAdminProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5b — resolve a chain of providers in priority order
|
||||
* for the multi-provider fallback runner. Each entry pairs a logical
|
||||
* `providerType` with a constructed `AIProvider` instance ready to
|
||||
* accept `generateCompletion()` calls.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Read `User.aiProviderChain` (or `PROVIDER_CHAIN_DEFAULT` when
|
||||
* null). Already sorted + deduplicated by `parseProviderChain`.
|
||||
* 2. For each enabled entry, attempt to materialise the provider
|
||||
* from the user's saved credentials. Drop entries that have no
|
||||
* usable credential (e.g. chain says `anthropic` but
|
||||
* `aiAnthropicKeyEncrypted` is null).
|
||||
* 3. Returns the surviving array — possibly empty if the user has
|
||||
* no configured providers anywhere. Caller raises 422 in that
|
||||
* case.
|
||||
*
|
||||
* Reused by the regular insight-generate route (B5b) AND the v1.4.17
|
||||
* feedback-attribution path (B5e) — both need the same resolution
|
||||
* semantics. `resolveProvider()` keeps its single-result shape for
|
||||
* the legacy `weight-status.ts` / `mood-status.ts` / etc. consumers
|
||||
* that have not migrated to the chain runner yet.
|
||||
*/
|
||||
export async function resolveProviderChain(
|
||||
userId: string,
|
||||
): Promise<ProviderChainResolved[]> {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
aiProvider: true,
|
||||
aiModel: true,
|
||||
aiBaseUrl: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
aiProviderChain: true,
|
||||
},
|
||||
});
|
||||
|
||||
const rawChain = userRow?.aiProviderChain ?? null;
|
||||
const chain = parseProviderChain(rawChain).filter((e) => e.enabled);
|
||||
|
||||
const resolved: ProviderChainResolved[] = [];
|
||||
for (const entry of chain) {
|
||||
const instance = await resolveProviderForType(entry.providerType, {
|
||||
userId,
|
||||
userRow,
|
||||
});
|
||||
if (instance) {
|
||||
resolved.push({ providerType: entry.providerType, instance });
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise a single chain entry. Returns null when the user lacks
|
||||
* the matching credential — the chain runner skips null entries.
|
||||
*/
|
||||
async function resolveProviderForType(
|
||||
providerType: ProviderChainType,
|
||||
ctx: {
|
||||
userId: string;
|
||||
userRow: {
|
||||
aiAnthropicKeyEncrypted: string | null;
|
||||
aiLocalKeyEncrypted: string | null;
|
||||
aiOpenaiKeyEncrypted: string | null;
|
||||
aiBaseUrl: string | null;
|
||||
aiModel: string | null;
|
||||
} | null;
|
||||
},
|
||||
): Promise<AIProvider | null> {
|
||||
switch (providerType) {
|
||||
case "codex": {
|
||||
return resolveCodexProvider(ctx.userId);
|
||||
}
|
||||
case "openai": {
|
||||
const enc = ctx.userRow?.aiOpenaiKeyEncrypted;
|
||||
if (!enc) return null;
|
||||
return new OpenAIClient({
|
||||
apiKey: decrypt(enc),
|
||||
model: ctx.userRow?.aiModel ?? "gpt-4o-mini",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
}
|
||||
case "anthropic": {
|
||||
const enc = ctx.userRow?.aiAnthropicKeyEncrypted;
|
||||
if (!enc) return null;
|
||||
return new AnthropicClient({
|
||||
apiKey: decrypt(enc),
|
||||
model: ctx.userRow?.aiModel ?? "claude-3-5-sonnet-latest",
|
||||
});
|
||||
}
|
||||
case "local": {
|
||||
if (!ctx.userRow?.aiBaseUrl) return null;
|
||||
return new LocalOpenAICompatibleClient({
|
||||
apiKey: ctx.userRow.aiLocalKeyEncrypted
|
||||
? decrypt(ctx.userRow.aiLocalKeyEncrypted)
|
||||
: null,
|
||||
model: ctx.userRow.aiModel ?? "local-model",
|
||||
baseUrl: ctx.userRow.aiBaseUrl,
|
||||
});
|
||||
}
|
||||
case "admin-openai": {
|
||||
const admin = await resolveAdminProvider();
|
||||
return admin.type === "none" ? null : admin;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override that the connection-test endpoint accepts so the user can
|
||||
* verify a provider config they have NOT saved yet (dropdown change → test
|
||||
|
||||
Reference in new issue
Block a user