| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const PROBE_TIMEOUT_MS = 2_000; |
| const CACHE_TTL_MS = 60_000; |
|
|
| |
| |
| |
| |
| |
| |
| |
| const MODEL_FAILURE_THRESHOLD = 2; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const MODEL_QUARANTINE_MS = 10 * 60_000; |
|
|
| interface HealthEntry { |
| available: boolean; |
| checkedAt: number; |
| } |
|
|
| interface ModelEntry { |
| failures: number; |
| |
| quarantinedUntil: number; |
| lastFailureAt: number; |
| lastStatus: number; |
| } |
|
|
| const cache = new Map<string, HealthEntry>(); |
| const inFlight = new Map<string, Promise<boolean>>(); |
| const modelCache = new Map<string, ModelEntry>(); |
|
|
| |
| |
| |
| |
| |
| async function probe(url: string): Promise<boolean> { |
| try { |
| const origin = new URL(url).origin; |
| await fetch(origin, { |
| method: 'GET', |
| signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), |
| }); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export async function isProviderAvailable(apiUrl: string): Promise<boolean> { |
| const origin = new URL(apiUrl).origin; |
| const cached = cache.get(origin); |
| if (cached && Date.now() - cached.checkedAt < CACHE_TTL_MS) { |
| return cached.available; |
| } |
|
|
| |
| const existing = inFlight.get(origin); |
| if (existing) return existing; |
|
|
| const promise = probe(apiUrl).then(available => { |
| cache.set(origin, { available, checkedAt: Date.now() }); |
| inFlight.delete(origin); |
| if (!available) { |
| console.warn(`[llm-health] Provider unreachable: ${origin}`); |
| } |
| return available; |
| }); |
| inFlight.set(origin, promise); |
| return promise; |
| } |
|
|
| |
| function modelKey(apiUrl: string, model: string): string | null { |
| if (!model) return null; |
| try { |
| return `${new URL(apiUrl).origin}|${model}`; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function readModelEntry(key: string): ModelEntry | undefined { |
| const entry = modelCache.get(key); |
| if (!entry) return undefined; |
| const expiresAt = entry.quarantinedUntil > 0 |
| ? entry.quarantinedUntil |
| : entry.lastFailureAt + MODEL_QUARANTINE_MS; |
| if (Date.now() >= expiresAt) { |
| modelCache.delete(key); |
| return undefined; |
| } |
| return entry; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function isModelRejection(status: number, body: string, model: string): boolean { |
| if (status !== 400 && status !== 404) return false; |
| if (!body || !model) return false; |
|
|
| let message = body; |
| try { |
| const parsed = JSON.parse(body) as { |
| message?: unknown; |
| error?: unknown; |
| }; |
| if (typeof parsed.error === 'string') { |
| message = parsed.error; |
| } else if ( |
| parsed.error |
| && typeof parsed.error === 'object' |
| && typeof (parsed.error as { message?: unknown }).message === 'string' |
| ) { |
| message = (parsed.error as { message: string }).message; |
| } else if (typeof parsed.message === 'string') { |
| message = parsed.message; |
| } else { |
| return false; |
| } |
| } catch { |
| |
| } |
|
|
| const escapedModel = model.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| const modelRef = `(?:^|[^a-z0-9._:/-])${escapedModel}(?=$|[^a-z0-9._:/-])`; |
| const patterns = [ |
| new RegExp(`\\b(?:no such|unknown|invalid)\\s+model\\b[^\\n]{0,80}?${modelRef}`, 'i'), |
| new RegExp(`\\bmodel\\b[^\\n]{0,40}?${modelRef}[^\\n]{0,80}?\\b(?:not found|does not exist|is not available)\\b`, 'i'), |
| new RegExp(`${modelRef}[^\\n]{0,80}?\\b(?:is not a valid model(?: id)?|is not available|model[\\s_-]*not[\\s_-]*found)\\b`, 'i'), |
| ]; |
| return patterns.some((pattern) => pattern.test(message)); |
| } |
|
|
| |
| |
| |
| |
| |
| export function isModelUsable(apiUrl: string, model: string): boolean { |
| const key = modelKey(apiUrl, model); |
| if (!key) return true; |
| const entry = readModelEntry(key); |
| return !entry || entry.quarantinedUntil === 0; |
| } |
|
|
| |
| |
| |
| |
| export function recordModelFailure(apiUrl: string, model: string, status: number, body: string): void { |
| if (!isModelRejection(status, body, model)) return; |
| const key = modelKey(apiUrl, model); |
| if (!key) return; |
|
|
| const entry = readModelEntry(key) ?? { failures: 0, quarantinedUntil: 0, lastFailureAt: 0, lastStatus: 0 }; |
| entry.failures += 1; |
| entry.lastFailureAt = Date.now(); |
| entry.lastStatus = status; |
| if (entry.quarantinedUntil === 0 && entry.failures >= MODEL_FAILURE_THRESHOLD) { |
| entry.quarantinedUntil = Date.now() + MODEL_QUARANTINE_MS; |
| console.warn( |
| `[llm-health] Model quarantined for ${MODEL_QUARANTINE_MS / 1000}s: ${key} — rejected ${entry.failures}x with HTTP ${status}`, |
| ); |
| } |
| modelCache.set(key, entry); |
| } |
|
|
| |
| export function recordModelSuccess(apiUrl: string, model: string): void { |
| if (modelCache.size === 0) return; |
| const key = modelKey(apiUrl, model); |
| if (key) modelCache.delete(key); |
| } |
|
|
| |
| |
| |
| |
| export function getLlmModelHealthStatus(): Record<string, { |
| quarantined: boolean; |
| failures: number; |
| quarantinedUntil: number; |
| lastStatus: number; |
| }> { |
| const status: Record<string, { |
| quarantined: boolean; |
| failures: number; |
| quarantinedUntil: number; |
| lastStatus: number; |
| }> = {}; |
| for (const key of [...modelCache.keys()]) { |
| |
| |
| const entry = readModelEntry(key); |
| if (!entry) continue; |
| status[key] = { |
| quarantined: entry.quarantinedUntil > 0, |
| failures: entry.failures, |
| quarantinedUntil: entry.quarantinedUntil, |
| lastStatus: entry.lastStatus, |
| }; |
| } |
| return status; |
| } |
|
|
| |
| |
| |
| |
| export function getLlmHealthStatus(): Record<string, { available: boolean; checkedAt: number }> { |
| const status: Record<string, { available: boolean; checkedAt: number }> = {}; |
| for (const [origin, entry] of cache) { |
| status[origin] = { available: entry.available, checkedAt: entry.checkedAt }; |
| } |
| return status; |
| } |
|
|
| |
| |
| |
| |
| export async function reprobeAll(): Promise<void> { |
| const origins = [...cache.keys()]; |
| await Promise.all(origins.map(async (origin) => { |
| const available = await probe(origin); |
| cache.set(origin, { available, checkedAt: Date.now() }); |
| })); |
| } |
|
|
| |
| |
| |
| |
| export function warmHealthCache(): void { |
| const providerUrls: string[] = []; |
|
|
| const ollamaUrl = typeof process !== 'undefined' |
| ? (process.env?.OLLAMA_API_URL || process.env?.LLM_API_URL) |
| : undefined; |
| if (ollamaUrl) providerUrls.push(ollamaUrl); |
|
|
| if (typeof process !== 'undefined' && process.env?.GROQ_API_KEY) { |
| providerUrls.push('https://api.groq.com/openai/v1/chat/completions'); |
| } |
| if (typeof process !== 'undefined' && process.env?.OPENROUTER_API_KEY) { |
| providerUrls.push('https://openrouter.ai/api/v1/chat/completions'); |
| } |
|
|
| for (const url of providerUrls) { |
| void isProviderAvailable(url); |
| } |
| } |
|
|
| |
| export const __testing__ = { |
| MODEL_FAILURE_THRESHOLD, |
| MODEL_QUARANTINE_MS, |
| reset(): void { |
| cache.clear(); |
| inFlight.clear(); |
| modelCache.clear(); |
| }, |
| }; |
|
|