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();
+}