Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ const customJestConfig = {
],
moduleDirectories: ['node_modules', '<rootDir>/'],
transformIgnorePatterns: [
'/node_modules/(?!(@auth|next-auth|next-intl|use-intl|cookie)/)',
// ai-kit is ESM-only ("type": "module", no require condition). Jest runs
// CJS, so an untransformed import dies with `Unexpected token 'export'`
// the moment anything reaches it — botsmann shipped exactly this bug
// once. Listed here AND injected into next/jest's own generated pattern
// below, for the same reason `cookie` needed both.
'/node_modules/(?!(@auth|next-auth|next-intl|use-intl|cookie|ai-kit)/)',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
Expand All @@ -60,10 +65,12 @@ const buildConfig = createJestConfig(customJestConfig)
// customJestConfig above cannot rescue an ESM-only package on its own; the
// package must also be injected into next/jest's generated allowlist here.
// cookie v2 is pure ESM ("type": "module") and is imported by src/lib/auth.
// ai-kit is the same shape, imported by src/lib/hirn/health.ts and
// src/lib/ai/health.ts.
module.exports = async () => {
const config = await buildConfig()
config.transformIgnorePatterns = config.transformIgnorePatterns.map((pattern) =>
pattern.includes('(?!(next-auth|') ? pattern.replace('(?!(next-auth|', '(?!(cookie|next-auth|') : pattern
pattern.includes('(?!(next-auth|') ? pattern.replace('(?!(next-auth|', '(?!(ai-kit|cookie|next-auth|') : pattern
)
return config
}
42 changes: 39 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,14 @@
"dependencies": {
"@auth/pg-adapter": "^1.11.3",
"@aws-sdk/client-s3": "^3.1117.0",
"@fleet/ai-forms": "github:catomean/ai-forms#v0.1.2",
"@fleet/ai-forms": "github:bitbaum/ai-forms#v0.1.2",
"@sentry/nextjs": "^10.71.0",
"@tiptap/extension-link": "^3.30.5",
"@tiptap/extension-table": "^3.30.5",
"@tiptap/pm": "^3.30.5",
"@tiptap/react": "^3.30.5",
"@tiptap/starter-kit": "^3.30.5",
"ai-kit": "github:bitbaum/ai-kit#v0.5.0",
"bcryptjs": "^2.4.3",
"busboy": "^1.6.0",
"clsx": "^2.1.1",
Expand Down
32 changes: 31 additions & 1 deletion src/app/api/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { sql } from 'drizzle-orm'
import { MEILISEARCH_URL } from '@/config/urls'
import { logger } from '@/lib/logger'
import { apiSuccess } from '@/lib/api/helpers'
import { getLLMHealth } from '@/lib/hirn/health'
import { getAIToolsHealth } from '@/lib/ai/health'

interface ServiceStatus {
status: 'healthy' | 'unhealthy' | 'degraded'
Expand All @@ -23,9 +25,35 @@ interface HealthResponse {
services: {
database: ServiceStatus
meilisearch: ServiceStatus
hirn: ServiceStatus
aiTools: ServiceStatus
}
}

/**
* A PASSIVE check — reports what the last real chat/AI-tools attempt
* actually did, rather than making a fresh vendor call the way
* `checkDatabase`/`checkMeilisearch` do. "unknown" (nothing has been
* attempted yet since the last restart) reads as healthy, same as the
* process itself: there is no evidence of a problem, and treating
* "untested" as a failure would flap this endpoint on every deploy.
*
* A "down" tracker maps to `unhealthy` here, but — same as Meilisearch —
* the aggregation below only escalates the OVERALL status to `unhealthy`
* (503) when the database itself is down. A dead API key does not get
* fixed by a restart, so it must never fail whatever gate decides whether
* to kill and restart this process.
*/
function fromTrackerStatus(health: ReturnType<typeof getLLMHealth>): ServiceStatus {
if (health.status === 'down') {
return { status: 'unhealthy', message: health.lastError ?? undefined }
}
if (health.status === 'degraded') {
return { status: 'degraded', message: health.lastError ?? undefined }
}
return { status: 'healthy' }
}

async function checkDatabase(): Promise<ServiceStatus> {
const start = Date.now()
try {
Expand Down Expand Up @@ -77,8 +105,10 @@ export async function GET() {
checkDatabase(),
checkMeilisearch(),
])
const hirn = fromTrackerStatus(getLLMHealth())
const aiTools = fromTrackerStatus(getAIToolsHealth())

const services = { database, meilisearch }
const services = { database, meilisearch, hirn, aiTools }

// Determine overall status
const statuses = Object.values(services).map(s => s.status)
Expand Down
9 changes: 2 additions & 7 deletions src/app/api/hirn/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import { NextRequest } from 'next/server'
import { withAuth } from '@/lib/api/middleware'
import { getDefaultChatProvider, type Message } from '@/lib/hirn/providers'
import { getChatResponse, type Message } from '@/lib/hirn/providers'
import { buildPublicSystemPrompt } from '@/lib/hirn/public-prompt'
import { resolveHirnContext } from '@/config/hirn/page-contexts'
import { apiSuccess, apiError, apiRateLimited } from '@/lib/api/helpers'
Expand Down Expand Up @@ -47,12 +47,7 @@ export const POST = withAuth(async (request: NextRequest, session) => {

// Same provider layer as the admin route (system default — public users
// have no per-user provider settings).
const provider = await getDefaultChatProvider()
const response = await provider.chat({
messages,
temperature: 0.7,
maxTokens: 1024,
})
const response = await getChatResponse({ messages, temperature: 0.7, maxTokens: 1024 })

logger.info('Public Hirn chat response generated', {
userId: session.user.id,
Expand Down
35 changes: 35 additions & 0 deletions src/lib/ai/__tests__/health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Same state machine as ../../hirn/health.ts, on a SEPARATE tracker — see
* the module docstring for why the two AI stacks are not merged into one
* health signal.
*/
import { getAIToolsHealth, recordAIToolsFailure, recordAIToolsSuccess, resetAIToolsHealth } from '../health'

describe('ai tools health tracker', () => {
beforeEach(() => resetAIToolsHealth())

it('starts unknown, before anything has been observed', () => {
expect(getAIToolsHealth().status).toBe('unknown')
})

it('is ok after a success', () => {
recordAIToolsSuccess()
expect(getAIToolsHealth().status).toBe('ok')
})

it('is down once failures are consistent, carrying the built failure message', () => {
for (let i = 0; i < 3; i += 1) recordAIToolsFailure('KI-Service: API-Schlüssel ungültig oder abgelaufen.')
const health = getAIToolsHealth()
expect(health.status).toBe('down')
expect(health.lastError).toContain('API-Schlüssel')
})

it('reset returns to unknown, clearing every field', () => {
recordAIToolsFailure('boom')
resetAIToolsHealth()
const health = getAIToolsHealth()
expect(health.status).toBe('unknown')
expect(health.consecutiveFailures).toBe(0)
expect(health.lastError).toBeNull()
})
})
44 changes: 44 additions & 0 deletions src/lib/ai/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Observed health of `callWithFallback`/`callVisionWithFallback` — the
* cascade behind form-assist, protocol/task/vote advisors, smart product
* entry, and blog translation.
*
* A SEPARATE tracker from `../hirn/health.ts` on purpose: this module and
* `../hirn/providers` are two independent AI stacks with their own config
* loading and their own cascades, even though both ultimately read
* `hirnProviderSettings`. A bug isolated to one (a stale cache, a broken
* fallback order) should show up as one named service going down in
* `/api/health`, not get averaged away inside a single "AI" bucket.
*
* Deliberately in-process — see `../hirn/health.ts` for why.
*/

import { createHealthTracker } from 'ai-kit'

const tracker = createHealthTracker({ downAfter: 3 })

/** Call after `callWithFallback`/`callVisionWithFallback` returns a result. */
export function recordAIToolsSuccess(): void {
tracker.recordSuccess()
}

/** Call when every provider in the cascade failed (a `null` return). */
export function recordAIToolsFailure(error: unknown): void {
tracker.recordFailure(error)
}

export function getAIToolsHealth() {
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 resetAIToolsHealth(): void {
tracker.reset()
}
5 changes: 5 additions & 0 deletions src/lib/ai/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { hirnProviderSettings } from '@/db/schema'
import { eq, desc } from 'drizzle-orm'
import { OLLAMA_URL, APP_URL } from '@/config/urls'
import { ORG } from '@/config/org'
import { recordAIToolsFailure, recordAIToolsSuccess } from './health'

// =============================================================================
// CONFIGURATION (SSOT - all AI provider settings in one place)
Expand Down Expand Up @@ -442,6 +443,7 @@ export async function callWithFallback(opts: CallOptions): Promise<CallResult |
})
}

recordAIToolsSuccess()
return {
text: result.text,
model: result.model,
Expand All @@ -454,6 +456,7 @@ export async function callWithFallback(opts: CallOptions): Promise<CallResult |
failures: failedProviders.map(p => ({ provider: p.provider, reason: p.reason, message: p.message })),
})

recordAIToolsFailure(buildFailureMessage(failedProviders))
return null
}

Expand Down Expand Up @@ -577,9 +580,11 @@ export async function callVisionWithFallback(opts: VisionCallOptions): Promise<C
continue
}
if (failed.length > 0) logger.info(`Vision fallback to ${result.provider}`, { failed: failed.map(p => `${p.provider}:${p.reason}`) })
recordAIToolsSuccess()
return { text: result.text, model: result.model, provider: result.provider, failedProviders: failed }
}
logger.error('All vision providers failed', { failures: failed.map(p => ({ provider: p.provider, reason: p.reason })) })
recordAIToolsFailure(buildFailureMessage(failed))
return null
}

Expand Down
5 changes: 2 additions & 3 deletions src/lib/deliverables/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import path from 'node:path'
import { readFile } from 'node:fs/promises'
import { getDefaultChatProvider, type Message } from '@/lib/hirn/providers'
import { getChatResponse, type Message } from '@/lib/hirn/providers'
import { ingestDocument } from '@/lib/hirn/ingestion'
import { searchSimilar, formatContext } from '@/lib/hirn/retrieval'
import { isTextFile } from '@/config/deliverables'
Expand Down Expand Up @@ -173,8 +173,7 @@ REGELN:
{ role: 'user', content: message },
]

