315 lines
8.1 KiB
JavaScript
315 lines
8.1 KiB
JavaScript
const axios = require('axios');
|
||
const { llmConfig, getHeaders } = require('../config/llm');
|
||
|
||
class LLMApiClient {
|
||
constructor() {
|
||
this.baseURL = llmConfig.baseURL;
|
||
this.model = llmConfig.model;
|
||
this.timeout = llmConfig.timeout;
|
||
this.maxRetries = llmConfig.maxRetries;
|
||
this.retryDelay = llmConfig.retryDelay;
|
||
|
||
// 创建axios实例
|
||
this.client = axios.create({
|
||
baseURL: this.baseURL,
|
||
timeout: this.timeout,
|
||
headers: getHeaders()
|
||
});
|
||
|
||
// 配置请求拦截器
|
||
this.client.interceptors.request.use(
|
||
(config) => {
|
||
console.log(`LLM API请求: ${config.method?.toUpperCase()} ${config.url}`);
|
||
return config;
|
||
},
|
||
(error) => {
|
||
console.error('LLM API请求拦截器错误:', error);
|
||
return Promise.reject(error);
|
||
}
|
||
);
|
||
|
||
// 配置响应拦截器
|
||
this.client.interceptors.response.use(
|
||
(response) => {
|
||
console.log(`LLM API响应: ${response.status} ${response.config.url}`);
|
||
return response;
|
||
},
|
||
(error) => {
|
||
console.error('LLM API响应错误:', error.response?.data || error.message);
|
||
return Promise.reject(error);
|
||
}
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 调用聊天完成API
|
||
*/
|
||
async createChatCompletion(messages, options = {}) {
|
||
try {
|
||
const requestBody = {
|
||
model: options.model || this.model,
|
||
messages: this.formatMessages(messages),
|
||
...llmConfig.defaultOptions,
|
||
...options
|
||
};
|
||
|
||
// 移除undefined值
|
||
Object.keys(requestBody).forEach(key => {
|
||
if (requestBody[key] === undefined) {
|
||
delete requestBody[key];
|
||
}
|
||
});
|
||
|
||
console.log('发送LLM请求:', JSON.stringify(requestBody, null, 2));
|
||
|
||
const response = await this.makeRequestWithRetry('/chat/completions', requestBody);
|
||
|
||
// 返回标准格式的响应
|
||
return this.formatResponse(response.data);
|
||
} catch (error) {
|
||
console.error('LLM API调用失败:', error);
|
||
throw this.handleError(error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建流式聊天完成
|
||
*/
|
||
async createStreamChatCompletion(messages, options = {}) {
|
||
try {
|
||
const requestBody = {
|
||
model: options.model || this.model,
|
||
messages: this.formatMessages(messages),
|
||
stream: true,
|
||
...llmConfig.defaultOptions,
|
||
...options
|
||
};
|
||
|
||
// 移除undefined值
|
||
Object.keys(requestBody).forEach(key => {
|
||
if (requestBody[key] === undefined) {
|
||
delete requestBody[key];
|
||
}
|
||
});
|
||
|
||
const response = await this.client.post('/chat/completions', requestBody, {
|
||
responseType: 'stream'
|
||
});
|
||
|
||
return response.data;
|
||
} catch (error) {
|
||
console.error('流式LLM API调用失败:', error);
|
||
throw this.handleError(error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 带重试的请求方法
|
||
*/
|
||
async makeRequestWithRetry(endpoint, data, retryCount = 0) {
|
||
try {
|
||
const response = await this.client.post(endpoint, data);
|
||
return response;
|
||
} catch (error) {
|
||
// 检查是否应该重试
|
||
if (this.shouldRetry(error, retryCount)) {
|
||
console.log(`请求失败,${this.retryDelay}ms后进行第${retryCount + 1}次重试...`);
|
||
await this.delay(this.retryDelay);
|
||
return this.makeRequestWithRetry(endpoint, data, retryCount + 1);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 判断是否应该重试
|
||
*/
|
||
shouldRetry(error, retryCount) {
|
||
// 超过最大重试次数
|
||
if (retryCount >= this.maxRetries) {
|
||
return false;
|
||
}
|
||
|
||
// 网络错误或服务器错误可以重试
|
||
if (error.code === 'ECONNABORTED' || error.code === 'ENOTFOUND') {
|
||
return true;
|
||
}
|
||
|
||
// HTTP状态码500+可以重试
|
||
if (error.response && error.response.status >= 500) {
|
||
return true;
|
||
}
|
||
|
||
// 429 Too Many Requests 可以重试
|
||
if (error.response && error.response.status === 429) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 延迟函数
|
||
*/
|
||
delay(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
/**
|
||
* 格式化消息数组
|
||
*/
|
||
formatMessages(messages) {
|
||
// 确保有系统消息
|
||
const formattedMessages = [...messages];
|
||
|
||
// 检查是否已有系统消息
|
||
const hasSystemMessage = formattedMessages.some(msg => msg.role === 'system');
|
||
|
||
if (!hasSystemMessage && llmConfig.systemPrompt) {
|
||
formattedMessages.unshift({
|
||
role: 'system',
|
||
content: llmConfig.systemPrompt
|
||
});
|
||
}
|
||
|
||
return formattedMessages;
|
||
}
|
||
|
||
/**
|
||
* 格式化API响应
|
||
*/
|
||
formatResponse(data) {
|
||
if (!data || !data.choices || data.choices.length === 0) {
|
||
throw new Error('LLM API返回无效响应');
|
||
}
|
||
|
||
const choice = data.choices[0];
|
||
const message = choice.message;
|
||
|
||
return {
|
||
id: data.id,
|
||
model: data.model,
|
||
content: message.content,
|
||
role: message.role,
|
||
finishReason: choice.finish_reason,
|
||
usage: data.usage,
|
||
created: data.created
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 处理API错误
|
||
*/
|
||
handleError(error) {
|
||
if (error.response) {
|
||
// API返回错误响应
|
||
const status = error.response.status;
|
||
const data = error.response.data;
|
||
|
||
switch (status) {
|
||
case 400:
|
||
return new Error(`请求参数错误: ${data.error?.message || '无效请求'}`);
|
||
case 401:
|
||
return new Error('API密钥无效或未提供');
|
||
case 403:
|
||
return new Error('API访问被拒绝,请检查权限');
|
||
case 404:
|
||
return new Error('API端点不存在');
|
||
case 429:
|
||
return new Error('请求频率过高,请稍后重试');
|
||
case 500:
|
||
return new Error('LLM服务内部错误');
|
||
case 503:
|
||
return new Error('LLM服务暂时不可用');
|
||
default:
|
||
return new Error(`LLM API错误 (${status}): ${data.error?.message || '未知错误'}`);
|
||
}
|
||
} else if (error.request) {
|
||
// 网络错误
|
||
if (error.code === 'ECONNABORTED') {
|
||
return new Error('请求超时,请检查网络连接');
|
||
} else if (error.code === 'ENOTFOUND') {
|
||
return new Error('无法连接到LLM服务,请检查网络和API地址');
|
||
} else {
|
||
return new Error(`网络错误: ${error.message}`);
|
||
}
|
||
} else {
|
||
// 其他错误
|
||
return new Error(`未知错误: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查API连接状态
|
||
*/
|
||
async checkConnection() {
|
||
try {
|
||
const testMessages = [
|
||
{ role: 'user', content: 'Hello' }
|
||
];
|
||
|
||
await this.createChatCompletion(testMessages, {
|
||
max_tokens: 10,
|
||
temperature: 0
|
||
});
|
||
|
||
return { status: 'connected', message: 'LLM API连接正常' };
|
||
} catch (error) {
|
||
return {
|
||
status: 'error',
|
||
message: error.message,
|
||
error: error
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取可用模型列表(如果API支持)
|
||
*/
|
||
async getModels() {
|
||
try {
|
||
const response = await this.client.get('/models');
|
||
return response.data;
|
||
} catch (error) {
|
||
console.warn('获取模型列表失败:', error.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 计算tokens估算(简单实现)
|
||
*/
|
||
estimateTokens(text) {
|
||
// 简单估算:英文按空格分割,中文按字符数
|
||
const englishWords = (text.match(/[a-zA-Z]+/g) || []).length;
|
||
const chineseChars = (text.match(/[\u4e00-\u9fff]/g) || []).length;
|
||
const otherChars = text.replace(/[a-zA-Z\u4e00-\u9fff\s]/g, '').length;
|
||
|
||
return englishWords + chineseChars + Math.ceil(otherChars / 2);
|
||
}
|
||
|
||
/**
|
||
* 截断消息以适应token限制
|
||
*/
|
||
truncateMessages(messages, maxTokens = 4000) {
|
||
let totalTokens = 0;
|
||
const truncatedMessages = [];
|
||
|
||
// 从最后一条消息开始,向前累加
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const message = messages[i];
|
||
const messageTokens = this.estimateTokens(message.content);
|
||
|
||
if (totalTokens + messageTokens > maxTokens && truncatedMessages.length > 0) {
|
||
break;
|
||
}
|
||
|
||
truncatedMessages.unshift(message);
|
||
totalTokens += messageTokens;
|
||
}
|
||
|
||
return truncatedMessages;
|
||
}
|
||
}
|
||
|
||
module.exports = LLMApiClient; |