From 64b9e70c8d21ea0fd6e89516e5d0e687c55b756b Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:11:26 +0200 Subject: [PATCH] feat: adopt ai-kit's tryChain + createHealthTracker; add /api/health hirnli has already been silently taken down once by a single retired Groq model id -- every route and script here called ONE pinned model, once, with nothing between that and total failure. This closes that gap the same way botsmann's did this session. - scripts/lib/groq-client.ts: callGroq now walks ai-kit's fallback chain for Groq via tryChain when the caller leaves the model unspecified, instead of calling one hardcoded id. GROQ_MODELS' alias table is now derived FROM that chain rather than hardcoding the same ids a second time. An explicit model/alias is still honoured as-is and alone, unchanged. - src/app/api/ai/gesuch-section/route.ts: this route hand-rolled its OWN separate Groq fetch, with its own hardcoded model id -- a second copy of exactly what groq-client.ts already did, despite that file's own docstring saying it was "extracted from" this route. Now calls callGroq directly; ~50 lines of duplicated fetch/error-handling code deleted. - src/lib/llm-health.ts + src/app/api/health/route.ts (both new): hirnli had no health endpoint at all. Records success/failure around the gesuch-section route via ai-kit's createHealthTracker, exposed at /api/health (liveness) and /api/health?strict=1 (readiness -- 503 only when the LLM chain itself is down, never on a restart-proof problem). ai-kit bumped to v0.5.0. Full verify green: lint, lint:umlauts, typecheck, 889 tests (17 new), production build. Co-Authored-By: Claude Sonnet 5 --- next-env.d.ts | 1 + package-lock.json | 43 +++++++- package.json | 1 + scripts/lib/groq-client.test.ts | 108 ++++++++++++++++++- scripts/lib/groq-client.ts | 139 ++++++++++++++++--------- src/app/api/ai/gesuch-section/route.ts | 98 ++++++----------- src/app/api/health/route.ts | 25 +++++ src/lib/llm-health.test.ts | 42 ++++++++ src/lib/llm-health.ts | 44 ++++++++ 9 files changed, 379 insertions(+), 122 deletions(-) create mode 100644 src/app/api/health/route.ts create mode 100644 src/lib/llm-health.test.ts create mode 100644 src/lib/llm-health.ts diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..ce4e94a6 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 5d172192..3b9e739f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { - "name": "revamp-info", + "name": "hirnli", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "revamp-info", + "name": "hirnli", "version": "0.1.0", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@react-pdf/renderer": "^4.8.1", + "ai-kit": "github:bitbaum/ai-kit#v0.5.0", "chart.js": "^4.5.1", "drizzle-orm": "^0.45.1", "fuse.js": "^7.1.0", @@ -43,7 +44,7 @@ "vitest": "^4.1.11" }, "engines": { - "node": ">=22", + "node": ">=20", "npm": ">=11" } }, @@ -4629,6 +4630,42 @@ "node": ">=0.8" } }, + "node_modules/ai-forms": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ai-forms/-/ai-forms-0.1.2.tgz", + "integrity": "sha512-agadca4pN0iGlZTlNy1Vo2SmnQB+0dNkrQSDE3wCJYeaVxKDs5KaxiwGj5+GL6mVYcI1xZ8pwnT9kL9WcxnBHA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/ai-kit": { + "version": "0.5.0", + "resolved": "git+ssh://git@github.com/bitbaum/ai-kit.git#de7319a29d8f85f9dc431993623da11084b9ead7", + "license": "MIT", + "dependencies": { + "ai-forms": "^0.1.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", diff --git a/package.json b/package.json index e502d32c..9de1e687 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@react-pdf/renderer": "^4.8.1", + "ai-kit": "github:bitbaum/ai-kit#v0.5.0", "chart.js": "^4.5.1", "drizzle-orm": "^0.45.1", "fuse.js": "^7.1.0", diff --git a/scripts/lib/groq-client.test.ts b/scripts/lib/groq-client.test.ts index 7e34960e..1d3b170e 100644 --- a/scripts/lib/groq-client.test.ts +++ b/scripts/lib/groq-client.test.ts @@ -17,8 +17,11 @@ * sending a nonsense one looks exactly the same from the outside. That is the * argument for testing the resolution rather than the ids. */ -import { describe, expect, it } from 'vitest'; -import { GROQ_MODELS, resolveModel } from './groq-client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { freeChain, providerModels } from 'ai-kit'; +import { GROQ_MODELS, callGroq, resolveModel } from './groq-client'; + +const CHAIN_MODEL_COUNT = providerModels(freeChain('HIRNLI')[0]).length; describe('resolveModel', () => { it('turns a size alias into a real id', () => { @@ -71,3 +74,104 @@ describe('the model ids themselves', () => { expect(GROQ_MODELS.small).not.toBe(GROQ_MODELS.large); }); }); + +/** + * `callGroq` used to call ONE pinned model, once — the exact shape that let a + * single Groq retirement take down every route and script in this repo at + * the same moment. It now walks `ai-kit`'s fallback chain for Groq, so these + * pin the demote-on-failure behaviour the fix actually depends on. + */ +describe('callGroq — fallback across the chain', () => { + const originalKey = process.env.GROQ_API_KEY; + let fetchMock: ReturnType; + + beforeEach(() => { + process.env.GROQ_API_KEY = 'test-key'; + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + process.env.GROQ_API_KEY = originalKey; + vi.unstubAllGlobals(); + }); + + it('demotes to the next model in the chain when the first is retired', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => '{"error":{"code":"model_not_found"}}', + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ choices: [{ message: { content: 'second model answered' } }] }), + }); + + const result = await callGroq('system', 'user'); + + expect(result).toEqual({ ok: true, content: 'second model answered', usage: undefined }); + expect(fetchMock).toHaveBeenCalledTimes(2); + // The first attempt must have asked for the FIRST chain model, and the + // second for a DIFFERENT one — a retry that resends the same id is not a + // fallback. + const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body); + const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(firstBody.model).not.toBe(secondBody.model); + }); + + it('reports every model it tried when the whole chain is exhausted', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + text: async () => 'invalid_api_key', + }); + + const result = await callGroq('system', 'user'); + + expect(result.ok).toBe(false); + // Every model in the chain must have been tried — not just the first. + expect(fetchMock).toHaveBeenCalledTimes(CHAIN_MODEL_COUNT); + // Every link's failure should be named, not just the last one tried. + expect(result.error).toMatch(/link\(s\) failed/); + }); + + it('an explicit model is called once and alone, not folded into the chain', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ choices: [{ message: { content: 'ok' } }] }), + }); + + const result = await callGroq('system', 'user', { model: 'small' }); + + expect(result.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + // The alias must still be resolved, exactly as before this change. + expect(body.model).toBe(GROQ_MODELS.small); + }); + + it('an explicit model that fails does NOT fall through to the rest of the chain', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + text: async () => 'model_not_found', + }); + + const result = await callGroq('system', 'user', { model: 'small' }); + + expect(result.ok).toBe(false); + // Naming a model and silently answering from a different one would be + // worse than failing — so exactly one attempt, not a walk. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('reports missing key without calling fetch at all', async () => { + process.env.GROQ_API_KEY = ''; + + const result = await callGroq('system', 'user'); + + expect(result).toEqual({ ok: false, error: 'GROQ_API_KEY not set in environment' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/lib/groq-client.ts b/scripts/lib/groq-client.ts index 0126a4e3..5f117336 100644 --- a/scripts/lib/groq-client.ts +++ b/scripts/lib/groq-client.ts @@ -9,33 +9,41 @@ * Model: see GROQ_MODELS below — never spell an id out at a call site */ +import { freeChain, providerModels, tryChain, type Link } from 'ai-kit'; + const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions'; -// Groq retired the entire llama-3.x family, so the previous default -// `llama-3.3-70b-versatile` returned 404 on every call with a valid key. -// Verified present in the live catalogue on 2026-08-27, and now checked daily -// by dotfiles/scripts/ci/model-pin-audit.mjs. -const GROQ_MODEL = 'openai/gpt-oss-120b'; +// Groq retired the entire llama-3.x family, so the previous single pinned +// default `llama-3.3-70b-versatile` returned 404 on every call with a valid +// key, and there was nothing between that and total failure — this module +// called ONE model, once. `GROQ_PROVIDER` (from `ai-kit`, checked daily by +// fleet/scripts/ci/model-pin-audit.mjs) is now a fallback LIST, and `callGroq` +// walks it via `tryChain` when the caller leaves the model unspecified. +const GROQ_PROVIDER = freeChain('HIRNLI')[0]; const DEFAULT_TIMEOUT_MS = 60_000; const DEFAULT_MAX_TOKENS = 2048; const DEFAULT_TEMPERATURE = 0.3; +// The ids themselves now come from `ai-kit`'s chain, not a literal here — +// this file used to be the second of the two places this repo hardcoded +// them (the other was the gesuch-section route), so a retirement had to be +// fixed twice. `SECONDARY_MODEL` falls back to the primary rather than going +// `undefined` if the fleet's chain for HIRNLI is ever pared to one model. +const [PRIMARY_MODEL, SECONDARY_MODEL = PRIMARY_MODEL] = providerModels(GROQ_PROVIDER); + /** * Model ALIASES — a size role, resolved to a live id by `resolveModel` below. * - * Both previous ids were retired together when Groq withdrew the llama-3.x - * family, so every alias here pointed at a 404. - * * The old keys `70b` and `8b` are kept because they are a CLI contract: * `pipeline-graduate.ts --model=8b` is typed by a human, and silently changing * what that accepts is a worse failure than an inaccurate key name. They now * describe a ROLE (bigger / faster) rather than a parameter count. */ export const GROQ_MODELS = { - large: 'openai/gpt-oss-120b', - small: 'openai/gpt-oss-20b', + large: PRIMARY_MODEL, + small: SECONDARY_MODEL, /** @deprecated size-named aliases, kept so existing --model= flags keep working */ - '70b': 'openai/gpt-oss-120b', - '8b': 'openai/gpt-oss-20b', + '70b': PRIMARY_MODEL, + '8b': SECONDARY_MODEL, } as const; /** @@ -76,46 +84,42 @@ export interface GroqResult { usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; } +interface CallOnceOptions { + maxTokens: number; + temperature: number; + timeoutMs: number; + json: boolean; +} + /** - * Call Groq API with system + user message. - * Returns the assistant's response content. + * One call, one model. `callGroq`'s fallback walk and its single-explicit- + * model path both go through here, so there is exactly one fetch + * implementation, not two. Throws on any failure — the caller decides + * whether that means "try the next model" or "report it". */ -export async function callGroq( +async function callGroqOnce( + model: string, + apiKey: string, systemPrompt: string, userPrompt: string, - options: GroqOptions = {}, -): Promise { - const apiKey = process.env.GROQ_API_KEY; - if (!apiKey) { - return { ok: false, error: 'GROQ_API_KEY not set in environment' }; - } - - const { - maxTokens = DEFAULT_MAX_TOKENS, - temperature = DEFAULT_TEMPERATURE, - timeoutMs = DEFAULT_TIMEOUT_MS, - json = false, - model = GROQ_MODEL, - } = options; - + options: CallOnceOptions, +): Promise<{ content: string; usage?: GroqResult['usage'] }> { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs); try { const body: Record = { - // Resolved, not passed through: a caller naming a size alias must not - // have that alias sent to the vendor as if it were a model id. - model: resolveModel(model), + model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], - temperature, - max_tokens: maxTokens, + temperature: options.temperature, + max_tokens: options.maxTokens, stream: false, }; - if (json) { + if (options.json) { body.response_format = { type: 'json_object' }; } @@ -129,30 +133,69 @@ export async function callGroq( signal: controller.signal, }); - clearTimeout(timeoutId); - if (!response.ok) { const errText = await response.text().catch(() => ''); - return { ok: false, error: `Groq API HTTP ${response.status}: ${errText.substring(0, 200)}` }; + throw new Error(`Groq API HTTP ${response.status}: ${errText.substring(0, 200)}`); } const data = await response.json(); const content = data.choices?.[0]?.message?.content?.trim(); if (!content) { - return { ok: false, error: 'Empty response from Groq' }; + throw new Error('Empty response from Groq'); } - return { - ok: true, - content, - usage: data.usage, - }; + return { content, usage: data.usage }; } catch (err) { - clearTimeout(timeoutId); if ((err as Error).name === 'AbortError') { - return { ok: false, error: `Groq timeout after ${timeoutMs}ms` }; + throw new Error(`Groq timeout after ${options.timeoutMs}ms`); } + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Call Groq API with system + user message. + * Returns the assistant's response content. + * + * When the caller leaves `model` unspecified, walks the fleet's fallback + * chain for Groq via `ai-kit`'s `tryChain` instead of calling one pinned + * model once — a retired id used to mean this whole module was down. + * An explicit `model`/alias is honoured as-is and alone: silently + * answering from a different model than asked for is worse than failing. + */ +export async function callGroq( + systemPrompt: string, + userPrompt: string, + options: GroqOptions = {}, +): Promise { + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) { + return { ok: false, error: 'GROQ_API_KEY not set in environment' }; + } + + const { + maxTokens = DEFAULT_MAX_TOKENS, + temperature = DEFAULT_TEMPERATURE, + timeoutMs = DEFAULT_TIMEOUT_MS, + json = false, + model, + } = options; + + // Resolved, not passed through: a caller naming a size alias must not have + // that alias sent to the vendor as if it were a model id. + const models = model ? [resolveModel(model)] : providerModels(GROQ_PROVIDER); + const chain: Link[] = models.map((m) => ({ provider: GROQ_PROVIDER, model: m })); + const callOnceOptions: CallOnceOptions = { maxTokens, temperature, timeoutMs, json }; + + try { + const { content, usage } = await tryChain(chain, { + attempt: (link) => callGroqOnce(link.model, apiKey, systemPrompt, userPrompt, callOnceOptions), + }); + return { ok: true, content, usage }; + } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } diff --git a/src/app/api/ai/gesuch-section/route.ts b/src/app/api/ai/gesuch-section/route.ts index 74e871ae..6d11a4fa 100644 --- a/src/app/api/ai/gesuch-section/route.ts +++ b/src/app/api/ai/gesuch-section/route.ts @@ -4,7 +4,7 @@ * POST /api/ai/gesuch-section * * Rewrites a gesuch text section based on a user instruction. - * Uses Groq (llama-3.3-70b) for fast inference. + * Uses Groq, via `scripts/lib/groq-client.ts`'s fallback chain, for fast inference. * * Body: { instruction, currentText, fieldPath, fieldDescription?, * foundationName?, foundationPurpose?, foundationType?, @@ -18,21 +18,17 @@ import { z } from 'zod'; import { ORG_PROFILE } from '@/lib/config/org-profile'; import { SHARED_ORG_NUMBERS } from '@/lib/config/shared-org-numbers.generated'; import { resolveTypeLabel } from '@/lib/config/foundations/metadata'; -import { - API_ERR_BAD_REQUEST, - API_ERR_AI_NOT_CONFIGURED, - API_ERR_AI_UNAVAILABLE, - API_ERR_AI_NO_RESPONSE, - API_ERR_AI_TIMEOUT, - API_ERR_INTERNAL, -} from '@/lib/utils/errors'; -import { apiError } from '@/lib/api/route-helpers'; - -const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions'; -// Retired with the rest of Groq's llama-3.x family — this route answered 404 -// with a valid key, which surfaces to the user as "AI unavailable". Verified -// live 2026-08-27; checked daily by dotfiles' model-pin audit. -const GROQ_MODEL = 'openai/gpt-oss-120b'; +import { API_ERR_BAD_REQUEST, API_ERR_AI_NOT_CONFIGURED, API_ERR_AI_UNAVAILABLE } from '@/lib/utils/errors'; +import { callGroq } from '../../../../../scripts/lib/groq-client'; +import { recordLLMFailure, recordLLMSuccess } from '@/lib/llm-health'; + +// This route used to hand-roll its own Groq fetch — a SECOND copy of the +// same request `scripts/lib/groq-client.ts` already made, with its own +// hardcoded model id. Both copies were retired together when Groq withdrew +// the whole llama-3.x family, and both had to be fixed by hand. `callGroq` +// now owns the request AND walks the fleet's model fallback chain, so a +// future retirement is one fix, not two, and this route survives a single +// retired id instead of going fully dark. /** Build system prompt from ORG_PROFILE config (no hardcoded metrics) */ function buildSystemPrompt(): string { @@ -191,58 +187,22 @@ export async function POST(request: NextRequest) { const userMessage = buildUserMessage(body); - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); - - const response = await fetch(GROQ_API_URL, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: GROQ_MODEL, - messages: [ - { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: userMessage }, - ], - temperature: 0.35, - max_tokens: 1024, - stream: false, - }), - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errText = await response.text().catch(() => ''); - console.error('Groq API error:', response.status, errText); - return NextResponse.json( - { success: false, error: API_ERR_AI_UNAVAILABLE }, - { status: 502 }, - ); - } - - const data = await response.json(); - const rewritten = data.choices?.[0]?.message?.content?.trim(); - - if (!rewritten) { - return NextResponse.json( - { success: false, error: API_ERR_AI_NO_RESPONSE }, - { status: 502 }, - ); - } - - return NextResponse.json({ success: true, data: { rewritten } }); - } catch (err) { - if ((err as Error).name === 'AbortError') { - return NextResponse.json( - { success: false, error: API_ERR_AI_TIMEOUT }, - { status: 504 }, - ); - } - return apiError('AI gesuch-section', err, API_ERR_INTERNAL); + const result = await callGroq(SYSTEM_PROMPT, userMessage, { + temperature: 0.35, + maxTokens: 1024, + timeoutMs: 30000, + }); + + if (!result.ok) { + console.error('Groq API error:', result.error); + recordLLMFailure(result.error); + // callGroq already tried every model in the fleet's chain — a single + // "unavailable" covers HTTP errors, an empty response and a timeout + // alike, since a multi-model walk can fail each attempt a different way + // and there is no one status code that would be more honest than this. + return NextResponse.json({ success: false, error: API_ERR_AI_UNAVAILABLE }, { status: 502 }); } + + recordLLMSuccess(); + return NextResponse.json({ success: true, data: { rewritten: result.content } }); } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 00000000..c2a8b548 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,25 @@ +/** + * Health API + * + * GET /api/health -> liveness: is the process up? (fast, no LLM check) + * GET /api/health?strict=1 -> readiness: is the AI layer actually answering? + * + * Split the same way botsmann's health route learned to the hard way: a dead + * Groq key is not fixed by a restart, so it must never fail the check a + * deploy gate or process supervisor uses to decide whether to kill the app — + * only the check something monitoring the AI feature specifically opts into. + */ + +import { type NextRequest, NextResponse } from 'next/server'; +import { getLLMHealth } from '@/lib/llm-health'; + +export async function GET(request: NextRequest) { + const strict = request.nextUrl.searchParams.get('strict') === '1'; + const llm = getLLMHealth(); + + if (strict && llm.status === 'down') { + return NextResponse.json({ success: false, error: 'AI-Dienst nicht erreichbar', data: { llm } }, { status: 503 }); + } + + return NextResponse.json({ success: true, data: { status: 'healthy', llm } }); +} diff --git a/src/lib/llm-health.test.ts b/src/lib/llm-health.test.ts new file mode 100644 index 00000000..aea2c3f6 --- /dev/null +++ b/src/lib/llm-health.test.ts @@ -0,0 +1,42 @@ +/** + * hirnli had no way to know its AI routes were down until someone tried one + * by hand. This pins the state machine `/api/health?strict=1` depends on. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { getLLMHealth, recordLLMFailure, recordLLMSuccess, resetLLMHealth } from './llm-health'; + +describe('llm health tracker', () => { + beforeEach(() => resetLLMHealth()); + + 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('401 invalid_api_key')); + expect(getLLMHealth().status).toBe('degraded'); + }); + + it('is down once failures are consistent', () => { + for (let i = 0; i < 3; i += 1) recordLLMFailure(new Error('401 invalid_api_key')); + const health = getLLMHealth(); + expect(health.status).toBe('down'); + expect(health.consecutiveFailures).toBe(3); + expect(health.lastError).toContain('401'); + }); + + 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/llm-health.ts b/src/lib/llm-health.ts new file mode 100644 index 00000000..2105a54a --- /dev/null +++ b/src/lib/llm-health.ts @@ -0,0 +1,44 @@ +/** + * Observed health of the LLM (Groq) chain used by /api/ai/*. + * + * hirnli's AI routes have already been silently dead once: Groq withdrew the + * whole llama-3.x family and every route calling it returned "AI unavailable" + * with a perfectly valid key, discovered by hand rather than by any check — + * this repo had no `/api/health` at all until this file. A friendly 5xx is + * still a check nothing was watching. + * + * Thin wrapper around `ai-kit`'s `createHealthTracker`: one shared in-process + * instance, since hirnli runs as a single service and module state is shared + * by every request. If it is ever scaled horizontally this becomes + * per-instance and wants a shared store. + */ + +import { createHealthTracker } from 'ai-kit'; + +const tracker = createHealthTracker({ downAfter: 3 }); + +/** Call after a generation that produced usable content. */ +export function recordLLMSuccess(): void { + tracker.recordSuccess(); +} + +/** Call when generation threw, or returned nothing usable. */ +export function recordLLMFailure(error: unknown): void { + tracker.recordFailure(error); +} + +export function getLLMHealth() { + const health = tracker.getHealth(); + return { + status: health.status, + consecutiveFailures: health.consecutiveFailures, + lastError: health.lastError, + lastSuccessAt: health.lastSuccessAt ? new Date(health.lastSuccessAt).toISOString() : null, + lastFailureAt: health.lastFailureAt ? new Date(health.lastFailureAt).toISOString() : null, + }; +} + +/** Test seam. */ +export function resetLLMHealth(): void { + tracker.reset(); +}