443 lines
20 KiB
JavaScript
443 lines
20 KiB
JavaScript
// api-config-builder.js
|
||
// API配置构建模块
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// =====================
|
||
// buildCustomApiConfig: 兼容自定义模型调用
|
||
// =====================
|
||
/**
|
||
* 构建自定义 API 访问配置。
|
||
* 该函数负责根据传入的参数,生成一个完整的 API 请求配置对象,
|
||
* 包括请求端点、模型ID、请求头、请求体构建器、响应提取器以及流式支持等。
|
||
*
|
||
* 主要逻辑:
|
||
* 1. 端点处理:如果 `window.modelDetector` 存在且能提供完整端点,则优先使用。
|
||
* 否则,如果提供的 `customApiEndpoint` 不规范(不含 `/v1/` 或 `/v1` 结尾),
|
||
* 会自动拼接 `/v1/chat/completions`。
|
||
* 2. 请求格式自动推断:如果 `customRequestFormat` 为空且端点以 `/v1/chat/completions` 结尾,
|
||
* 则自动设置为 `openai` 格式。
|
||
* 3. 模型ID获取:如果 `window.modelDetector` 存在,则尝试获取当前选择的模型ID。
|
||
* 4. 根据 `customRequestFormat` (如 'openai', 'anthropic', 'gemini' 等) 构建特定配置:
|
||
* - 设置认证头 (Authorization, x-api-key)。
|
||
* - 定义 `bodyBuilder` 用于构建非流式请求的请求体。
|
||
* - 定义 `streamBodyBuilder` 用于构建流式请求的请求体。
|
||
* - 定义 `responseExtractor` 用于从 API 响应中提取所需内容。
|
||
* - 设置 `streamSupport` 标记是否支持流式响应。
|
||
* - 为 Gemini 等特殊模型处理端点参数 (如 `alt=sse`)。
|
||
* 5. 对于不支持的 `customRequestFormat`,会抛出错误。
|
||
* 6. 最终返回构建好的 `config` 对象。
|
||
*
|
||
* @param {string} key API 密钥。
|
||
* @param {string} customApiEndpoint 自定义 API 端点基础 URL。
|
||
* @param {string} customModelId 自定义模型 ID。
|
||
* @param {string} customRequestFormat 自定义请求格式 (例如 'openai', 'anthropic', 'gemini')。
|
||
* @param {number} [temperature] 模型温度参数,控制生成文本的随机性。
|
||
* @param {number} [max_tokens] 模型最大输出 token 数。
|
||
* @returns {object} 构建好的 API 配置对象,包含 endpoint, modelName, headers, bodyBuilder, responseExtractor, streamSupport, streamBodyBuilder 等。
|
||
* @throws {Error} 如果 customRequestFormat 不被支持。
|
||
*/
|
||
|
||
// 辅助函数:如果 userContent 是数组,则提取其中的文本内容
|
||
const extractTextFromUserContent = (userContent) => {
|
||
if (Array.isArray(userContent)) {
|
||
const textPart = userContent.find(part => part.type === 'text');
|
||
return textPart ? textPart.text : '';
|
||
}
|
||
return userContent; // 假设已经是字符串
|
||
};
|
||
|
||
// 辅助函数:将 OpenAI 风格的 userContent 转换为 Gemini 格式
|
||
const convertOpenAIToGeminiParts = (userContent) => {
|
||
if (Array.isArray(userContent)) {
|
||
return userContent.map(part => {
|
||
if (part.type === 'text') {
|
||
return { text: part.text };
|
||
} else if (part.type === 'image_url' && part.image_url && part.image_url.url) {
|
||
const base64Data = part.image_url.url.split(',')[1];
|
||
if (!base64Data) return null;
|
||
const mimeType = part.image_url.url.match(/^data:(image\/\w+);base64,/)?.[1] || 'image/jpeg';
|
||
return { inlineData: { mimeType: mimeType, data: base64Data } };
|
||
}
|
||
return null;
|
||
}).filter(p => p);
|
||
}
|
||
return [{ text: userContent }]; // 假设为字符串
|
||
};
|
||
|
||
// 辅助函数:将 OpenAI 风格的 userContent 转换为 Anthropic 格式
|
||
const convertOpenAIToAnthropicContent = (userContent) => {
|
||
if (Array.isArray(userContent)) {
|
||
return userContent.map(part => {
|
||
if (part.type === 'text') {
|
||
return { type: 'text', text: part.text };
|
||
} else if (part.type === 'image_url' && part.image_url && part.image_url.url) {
|
||
const base64Data = part.image_url.url.split(',')[1];
|
||
if (!base64Data) return null;
|
||
const mediaType = part.image_url.url.match(/^data:(image\/\w+);base64,/)?.[1] || 'image/jpeg';
|
||
return { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } };
|
||
}
|
||
return null;
|
||
}).filter(p => p);
|
||
}
|
||
return [{ type: 'text', text: userContent }]; // 假设为字符串
|
||
};
|
||
|
||
const appendPathSegment = (base, segment) => {
|
||
if (!base) return segment || '';
|
||
if (!segment) return base;
|
||
const hasTrailingSlash = base.endsWith('/');
|
||
const hasLeadingSlash = segment.startsWith('/');
|
||
if (hasTrailingSlash && hasLeadingSlash) {
|
||
return base + segment.slice(1);
|
||
}
|
||
if (!hasTrailingSlash && !hasLeadingSlash) {
|
||
return `${base}/${segment}`;
|
||
}
|
||
return base + segment;
|
||
};
|
||
|
||
const normalizeOpenAIEndpointForChatbot = (baseApiUrlInput, format, endpointMode = 'auto', targetSegment) => {
|
||
if (!baseApiUrlInput || typeof baseApiUrlInput !== 'string') {
|
||
throw new Error('自定义模型需要提供 API Base URL');
|
||
}
|
||
|
||
const trimmed = baseApiUrlInput.trim();
|
||
if (!trimmed) {
|
||
throw new Error('自定义模型需要提供 API Base URL');
|
||
}
|
||
|
||
const mode = endpointMode || 'auto';
|
||
const normalizedSegment = (targetSegment
|
||
? targetSegment
|
||
: ((format || '').toLowerCase() === 'anthropic' ? 'messages' : 'chat/completions'))
|
||
.replace(/^\/+/, '');
|
||
|
||
const lower = trimmed.toLowerCase();
|
||
const base = trimmed.replace(/\/+$/, '');
|
||
const v1Segment = normalizedSegment.startsWith('v1/') ? normalizedSegment : `v1/${normalizedSegment}`;
|
||
|
||
const terminalPaths = [
|
||
normalizedSegment,
|
||
`/${normalizedSegment}`,
|
||
v1Segment,
|
||
`/${v1Segment}`
|
||
];
|
||
|
||
if (terminalPaths.some(path => lower.endsWith(path))) {
|
||
return base;
|
||
}
|
||
|
||
if (mode === 'manual') {
|
||
return base;
|
||
}
|
||
|
||
if (mode === 'chat') {
|
||
return appendPathSegment(base, normalizedSegment);
|
||
}
|
||
|
||
if (/\/v1$/.test(lower)) {
|
||
return appendPathSegment(base, normalizedSegment);
|
||
}
|
||
|
||
return appendPathSegment(base, v1Segment);
|
||
};
|
||
|
||
function buildCustomApiConfig(key, customApiEndpoint, customModelId, customRequestFormat, temperature, max_tokens, options = {}) {
|
||
let apiEndpoint = customApiEndpoint;
|
||
let modelId = customModelId;
|
||
const endpointMode = options.endpointMode || 'auto';
|
||
let resolvedRequestFormat = customRequestFormat;
|
||
|
||
// 代理服务器支持
|
||
// 支持三种配置方式:
|
||
// 1. options.useProxy - 单次调用时指定
|
||
// 2. options.proxyMode - 'proxy' 强制使用代理,'direct' 强制直连,'auto' 自动选择
|
||
// 3. window.PBX_PROXY_MODE - 全局配置(可在 index.html 或配置文件中设置)
|
||
const globalProxyMode = (typeof window !== 'undefined' && window.PBX_PROXY_MODE) || 'auto';
|
||
const useProxy = options.useProxy ||
|
||
options.proxyMode === 'proxy' ||
|
||
(options.proxyMode !== 'direct' && globalProxyMode === 'proxy');
|
||
const proxyBaseUrl = options.proxyBaseUrl ||
|
||
(typeof window !== 'undefined' && window.ProxyConfig ? window.ProxyConfig.getProxyUrl() : null) ||
|
||
(typeof window !== 'undefined' && window.PBX_PROXY_BASE_URL ? window.PBX_PROXY_BASE_URL : null) ||
|
||
'http://localhost:3456';
|
||
const provider = options.provider || 'openai';
|
||
|
||
// 获取当前选择的模型ID(如果有模型检测模块)
|
||
if (typeof window.modelDetector !== 'undefined') {
|
||
const currentModelId = window.modelDetector.getCurrentModelId();
|
||
if (currentModelId) {
|
||
modelId = currentModelId;
|
||
}
|
||
}
|
||
|
||
// 新增:如果 customRequestFormat 为空且 endpoint 以 /v1/chat/completions 结尾,则自动设为 openai
|
||
if ((!resolvedRequestFormat || resolvedRequestFormat === '') && apiEndpoint && apiEndpoint.endsWith('/v1/chat/completions')) {
|
||
resolvedRequestFormat = 'openai';
|
||
}
|
||
|
||
// 检查是否有模型检测模块,如果有则使用其提供的完整端点
|
||
// 注意:现在 resolvedRequestFormat 已经确定,端点标准化会使用正确的路径
|
||
if (typeof window.modelDetector !== 'undefined' && typeof window.modelDetector.getFullApiEndpoint === 'function') {
|
||
const fullEndpoint = window.modelDetector.getFullApiEndpoint();
|
||
if (fullEndpoint) {
|
||
apiEndpoint = fullEndpoint;
|
||
} else {
|
||
apiEndpoint = normalizeOpenAIEndpointForChatbot(apiEndpoint, resolvedRequestFormat, endpointMode);
|
||
}
|
||
} else {
|
||
apiEndpoint = normalizeOpenAIEndpointForChatbot(apiEndpoint, resolvedRequestFormat, endpointMode);
|
||
}
|
||
|
||
// 如果使用代理服务器,重写端点
|
||
if (useProxy && proxyBaseUrl) {
|
||
console.log('[ApiConfigBuilder] 使用代理服务器:', {
|
||
provider,
|
||
proxyBaseUrl,
|
||
originalEndpoint: apiEndpoint
|
||
});
|
||
apiEndpoint = `${proxyBaseUrl.replace(/\/$/, '')}/api/llm/${provider}/v1/chat/completions`;
|
||
}
|
||
|
||
const config = {
|
||
endpoint: apiEndpoint,
|
||
modelName: modelId, // 使用最新获取的modelId
|
||
headers: { 'Content-Type': 'application/json' },
|
||
bodyBuilder: null,
|
||
responseExtractor: null,
|
||
streamSupport: false, // 默认不支持流式
|
||
streamBodyBuilder: null // 流式请求构建器
|
||
};
|
||
|
||
const normalizedFormat = (resolvedRequestFormat || 'openai').toLowerCase();
|
||
|
||
// 在使用代理服务器模式时,即使前端没有 API Key,也可以正常发送请求
|
||
// 因为 API Key 会在后端代理服务器中配置
|
||
const isProxyMode = useProxy && proxyBaseUrl;
|
||
const authKey = key || ''; // 允许空 Key(代理模式)
|
||
|
||
switch (normalizedFormat) {
|
||
case 'openai':
|
||
case 'openai-vision': // Add a specific format for vision-enabled OpenAI
|
||
// 代理模式下,Authorization 头可选(后端会使用环境变量中的 Key)
|
||
if (authKey) {
|
||
config.headers['Authorization'] = `Bearer ${authKey}`;
|
||
}
|
||
config.bodyBuilder = (sys_prompt, user_content) => ({ // user_content can be string or array
|
||
model: modelId,
|
||
messages: [{ role: "system", content: sys_prompt }, { role: "user", content: user_content }],
|
||
temperature: temperature ?? 0.5,
|
||
max_tokens: max_tokens ?? 8000
|
||
});
|
||
|
||
// 流式请求构建器 - 针对转接站兼容性优化
|
||
config.streamBodyBuilder = (sys, msgs, user_content) => {
|
||
// 检测是否是 Claude 模型 + OpenAI 格式(转接站场景)
|
||
const isClaudeViaProxy = modelId && typeof modelId === 'string' && modelId.toLowerCase().includes('claude');
|
||
|
||
if (isClaudeViaProxy) {
|
||
// 对于通过转接站使用的 Claude 模型,将 system prompt 合并到第一条 user 消息中
|
||
// 因为很多转接站不正确处理 system role
|
||
console.log('[ApiConfigBuilder] 🔧 检测到通过转接站使用 Claude,将 system prompt 合并到 user 消息');
|
||
|
||
// 构建合并后的第一条用户消息
|
||
let firstUserMessage = sys ? `${sys}\n\n---\n\n` : '';
|
||
if (typeof user_content === 'string') {
|
||
firstUserMessage += user_content;
|
||
} else if (Array.isArray(user_content)) {
|
||
// 如果是多模态内容,只提取文本部分合并
|
||
const textPart = user_content.find(p => p.type === 'text');
|
||
if (textPart) {
|
||
firstUserMessage += textPart.text;
|
||
}
|
||
}
|
||
|
||
return {
|
||
model: modelId,
|
||
messages: [
|
||
...msgs,
|
||
{ role: 'user', content: firstUserMessage }
|
||
],
|
||
temperature: temperature ?? 0.5,
|
||
max_tokens: max_tokens ?? 8000,
|
||
stream: true
|
||
};
|
||
} else {
|
||
// 其他模型使用标准格式
|
||
return {
|
||
model: modelId,
|
||
messages: [
|
||
{ role: 'system', content: sys },
|
||
...msgs,
|
||
{ role: 'user', content: user_content }
|
||
],
|
||
temperature: temperature ?? 0.5,
|
||
max_tokens: max_tokens ?? 8000,
|
||
stream: true
|
||
};
|
||
}
|
||
};
|
||
config.responseExtractor = (data) => data?.choices?.[0]?.message?.content;
|
||
config.streamSupport = true;
|
||
break;
|
||
case 'anthropic':
|
||
// 代理模式下,x-api-key 可选(后端会使用环境变量中的 Key)
|
||
if (authKey) {
|
||
config.headers['x-api-key'] = authKey;
|
||
}
|
||
config.headers['anthropic-version'] = '2023-06-01';
|
||
config.bodyBuilder = (sys_prompt, user_content) => ({
|
||
model: modelId,
|
||
system: sys_prompt,
|
||
messages: [{ role: "user", content: convertOpenAIToAnthropicContent(user_content) }],
|
||
temperature: temperature ?? 0.5,
|
||
max_tokens: max_tokens ?? 8000
|
||
});
|
||
config.streamBodyBuilder = (sys, msgs, user_content) => {
|
||
return {
|
||
model: modelId,
|
||
system: sys,
|
||
messages: msgs.length ?
|
||
[...msgs, { role: 'user', content: convertOpenAIToAnthropicContent(user_content) }] :
|
||
[{ role: 'user', content: convertOpenAIToAnthropicContent(user_content) }],
|
||
max_tokens: max_tokens ?? 8000,
|
||
temperature: temperature ?? 0.5,
|
||
stream: true
|
||
};
|
||
};
|
||
config.responseExtractor = (data) => data?.content?.[0]?.text;
|
||
config.streamSupport = true;
|
||
config.streamHandler = 'claude';
|
||
break;
|
||
case 'gemini':
|
||
case 'gemini-preview': // Assuming gemini-preview also supports this
|
||
let baseUrl = config.endpoint.split('?')[0];
|
||
// 代理模式下,API Key 由后端配置,前端不需要传递
|
||
if (isProxyMode) {
|
||
// 代理模式:使用后端配置的 Key,前端不传递
|
||
config.endpoint = `${proxyBaseUrl.replace(/\/$/, '')}/api/llm/gemini/v1/models/${modelId || 'gemini-pro'}:streamGenerateContent?alt=sse`;
|
||
config.streamEndpoint = config.endpoint;
|
||
} else {
|
||
// 直连模式:使用前端 Key
|
||
config.endpoint = `${baseUrl}?key=${key}`;
|
||
config.streamEndpoint = `${baseUrl}?key=${key}&alt=sse`;
|
||
}
|
||
const geminiModelIdToUse = modelId || (normalizedFormat === 'gemini-preview' ? 'gemini-1.5-flash-latest' : 'gemini-pro'); // Updated default for preview
|
||
config.modelName = geminiModelIdToUse;
|
||
|
||
config.bodyBuilder = (sys_prompt, user_content) => ({
|
||
contents: [{ role: "user", parts: convertOpenAIToGeminiParts(user_content) }],
|
||
generationConfig: { temperature: temperature ?? 0.5, maxOutputTokens: max_tokens ?? 8192 },
|
||
...(sys_prompt && { systemInstruction: { parts: [{ text: sys_prompt }] }})
|
||
});
|
||
config.streamBodyBuilder = (sys, msgs, user_content) => {
|
||
const geminiMessages = [];
|
||
if (msgs.length) {
|
||
for (const msg of msgs) {
|
||
geminiMessages.push({ role: msg.role === 'assistant' ? 'model' : 'user', parts: convertOpenAIToGeminiParts(msg.content) });
|
||
}
|
||
}
|
||
geminiMessages.push({ role: 'user', parts: convertOpenAIToGeminiParts(user_content) });
|
||
return {
|
||
contents: geminiMessages,
|
||
generationConfig: {
|
||
temperature: temperature ?? 0.5,
|
||
maxOutputTokens: max_tokens ?? 8192,
|
||
...(normalizedFormat === 'gemini-preview' && { responseModalities: ["TEXT"], responseMimeType: "text/plain" })
|
||
},
|
||
...(sys && { systemInstruction: { parts: [{ text: sys }] }})
|
||
};
|
||
};
|
||
config.responseExtractor = (data) => {
|
||
if (data?.candidates && data.candidates.length > 0 && data.candidates[0].content) {
|
||
const parts = data.candidates[0].content.parts;
|
||
return parts && parts.length > 0 ? parts.map(p => p.text).join('') : ''; // Join text parts
|
||
}
|
||
return '';
|
||
};
|
||
config.streamSupport = true;
|
||
config.streamHandler = 'gemini';
|
||
break;
|
||
case 'volcano':
|
||
case 'tongyi':
|
||
// 代理模式下,Authorization 头可选(后端会使用环境变量中的 Key)
|
||
if (authKey) {
|
||
config.headers['Authorization'] = `Bearer ${authKey}`;
|
||
}
|
||
let specificModelId = '';
|
||
// 读取保存的默认模型ID作为具体模型
|
||
try {
|
||
const cfg = (customRequestFormat === 'volcano') ? (window.loadModelConfig && loadModelConfig('volcano')) : (window.loadModelConfig && loadModelConfig('tongyi'));
|
||
if (cfg && (cfg.preferredModelId || cfg.modelId)) specificModelId = cfg.preferredModelId || cfg.modelId;
|
||
} catch {}
|
||
|
||
config.bodyBuilder = (sys_prompt, user_content) => ({
|
||
model: modelId || specificModelId,
|
||
messages: [
|
||
{ role: 'system', content: sys_prompt },
|
||
{ role: 'user', content: extractTextFromUserContent(user_content) }
|
||
],
|
||
temperature: temperature ?? 0.5,
|
||
max_tokens: max_tokens ?? 8192,
|
||
stream: true
|
||
});
|
||
config.streamBodyBuilder = (sys, msgs, user_content) => ({
|
||
model: modelId || specificModelId,
|
||
messages: [
|
||
{ role: 'system', content: sys },
|
||
...msgs.map(m => ({ role: m.role, content: extractTextFromUserContent(m.content) })),
|
||
{ role: 'user', content: extractTextFromUserContent(user_content) }
|
||
],
|
||
temperature: temperature ?? 0.5,
|
||
max_tokens: max_tokens ?? 8192,
|
||
stream: true
|
||
});
|
||
config.responseExtractor = (data) => data?.choices?.[0]?.message?.content;
|
||
config.streamSupport = true;
|
||
config.streamHandler = true;
|
||
break;
|
||
default:
|
||
// 代理模式下,Authorization 头可选(后端会使用环境变量中的 Key)
|
||
if (authKey) {
|
||
config.headers['Authorization'] = `Bearer ${authKey}`;
|
||
}
|
||
config.bodyBuilder = (sys_prompt, user_content) => ({
|
||
model: modelId,
|
||
messages: [{ role: "system", content: sys_prompt }, { role: "user", content: extractTextFromUserContent(user_content) }],
|
||
temperature: temperature ?? 0.5,
|
||
max_tokens: max_tokens ?? 8000
|
||
});
|
||
config.streamBodyBuilder = (sys, msgs, user_content) => ({
|
||
model: modelId,
|
||
messages: [
|
||
{ role: 'system', content: sys },
|
||
...msgs.map(m => ({ role: m.role, content: extractTextFromUserContent(m.content) })),
|
||
{ role: 'user', content: extractTextFromUserContent(user_content) }
|
||
],
|
||
stream: true,
|
||
temperature: temperature ?? 0.5,
|
||
max_tokens: max_tokens ?? 8000,
|
||
});
|
||
config.responseExtractor = (data) => data?.choices?.[0]?.message?.content;
|
||
config.streamSupport = true;
|
||
console.warn(`Custom request format "${resolvedRequestFormat}" is not explicitly handled for multimodal input. Defaulting to text-only for user messages if images are provided.`);
|
||
}
|
||
console.log('buildCustomApiConfig:', {
|
||
customRequestFormat: resolvedRequestFormat,
|
||
endpoint: apiEndpoint,
|
||
modelId,
|
||
streamSupport: config.streamSupport,
|
||
hasStreamBodyBuilder: !!config.streamBodyBuilder
|
||
});
|
||
return config;
|
||
}
|
||
|
||
// 导出到全局
|
||
window.ApiConfigBuilder = {
|
||
buildCustomApiConfig
|
||
};
|
||
|
||
})();
|