Skip to content
Open
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
30 changes: 26 additions & 4 deletions package-lock.json

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

168 changes: 156 additions & 12 deletions server/src/services/llm.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,141 @@
import OpenAI from 'openai';
import OpenAI, { APIError, RateLimitError, APIConnectionError, AuthenticationError } from 'openai';
import type { ChatCompletionContentPart } from 'openai/resources/chat/completions';

interface LLMConfig { apiKey: string; baseURL: string; model: string; }

// 重试配置
const RETRY_CONFIG = {
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 30000,
};

// 自定义错误类型,用于前端识别
export class LLMError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode?: number,
public readonly retryable: boolean = false
) {
super(message);
this.name = 'LLMError';
}
}

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

function handleAPIError(error: unknown): never {
if (error instanceof RateLimitError) {
throw new LLMError(
'API rate limit exceeded. Please wait a moment and try again.',
'RATE_LIMIT',
429,
true
);
}

if (error instanceof AuthenticationError) {
throw new LLMError(
'Invalid API key. Please check your API key configuration.',
'AUTH_ERROR',
401,
false
);
}

if (error instanceof APIConnectionError) {
throw new LLMError(
'Failed to connect to API server. Please check your network or API base URL.',
'CONNECTION_ERROR',
undefined,
true
);
}

if (error instanceof APIError) {
const statusCode = error.status;
if (statusCode === 429) {
throw new LLMError(
'API rate limit exceeded. Please wait a moment and try again.',
'RATE_LIMIT',
429,
true
);
}
if (statusCode === 401 || statusCode === 403) {
throw new LLMError(
'Authentication failed. Please check your API key.',
'AUTH_ERROR',
statusCode,
false
);
}
if (statusCode === 400) {
throw new LLMError(
'Invalid request. Please check your input parameters.',
'BAD_REQUEST',
400,
false
);
}
if (statusCode && statusCode >= 500) {
throw new LLMError(
'API server error. Please try again later.',
'SERVER_ERROR',
statusCode,
true
);
}
throw new LLMError(
error.message || 'An unexpected API error occurred.',
'API_ERROR',
statusCode,
false
);
}

if (error instanceof Error) {
throw new LLMError(error.message, 'UNKNOWN_ERROR', undefined, false);
}

throw new LLMError('An unexpected error occurred.', 'UNKNOWN_ERROR', undefined, false);
}

async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = RETRY_CONFIG.maxRetries
): Promise<T> {
let lastError: Error = new Error('Unknown error');
let delay = RETRY_CONFIG.initialDelayMs;

for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;

// 检查是否是可重试的错误
const isRetryable =
error instanceof RateLimitError ||
error instanceof APIConnectionError ||
(error instanceof APIError && error.status && error.status >= 500) ||
(error instanceof LLMError && error.retryable);

if (isRetryable && attempt < maxRetries) {
console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms...`);
await sleep(delay);
delay = Math.min(delay * 2, RETRY_CONFIG.maxDelayMs);
continue;
}

handleAPIError(error);
}
}

handleAPIError(lastError);
}

function normalizeBaseURL(url: string): string {
const parsed = new URL(url.trim());
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('Base URL must use http or https');
Expand Down Expand Up @@ -30,22 +163,33 @@ const buildUserContent = (userPrompt: string, pdfDataUrl?: string): MessageConte

export async function generateWithLLM(systemPrompt: string, userPrompt: string, pdfDataUrl?: string, config?: Partial<LLMConfig>): Promise<string> {
const { client, model } = createClient(config);
const response = await client.chat.completions.create({
model,
messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: buildUserContent(userPrompt, pdfDataUrl) }],

return withRetry(async () => {
const response = await client.chat.completions.create({
model,
messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: buildUserContent(userPrompt, pdfDataUrl) }],
});
return response.choices[0]?.message?.content || '';
});
return response.choices[0]?.message?.content || '';
}

export async function* generateWithLLMStream(systemPrompt: string, userPrompt: string, pdfDataUrl?: string, config?: Partial<LLMConfig>): AsyncGenerator<string> {
const { client, model } = createClient(config);
const stream = await client.chat.completions.create({
model,
messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: buildUserContent(userPrompt, pdfDataUrl) }],
stream: true,

const stream = await withRetry(async () => {
return client.chat.completions.create({
model,
messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: buildUserContent(userPrompt, pdfDataUrl) }],
stream: true,
});
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) yield content;

try {
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) yield content;
}
} catch (error) {
handleAPIError(error);
}
Comment on lines +187 to 194

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the retry logic, when an error occurs during the stream iteration (lines 187-194), the error is handled by handleAPIError which always throws. However, at this point, the stream has already been successfully created by withRetry. If the error occurs while consuming the stream chunks, it won't be retried. Consider whether stream consumption errors should also be retryable, or if the current behavior (only retry stream creation) is intentional.

Copilot uses AI. Check for mistakes.
}
Loading
Loading