fix(backend): harden LLM error handling — retry, timeout, missing key guards
This commit is contained in:
parent
b8bc096adb
commit
c71b01681f
@ -6,6 +6,13 @@
|
|||||||
|
|
||||||
import { llm } from './llm.js';
|
import { llm } from './llm.js';
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('LLM request timed out')), ms)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
export type CopilotAction = 'shorten' | 'expand' | 'bulletize' | 'grammar' | 'fix-rewrite' | 'change-tone' | 'continue' | 'explain';
|
export type CopilotAction = 'shorten' | 'expand' | 'bulletize' | 'grammar' | 'fix-rewrite' | 'change-tone' | 'continue' | 'explain';
|
||||||
|
|
||||||
const SYSTEM_PROMPTS: Record<CopilotAction, string> = {
|
const SYSTEM_PROMPTS: Record<CopilotAction, string> = {
|
||||||
@ -51,19 +58,30 @@ export async function runCopilotTransform(action: CopilotAction, text: string):
|
|||||||
return fallbackTransform(action, text);
|
return fallbackTransform(action, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const maxRetries = 3;
|
||||||
const result = await provider.chatCompletion({
|
const baseDelayMs = 1000;
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: SYSTEM_PROMPTS[action] },
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
{ role: 'user', content: text },
|
try {
|
||||||
],
|
const result = await withTimeout(provider.chatCompletion({
|
||||||
temperature: 0.3,
|
messages: [
|
||||||
maxTokens: 4096,
|
{ role: 'system', content: SYSTEM_PROMPTS[action] },
|
||||||
});
|
{ role: 'user', content: text },
|
||||||
const out = result.content.trim();
|
],
|
||||||
if (out.length > 0) return out;
|
temperature: 0.3,
|
||||||
} catch {
|
maxTokens: 4096,
|
||||||
// fall through to local heuristics
|
}), 60_000);
|
||||||
|
const out = result.content.trim();
|
||||||
|
if (out.length > 0) return out;
|
||||||
|
break; // empty response — fall through to heuristic
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const isRateLimit = err instanceof Error && (err.message.includes('429') || err.message.includes('rate'));
|
||||||
|
if (isRateLimit && attempt < maxRetries - 1) {
|
||||||
|
await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break; // non-retriable error — fall through to heuristic
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fallbackTransform(action, text);
|
return fallbackTransform(action, text);
|
||||||
}
|
}
|
||||||
@ -76,18 +94,18 @@ export async function suggestTitleFromBody(body: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await provider.chatCompletion({
|
const result = await withTimeout(provider.chatCompletion({
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: 'Suggest a concise, descriptive title (max 8 words). Return only the title, no quotes.' },
|
{ role: 'system', content: 'Suggest a concise, descriptive title (max 8 words). Return only the title, no quotes.' },
|
||||||
{ role: 'user', content: plain.slice(0, 4000) },
|
{ role: 'user', content: plain.slice(0, 4000) },
|
||||||
],
|
],
|
||||||
temperature: 0.6,
|
temperature: 0.6,
|
||||||
maxTokens: 64,
|
maxTokens: 64,
|
||||||
});
|
}), 60_000);
|
||||||
const t = result.content.trim();
|
const t = result.content.trim();
|
||||||
if (t.length > 0 && t.length < 500) return t;
|
if (t.length > 0 && t.length < 500) return t;
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// timeout or LLM error — fall through to heuristic
|
||||||
}
|
}
|
||||||
return plain.split(/[.!?]/)[0]?.trim().slice(0, 80) || 'Untitled note';
|
return plain.split(/[.!?]/)[0]?.trim().slice(0, 80) || 'Untitled note';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,13 @@ import {
|
|||||||
} from '@bytelyst/llm';
|
} from '@bytelyst/llm';
|
||||||
import type { PromptTemplateDoc, RunPromptInput, RunPromptOutput } from './types.js';
|
import type { PromptTemplateDoc, RunPromptInput, RunPromptOutput } from './types.js';
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('LLM request timed out')), ms)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interpolate {{variable}} placeholders in a template string.
|
* Interpolate {{variable}} placeholders in a template string.
|
||||||
*/
|
*/
|
||||||
@ -29,6 +36,10 @@ export async function executePrompt(
|
|||||||
): Promise<RunPromptOutput> {
|
): Promise<RunPromptOutput> {
|
||||||
const provider = llm();
|
const provider = llm();
|
||||||
|
|
||||||
|
if (!provider.isConfigured()) {
|
||||||
|
throw new Error('LLM provider is not configured. Set LLM_PROVIDER and the required API key.');
|
||||||
|
}
|
||||||
|
|
||||||
// Build variables map
|
// Build variables map
|
||||||
const vars: Record<string, string> = {
|
const vars: Record<string, string> = {
|
||||||
...input.variables,
|
...input.variables,
|
||||||
@ -58,27 +69,49 @@ export async function executePrompt(
|
|||||||
model = config.LLM_VISION_MODEL;
|
model = config.LLM_VISION_MODEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await provider.chatCompletion({
|
const maxRetries = 3;
|
||||||
messages,
|
const baseDelayMs = 1000;
|
||||||
model,
|
let lastError: unknown;
|
||||||
temperature: template.temperature ?? 0.7,
|
|
||||||
maxTokens: template.maxTokens ?? 4096,
|
|
||||||
});
|
|
||||||
|
|
||||||
const output: RunPromptOutput = {
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
content: result.content,
|
try {
|
||||||
model: result.model,
|
const result = await withTimeout(provider.chatCompletion({
|
||||||
usage: result.usage,
|
messages,
|
||||||
templateSlug: template.slug,
|
model,
|
||||||
outputType: template.outputType,
|
temperature: template.temperature ?? 0.7,
|
||||||
};
|
maxTokens: template.maxTokens ?? 4096,
|
||||||
|
}), 60_000);
|
||||||
|
|
||||||
// F27: Approval-gated actions — produce proposed state instead of applied
|
if (!result.content || result.content.trim().length === 0) {
|
||||||
if (template.requiresApproval) {
|
throw new Error('LLM returned empty response');
|
||||||
output.approvalState = 'proposed';
|
}
|
||||||
} else {
|
|
||||||
output.approvalState = 'applied';
|
const output: RunPromptOutput = {
|
||||||
|
content: result.content,
|
||||||
|
model: result.model,
|
||||||
|
usage: result.usage,
|
||||||
|
templateSlug: template.slug,
|
||||||
|
outputType: template.outputType,
|
||||||
|
};
|
||||||
|
|
||||||
|
// F27: Approval-gated actions — produce proposed state instead of applied
|
||||||
|
if (template.requiresApproval) {
|
||||||
|
output.approvalState = 'proposed';
|
||||||
|
} else {
|
||||||
|
output.approvalState = 'applied';
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
lastError = err;
|
||||||
|
const isRateLimit = err instanceof Error && (err.message.includes('429') || err.message.includes('rate'));
|
||||||
|
if (isRateLimit && attempt < maxRetries - 1) {
|
||||||
|
await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
throw lastError instanceof Error ? lastError : new Error('LLM call failed after retries');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -212,8 +212,9 @@ export async function runSchedulerTick(): Promise<number> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ran++;
|
ran++;
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
// Log but don't break the loop
|
const msg = err instanceof Error ? err.message : 'Unknown scheduler error';
|
||||||
|
process.stderr.write(`[scheduler] Failed to run schedule ${schedule.id}: ${msg}\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user