const provider = await getDefaultChatProvider()
const response = await provider.chat({ messages, temperature: 0.3, maxTokens: 900 })
const response = await getChatResponse({ messages, temperature: 0.3, maxTokens: 900 })
// Enforce Swiss German deterministically — the model doesn't always honour the
// «ss statt ß» rule from the prompt. Safe: ß→ss is always correct in de-CH.
return response.content.replace(/ß/g, 'ss')
Expand Down
20 changes: 10 additions & 10 deletions src/lib/hirn/__tests__/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,17 @@ jest.mock('drizzle-orm', () => ({
count: jest.fn().mockReturnValue({ __count: 0 }),
}))

// mockChatFn and mockGetDefaultChatProvider are declared here but only
// initialized after imports run; the closure in jest.mock captures them
// by reference so they're available when tests execute.
const mockChatFn = jest.fn()
const mockGetDefaultChatProvider = jest.fn()
// mockGetChatResponse is declared here but only initialized after imports
// run; the closure in jest.mock captures it by reference so it's available
// when tests execute.
const mockGetChatResponse = jest.fn()

jest.mock('../providers', () => ({
// Wrapper captures mockGetDefaultChatProvider by reference (not by value),
// so it resolves correctly when tests run (after module-level init).
getDefaultChatProvider: (...args: unknown[]) => mockGetDefaultChatProvider.apply(null, args),
// Wrapper captures mockGetChatResponse by reference (not by value), so it
// resolves correctly when tests run (after module-level init). chat.ts
// calls getChatResponse (provider selection + generation + health
// recording in one step) rather than getDefaultChatProvider directly.
getChatResponse: (...args: unknown[]) => mockGetChatResponse.apply(null, args),
}))

jest.mock('../system-prompt', () => ({
Expand Down Expand Up @@ -158,13 +159,12 @@ beforeEach(() => {
mockDbInsert.mockImplementation(() => makeChain([]))
mockDbDelete.mockImplementation(() => makeChain([]))
mockDbExecute.mockResolvedValue({ rows: [] })
mockChatFn.mockResolvedValue({
mockGetChatResponse.mockResolvedValue({
content: 'Hier ist meine Antwort.',
provider: 'groq',
model: 'groq:llama-3.3-70b',
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
})
mockGetDefaultChatProvider.mockResolvedValue({ chat: mockChatFn })
;(parseActionEnvelope as jest.Mock).mockReturnValue({ actions: [], parsingError: null })
;(stripActionBlock as jest.Mock).mockImplementation((c: string) => c)
})
Expand Down
Loading
Loading