feat: phase 1 of AI pipeline hardening — single LLM client + tier-aware models
Implements phase 1 of AI_PIPELINE_HARDENING_PLAN.md. Every Anthropic call now goes through one module that owns retry, timeout, abort, structured- output parsing, schema validation, and best-effort call telemetry. * src/lib/llm.js — single callLLM entry point. Resolves model per tier (fast / standard / reasoning) with admin:model legacy fallback for the standard tier; 60s default timeout via AbortController; balanced-brace JSON extraction; LLMHttpError, LLMTruncatedError, LLMOutputError, and LLMValidationError surface clearly distinct failure modes. * src/lib/llmRetry.js — exponential backoff with full jitter, retries only on transient HTTP statuses, honours Retry-After up to 60s, never retries on AbortError. * src/lib/llmSchemas.js — Zod schemas for every structured task plus normalizeHandbookResult (collapses legacy "executes" relations into the canonical "executed_by" vocabulary). * src/lib/api.js — thin shim over callLLM so existing callers (extraction pipeline, learning, quiz, R42, knowledge graph) keep working unchanged. * src/lib/__tests__/ — 32 Vitest cases covering parse paths, error surfaces, simulation mode, model resolution, and schema validation. * src/pages/Admin/index.jsx — three model inputs (fast / standard / reasoning) replacing the single legacy field; legacy value falls back for the standard tier so existing overrides survive. Adds Zod and Vitest, plus an "npm run test" script. Also cleans up the pre-existing repo-wide ESLint failures so phase 1's "npm run lint passes" acceptance criterion can be checked: drops unused React imports across the JSX tree (React 19 JSX runtime auto-imports), attaches cause to rethrown errors in the service modules, ignores pb_migrations in the ESLint config (PocketBase JSVM globals), and removes one dead handleCreateCustom function in Leren.jsx. A real behaviour bug surfaced in Testen.jsx — the quiz timer captured a stale finishQuiz via setInterval closure; now updated via finishQuizRef so the timer always invokes the latest callback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
96
src/lib/llmRetry.js
Normal file
96
src/lib/llmRetry.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Retry policy for LLM calls.
|
||||
*
|
||||
* Exponential backoff with full jitter, base 1000ms, cap 16000ms. Only
|
||||
* retries on transient HTTP statuses (408, 425, 429, 5xx, 529). Honours a
|
||||
* `Retry-After` hint up to 60 seconds; longer waits fail fast. Never retries
|
||||
* on AbortError.
|
||||
*/
|
||||
|
||||
const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504, 529]);
|
||||
const BASE_DELAY_MS = 1000;
|
||||
const CAP_DELAY_MS = 16000;
|
||||
const MAX_RETRY_AFTER_MS = 60 * 1000;
|
||||
|
||||
export class RetryableError extends Error {
|
||||
constructor(status, retryAfterMs = null, message) {
|
||||
super(message || `Retryable HTTP ${status}`);
|
||||
this.name = 'RetryableError';
|
||||
this.status = status;
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRetryableStatus(status) {
|
||||
return RETRYABLE_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function backoffWithJitter(attempt) {
|
||||
const exp = Math.min(CAP_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
|
||||
return Math.floor(Math.random() * exp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `Retry-After` header. Returns null when absent or unusable, or
|
||||
* a millisecond delay otherwise. Supports both seconds and HTTP-date forms.
|
||||
*/
|
||||
export function parseRetryAfter(value, now = Date.now()) {
|
||||
if (value == null) return null;
|
||||
const s = String(value).trim();
|
||||
if (!s) return null;
|
||||
if (/^\d+$/.test(s)) return Number(s) * 1000;
|
||||
const dateMs = Date.parse(s);
|
||||
if (Number.isFinite(dateMs)) {
|
||||
const delta = dateMs - now;
|
||||
return delta > 0 ? delta : 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
const t = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(t);
|
||||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn(attempt)` with retry. `fn` may throw a `RetryableError` to request
|
||||
* a retry, or any other error to fail immediately.
|
||||
*
|
||||
* @template T
|
||||
* @param {(attempt:number) => Promise<T>} fn
|
||||
* @param {{ maxRetries?: number, signal?: AbortSignal }} [opts]
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
export async function withRetry(fn, { maxRetries = 4, signal } = {}) {
|
||||
let attempt = 0;
|
||||
for (;;) {
|
||||
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
|
||||
try {
|
||||
return await fn(attempt);
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') throw err;
|
||||
const retryable = err instanceof RetryableError;
|
||||
if (!retryable || attempt >= maxRetries) throw err;
|
||||
|
||||
let delayMs;
|
||||
if (err.retryAfterMs != null) {
|
||||
if (err.retryAfterMs > MAX_RETRY_AFTER_MS) throw err;
|
||||
delayMs = err.retryAfterMs;
|
||||
} else {
|
||||
delayMs = backoffWithJitter(attempt);
|
||||
}
|
||||
await sleep(delayMs, signal);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user