diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..ea2172f --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,29 @@ +// GET /api/health — liveness by default; add ?strict=1 for readiness. +// +// A dead LLM key must never fail the check a kill-and-restart decision reads: +// restarting the process can't fix someone else's outage. So the plain check +// always returns 200 once the process is up, carrying `llm` as information +// only. `?strict=1` is the opt-in for a caller that actually wants to know +// whether analysis currently works — it 503s only when the chain has been +// down for `downAfter` consecutive requests. + +import { NextRequest, NextResponse } from "next/server"; +import { getLLMHealth } from "@/lib/health"; + +export async function GET(req: NextRequest) { + const llm = getLLMHealth(); + const strict = req.nextUrl.searchParams.get("strict") === "1"; + const status = strict && llm.status === "down" ? 503 : 200; + + return NextResponse.json( + { + success: true, + data: { + status: status === 200 ? "healthy" : "unhealthy", + llm, + timestamp: new Date().toISOString(), + }, + }, + { status }, + ); +} diff --git a/src/lib/health.test.ts b/src/lib/health.test.ts new file mode 100644 index 0000000..be8acbe --- /dev/null +++ b/src/lib/health.test.ts @@ -0,0 +1,44 @@ +/** + * `/api/health` had nothing to report about the LLM chain until now — an + * outage was invisible to it despite `callLLM` already walking a fallback + * when a vendor refuses. This pins the state machine that check depends on. + * Mirrors kivvi/evig/hirnli/botsmann/aoz-housing's own copy. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { getLLMHealth, recordLLMFailure, recordLLMSuccess, resetLLMHealth } from "./health"; + +beforeEach(() => resetLLMHealth()); + +describe("llm health tracker", () => { + it("starts unknown, before anything has been observed", () => { + expect(getLLMHealth().status).toBe("unknown"); + }); + + it("is ok after a success", () => { + recordLLMSuccess(); + expect(getLLMHealth().status).toBe("ok"); + }); + + it("is degraded on the first failures, not down", () => { + recordLLMFailure(new Error("LLM chain exhausted")); + expect(getLLMHealth().status).toBe("degraded"); + }); + + it("is down once failures are consistent", () => { + for (let i = 0; i < 3; i += 1) recordLLMFailure(new Error("boom")); + const health = getLLMHealth(); + expect(health.status).toBe("down"); + expect(health.consecutiveFailures).toBe(3); + expect(health.lastError).toBe("boom"); + }); + + it("recovers to ok on the next success", () => { + for (let i = 0; i < 5; i += 1) recordLLMFailure(new Error("boom")); + expect(getLLMHealth().status).toBe("down"); + recordLLMSuccess(); + const health = getLLMHealth(); + expect(health.status).toBe("ok"); + expect(health.consecutiveFailures).toBe(0); + expect(health.lastError).toBeNull(); + }); +}); diff --git a/src/lib/health.ts b/src/lib/health.ts new file mode 100644 index 0000000..6701b4a --- /dev/null +++ b/src/lib/health.ts @@ -0,0 +1,28 @@ +/** + * LLM chain health — did the last analysis actually reach a model? + * + * `callLLM` already knows whether every link in the chain refused; this just + * remembers that fact between requests so `/api/health` can say so before a + * reader does. Mirrors the same `ai-kit` tracker adopted fleet-wide (evig, + * kivvi, botsmann, hirnli, aoz-housing). + */ + +import { createHealthTracker } from "ai-kit"; + +const tracker = createHealthTracker({ downAfter: 3 }); + +export function recordLLMSuccess(): void { + tracker.recordSuccess(); +} + +export function recordLLMFailure(error: unknown): void { + tracker.recordFailure(error); +} + +export function getLLMHealth() { + return tracker.getHealth(); +} + +export function resetLLMHealth(): void { + tracker.reset(); +} diff --git a/src/lib/llm.ts b/src/lib/llm.ts index d88a381..c5694d4 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -21,6 +21,7 @@ // still gets the model-level fallback, which is what rot actually looks like. import { freeChain, usableChain, type Env, type Link } from "ai-kit"; +import { recordLLMFailure, recordLLMSuccess } from "./health"; /** Prefix for this app's per-vendor model overrides (TRUTHSEEKER_GROQ_MODELS…). */ const CHAIN_PREFIX = "TRUTHSEEKER"; @@ -76,7 +77,9 @@ export async function callLLM(prompt: string, opts: LLMOptions = {}): Promise l.provider.keyEnv)); if (rejected.size > 0 && rejected.size === configuredVendors.size) { - throw new Error( + const error = new Error( `${rejectedKeyMessage(rejected)} ${secondVendorHint(configuredVendors)}`.trim(), ); + recordLLMFailure(error); + throw error; } // Otherwise: name the whole chain, not just the last link. "gpt-oss-120b // failed" sends the reader after one model; "all 2 links failed" says the // shape of the problem is the key, the network or the budget. - throw new Error( + const exhausted = new Error( `LLM chain exhausted — all ${links.length} link(s) failed. Last: ${lastError?.message ?? "unknown"}`, ); + recordLLMFailure(exhausted); + throw exhausted; } /** HTTP statuses that mean "this key", not "this model". */