feat(convert): 增强协议转换功能,支持更多模型和工具调用
- 新增OpenAI到Claude的协议转换支持 - 添加工具调用状态管理器和JSON Schema清理功能 - 实现智能的reasoning_effort等级判断 - 优化错误处理和日志记录 - 重构代码结构,增加注释和辅助函数
This commit is contained in:
parent
1b7c143971
commit
66f758d741
5 changed files with 755 additions and 178 deletions
|
|
@ -202,10 +202,12 @@ export async function handleUnifiedResponse(res, responsePayload, isStream) {
|
||||||
|
|
||||||
export async function handleStreamRequest(res, service, model, requestBody, fromProvider, toProvider, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid) {
|
export async function handleStreamRequest(res, service, model, requestBody, fromProvider, toProvider, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid) {
|
||||||
let fullResponseText = '';
|
let fullResponseText = '';
|
||||||
|
let fullResponseJson = '';
|
||||||
let responseClosed = false;
|
let responseClosed = false;
|
||||||
|
|
||||||
await handleUnifiedResponse(res, '', true);
|
await handleUnifiedResponse(res, '', true);
|
||||||
|
|
||||||
|
// fs.writeFile('request'+Date.now()+'.json', JSON.stringify(requestBody));
|
||||||
// The service returns a stream in its native format (toProvider).
|
// The service returns a stream in its native format (toProvider).
|
||||||
const nativeStream = await service.generateContentStream(model, requestBody);
|
const nativeStream = await service.generateContentStream(model, requestBody);
|
||||||
const needsConversion = getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider);
|
const needsConversion = getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider);
|
||||||
|
|
@ -216,7 +218,7 @@ export async function handleStreamRequest(res, service, model, requestBody, from
|
||||||
for await (const nativeChunk of nativeStream) {
|
for await (const nativeChunk of nativeStream) {
|
||||||
// Convert chunk to the client's format (fromProvider), if necessary.
|
// Convert chunk to the client's format (fromProvider), if necessary.
|
||||||
const chunkText = extractResponseText(nativeChunk, toProvider);
|
const chunkText = extractResponseText(nativeChunk, toProvider);
|
||||||
if (chunkText) {
|
if (chunkText && !Array.isArray(chunkText)) {
|
||||||
fullResponseText += chunkText;
|
fullResponseText += chunkText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,6 +235,7 @@ export async function handleStreamRequest(res, service, model, requestBody, from
|
||||||
// console.log(`event: ${chunkToSend.type}\n`);
|
// console.log(`event: ${chunkToSend.type}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fullResponseJson += JSON.stringify(chunkToSend)+"\n";
|
||||||
res.write(`data: ${JSON.stringify(chunkToSend)}\n\n`);
|
res.write(`data: ${JSON.stringify(chunkToSend)}\n\n`);
|
||||||
// console.log(`data: ${JSON.stringify(chunkToSend)}\n`);
|
// console.log(`data: ${JSON.stringify(chunkToSend)}\n`);
|
||||||
}
|
}
|
||||||
|
|
@ -262,6 +265,7 @@ export async function handleStreamRequest(res, service, model, requestBody, from
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
await logConversation('output', fullResponseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
await logConversation('output', fullResponseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||||
|
// fs.writeFile('response'+Date.now()+'.json', fullResponseJson);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
824
src/convert.js
824
src/convert.js
|
|
@ -1,6 +1,10 @@
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from './common.js';
|
import { MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from './common.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 常量和辅助函数定义
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
// 定义默认常量
|
// 定义默认常量
|
||||||
const DEFAULT_MAX_TOKENS = 8192;
|
const DEFAULT_MAX_TOKENS = 8192;
|
||||||
const DEFAULT_GEMINI_MAX_TOKENS = 65536;
|
const DEFAULT_GEMINI_MAX_TOKENS = 65536;
|
||||||
|
|
@ -15,6 +19,162 @@ function checkAndAssignOrDefault(value, defaultValue) {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 映射结束原因
|
||||||
|
* @param {string} reason - 结束原因
|
||||||
|
* @param {string} sourceFormat - 源格式
|
||||||
|
* @param {string} targetFormat - 目标格式
|
||||||
|
* @returns {string} 映射后的结束原因
|
||||||
|
*/
|
||||||
|
function _mapFinishReason(reason, sourceFormat, targetFormat) {
|
||||||
|
const reasonMappings = {
|
||||||
|
openai: {
|
||||||
|
anthropic: {
|
||||||
|
stop: "end_turn",
|
||||||
|
length: "max_tokens",
|
||||||
|
content_filter: "stop_sequence",
|
||||||
|
tool_calls: "tool_use"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
anthropic: {
|
||||||
|
// 旧版本大写格式
|
||||||
|
STOP: "end_turn",
|
||||||
|
MAX_TOKENS: "max_tokens",
|
||||||
|
SAFETY: "stop_sequence",
|
||||||
|
RECITATION: "stop_sequence",
|
||||||
|
// 新版本小写格式(v1beta/v1 API)
|
||||||
|
stop: "end_turn",
|
||||||
|
length: "max_tokens",
|
||||||
|
safety: "stop_sequence",
|
||||||
|
recitation: "stop_sequence",
|
||||||
|
other: "end_turn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return reasonMappings[sourceFormat][targetFormat][reason] || "end_turn";
|
||||||
|
} catch (e) {
|
||||||
|
return "end_turn";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归清理Gemini不支持的JSON Schema属性
|
||||||
|
* @param {Object} schema - JSON Schema
|
||||||
|
* @returns {Object} 清理后的JSON Schema
|
||||||
|
*/
|
||||||
|
function _cleanJsonSchemaProperties(schema) {
|
||||||
|
if (!schema || typeof schema !== 'object') {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除所有非标准属性
|
||||||
|
const sanitized = {};
|
||||||
|
for (const [key, value] of Object.entries(schema)) {
|
||||||
|
if (["type", "description", "properties", "required", "enum", "items"].includes(key)) {
|
||||||
|
sanitized[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized.properties && typeof sanitized.properties === 'object') {
|
||||||
|
const cleanProperties = {};
|
||||||
|
for (const [propName, propSchema] of Object.entries(sanitized.properties)) {
|
||||||
|
cleanProperties[propName] = _cleanJsonSchemaProperties(propSchema);
|
||||||
|
}
|
||||||
|
sanitized.properties = cleanProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized.items) {
|
||||||
|
sanitized.items = _cleanJsonSchemaProperties(sanitized.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据budget_tokens智能判断OpenAI reasoning_effort等级
|
||||||
|
* @param {number|null} budgetTokens - Anthropic thinking的budget_tokens值
|
||||||
|
* @returns {string} OpenAI reasoning_effort等级 ("low", "medium", "high")
|
||||||
|
*/
|
||||||
|
function _determineReasoningEffortFromBudget(budgetTokens) {
|
||||||
|
// 如果没有提供budget_tokens,默认为high
|
||||||
|
if (budgetTokens === null || budgetTokens === undefined) {
|
||||||
|
console.info("No budget_tokens provided, defaulting to reasoning_effort='high'");
|
||||||
|
return "high";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从环境变量获取阈值配置
|
||||||
|
const lowThresholdStr = process.env.ANTHROPIC_TO_OPENAI_LOW_REASONING_THRESHOLD;
|
||||||
|
const highThresholdStr = process.env.ANTHROPIC_TO_OPENAI_HIGH_REASONING_THRESHOLD;
|
||||||
|
|
||||||
|
// 检查必需的环境变量
|
||||||
|
if (lowThresholdStr === undefined) {
|
||||||
|
throw new Error("ANTHROPIC_TO_OPENAI_LOW_REASONING_THRESHOLD environment variable is required for intelligent reasoning_effort determination");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (highThresholdStr === undefined) {
|
||||||
|
throw new Error("ANTHROPIC_TO_OPENAI_HIGH_REASONING_THRESHOLD environment variable is required for intelligent reasoning_effort determination");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lowThreshold = parseInt(lowThresholdStr, 10);
|
||||||
|
const highThreshold = parseInt(highThresholdStr, 10);
|
||||||
|
|
||||||
|
console.debug(`Threshold configuration: low <= ${lowThreshold}, medium <= ${highThreshold}, high > ${highThreshold}`);
|
||||||
|
|
||||||
|
let effort;
|
||||||
|
if (budgetTokens <= lowThreshold) {
|
||||||
|
effort = "low";
|
||||||
|
} else if (budgetTokens <= highThreshold) {
|
||||||
|
effort = "medium";
|
||||||
|
} else {
|
||||||
|
effort = "high";
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`🎯 Budget tokens ${budgetTokens} -> reasoning_effort '${effort}' (thresholds: low<=${lowThreshold}, high<=${highThreshold})`);
|
||||||
|
return effort;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Invalid threshold values in environment variables: ${e.message}. ANTHROPIC_TO_OPENAI_LOW_REASONING_THRESHOLD and ANTHROPIC_TO_OPENAI_HIGH_REASONING_THRESHOLD must be integers.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局工具状态管理器
|
||||||
|
class ToolStateManager {
|
||||||
|
constructor() {
|
||||||
|
if (ToolStateManager.instance) {
|
||||||
|
return ToolStateManager.instance;
|
||||||
|
}
|
||||||
|
ToolStateManager.instance = this;
|
||||||
|
this._toolMappings = {};
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储工具名到ID的映射
|
||||||
|
storeToolMapping(funcName, toolId) {
|
||||||
|
this._toolMappings[funcName] = toolId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据工具名获取ID
|
||||||
|
getToolId(funcName) {
|
||||||
|
return this._toolMappings[funcName] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有映射
|
||||||
|
clearMappings() {
|
||||||
|
this._toolMappings = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局工具状态管理器实例
|
||||||
|
const toolStateManager = new ToolStateManager();
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 主转换函数
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic data conversion function.
|
* Generic data conversion function.
|
||||||
* @param {object} data - The data to convert (request body or response).
|
* @param {object} data - The data to convert (request body or response).
|
||||||
|
|
@ -48,6 +208,7 @@ export function convertData(data, type, fromProvider, toProvider, model) {
|
||||||
},
|
},
|
||||||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
|
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
|
||||||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeChatCompletionFromGemini, // from Gemini protocol
|
[MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeChatCompletionFromGemini, // from Gemini protocol
|
||||||
|
[MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeChatCompletionFromOpenAI, // from OpenAI protocol
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
streamChunk: {
|
streamChunk: {
|
||||||
|
|
@ -57,6 +218,7 @@ export function convertData(data, type, fromProvider, toProvider, model) {
|
||||||
},
|
},
|
||||||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
|
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
|
||||||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeStreamChunkFromGemini, // from Gemini protocol
|
[MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeStreamChunkFromGemini, // from Gemini protocol
|
||||||
|
[MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeStreamChunkFromOpenAI, // from OpenAI protocol
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
modelList: {
|
modelList: {
|
||||||
|
|
@ -64,6 +226,10 @@ export function convertData(data, type, fromProvider, toProvider, model) {
|
||||||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIModelListFromGemini, // from Gemini protocol
|
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIModelListFromGemini, // from Gemini protocol
|
||||||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIModelListFromClaude, // from Claude protocol
|
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIModelListFromClaude, // from Claude protocol
|
||||||
},
|
},
|
||||||
|
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
|
||||||
|
[MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeModelListFromGemini, // from Gemini protocol
|
||||||
|
[MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeModelListFromOpenAI, // from OpenAI protocol
|
||||||
|
},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -90,6 +256,9 @@ export function convertData(data, type, fromProvider, toProvider, model) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// OpenAI 相关转换函数
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a Gemini API request body to an OpenAI chat completion request body.
|
* Converts a Gemini API request body to an OpenAI chat completion request body.
|
||||||
|
|
@ -199,7 +368,6 @@ function processGeminiPartsToOpenAIContent(parts) {
|
||||||
: contentArray;
|
: contentArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function toOpenAIModelListFromGemini(geminiModels) {
|
export function toOpenAIModelListFromGemini(geminiModels) {
|
||||||
return {
|
return {
|
||||||
object: "list",
|
object: "list",
|
||||||
|
|
@ -455,8 +623,6 @@ export function getOpenAIStreamChunkStop(model) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a Claude API model list response to an OpenAI model list response.
|
* Converts a Claude API model list response to an OpenAI model list response.
|
||||||
* @param {Array<Object>} claudeModels - The array of model objects from Claude API.
|
* @param {Array<Object>} claudeModels - The array of model objects from Claude API.
|
||||||
|
|
@ -476,7 +642,86 @@ export function toOpenAIModelListFromClaude(claudeModels) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an OpenAI chat completion response to a Claude API messages response.
|
||||||
|
* @param {Object} openaiResponse - The OpenAI API chat completion response object.
|
||||||
|
* @param {string} model - The model name to include in the response.
|
||||||
|
* @returns {Object} The formatted Claude API messages response.
|
||||||
|
*/
|
||||||
|
export function toClaudeChatCompletionFromOpenAI(openaiResponse, model) {
|
||||||
|
if (!openaiResponse || !openaiResponse.choices || openaiResponse.choices.length === 0) {
|
||||||
|
return {
|
||||||
|
id: `msg_${uuidv4()}`,
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [],
|
||||||
|
model: model,
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: openaiResponse?.usage?.prompt_tokens || 0,
|
||||||
|
output_tokens: openaiResponse?.usage?.completion_tokens || 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = openaiResponse.choices[0];
|
||||||
|
const contentList = [];
|
||||||
|
|
||||||
|
// Handle tool calls
|
||||||
|
const toolCalls = choice.message?.tool_calls || [];
|
||||||
|
for (const toolCall of toolCalls.filter(tc => tc && typeof tc === 'object')) {
|
||||||
|
if (toolCall.function) {
|
||||||
|
const func = toolCall.function;
|
||||||
|
const argStr = func.arguments || "{}";
|
||||||
|
let argObj;
|
||||||
|
try {
|
||||||
|
argObj = typeof argStr === 'string' ? JSON.parse(argStr) : argStr;
|
||||||
|
} catch (e) {
|
||||||
|
argObj = {};
|
||||||
|
}
|
||||||
|
contentList.push({
|
||||||
|
type: "tool_use",
|
||||||
|
id: toolCall.id || "",
|
||||||
|
name: func.name || "",
|
||||||
|
input: argObj,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text content
|
||||||
|
const contentText = choice.message?.content || "";
|
||||||
|
if (contentText) {
|
||||||
|
// 使用 _extractThinkingFromOpenAIText 提取 thinking 内容
|
||||||
|
const extractedContent = _extractThinkingFromOpenAIText(contentText);
|
||||||
|
if (Array.isArray(extractedContent)) {
|
||||||
|
contentList.push(...extractedContent);
|
||||||
|
} else {
|
||||||
|
contentList.push({ type: "text", text: extractedContent });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map OpenAI finish reason to Claude stop reason
|
||||||
|
const stopReason = _mapFinishReason(
|
||||||
|
choice.finish_reason || "stop",
|
||||||
|
"openai",
|
||||||
|
"anthropic"
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `msg_${uuidv4()}`,
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: contentList,
|
||||||
|
model: model,
|
||||||
|
stop_reason: stopReason,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: openaiResponse.usage?.prompt_tokens || 0,
|
||||||
|
output_tokens: openaiResponse.usage?.completion_tokens || 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a Claude API request body to an OpenAI chat completion request body.
|
* Converts a Claude API request body to an OpenAI chat completion request body.
|
||||||
|
|
@ -488,29 +733,107 @@ export function toOpenAIRequestFromClaude(claudeRequest) {
|
||||||
const openaiMessages = [];
|
const openaiMessages = [];
|
||||||
let systemMessageContent = '';
|
let systemMessageContent = '';
|
||||||
|
|
||||||
// Claude system message handling
|
// Add system message if present
|
||||||
if (claudeRequest.system) {
|
if (claudeRequest.system) {
|
||||||
systemMessageContent = claudeRequest.system;
|
systemMessageContent = claudeRequest.system;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process messages
|
||||||
if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) {
|
if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) {
|
||||||
claudeRequest.messages.forEach(message => {
|
const tempOpenAIMessages = [];
|
||||||
const openaiRole = message.role === 'assistant' ? 'assistant' : 'user';
|
for (const msg of claudeRequest.messages) {
|
||||||
const content = message.content; // Claude content can be string or array
|
const role = msg.role;
|
||||||
|
|
||||||
if (typeof content === 'string') {
|
// 处理用户的工具结果消息
|
||||||
openaiMessages.push({ role: openaiRole, content: content });
|
if (role === "user" && Array.isArray(msg.content)) {
|
||||||
} else if (Array.isArray(content)) {
|
const hasToolResult = msg.content.some(
|
||||||
// Process multimodal content
|
item => item && typeof item === 'object' && item.type === "tool_result"
|
||||||
const processedContent = processClaudeContentToOpenAIContent(content);
|
);
|
||||||
if (processedContent && processedContent.length > 0) {
|
|
||||||
openaiMessages.push({
|
if (hasToolResult) {
|
||||||
role: openaiRole,
|
for (const item of msg.content) {
|
||||||
content: processedContent
|
if (item && typeof item === 'object' && item.type === "tool_result") {
|
||||||
});
|
const toolUseId = item.tool_use_id || item.id || "";
|
||||||
|
const contentStr = String(item.content || "");
|
||||||
|
tempOpenAIMessages.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: toolUseId,
|
||||||
|
content: contentStr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue; // 已处理工具结果,跳过后续处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// 处理 assistant 消息中的工具调用
|
||||||
|
if (role === "assistant" && Array.isArray(msg.content) && msg.content.length > 0) {
|
||||||
|
const firstPart = msg.content[0];
|
||||||
|
if (firstPart.type === "tool_use") {
|
||||||
|
const funcName = firstPart.name || "";
|
||||||
|
const funcArgs = firstPart.input || {};
|
||||||
|
tempOpenAIMessages.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: firstPart.id || `call_${funcName}_1`,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: funcName,
|
||||||
|
arguments: JSON.stringify(funcArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
continue; // 已处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通文本消息
|
||||||
|
const contentConverted = processClaudeContentToOpenAIContent(msg.content || "");
|
||||||
|
// 跳过空消息,避免在历史中插入空字符串导致模型误判
|
||||||
|
if (contentConverted && (Array.isArray(contentConverted) ? contentConverted.length > 0 : contentConverted.trim().length > 0)) {
|
||||||
|
tempOpenAIMessages.push({
|
||||||
|
role: role,
|
||||||
|
content: contentConverted
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- OpenAI 兼容性校验 ----------------
|
||||||
|
// 确保所有 assistant.tool_calls 均有后续 tool 响应消息;否则移除不匹配的 tool_call
|
||||||
|
const validatedMessages = [];
|
||||||
|
for (let idx = 0; idx < tempOpenAIMessages.length; idx++) {
|
||||||
|
const m = tempOpenAIMessages[idx];
|
||||||
|
if (m.role === "assistant" && m.tool_calls) {
|
||||||
|
const callIds = m.tool_calls.map(tc => tc.id).filter(id => id);
|
||||||
|
// 统计后续是否有对应的 tool 消息
|
||||||
|
let unmatched = new Set(callIds);
|
||||||
|
for (let laterIdx = idx + 1; laterIdx < tempOpenAIMessages.length; laterIdx++) {
|
||||||
|
const later = tempOpenAIMessages[laterIdx];
|
||||||
|
if (later.role === "tool" && unmatched.has(later.tool_call_id)) {
|
||||||
|
unmatched.delete(later.tool_call_id);
|
||||||
|
}
|
||||||
|
if (unmatched.size === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (unmatched.size > 0) {
|
||||||
|
// 移除无匹配的 tool_call
|
||||||
|
m.tool_calls = m.tool_calls.filter(tc => !unmatched.has(tc.id));
|
||||||
|
// 如果全部被移除,则降级为普通 assistant 文本消息
|
||||||
|
if (m.tool_calls.length === 0) {
|
||||||
|
delete m.tool_calls;
|
||||||
|
if (m.content === null) {
|
||||||
|
m.content = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validatedMessages.push(m);
|
||||||
|
}
|
||||||
|
openaiMessages.push(...validatedMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
const openaiRequest = {
|
const openaiRequest = {
|
||||||
|
|
@ -519,12 +842,73 @@ export function toOpenAIRequestFromClaude(claudeRequest) {
|
||||||
max_tokens: checkAndAssignOrDefault(claudeRequest.max_tokens, DEFAULT_MAX_TOKENS),
|
max_tokens: checkAndAssignOrDefault(claudeRequest.max_tokens, DEFAULT_MAX_TOKENS),
|
||||||
temperature: checkAndAssignOrDefault(claudeRequest.temperature, DEFAULT_TEMPERATURE),
|
temperature: checkAndAssignOrDefault(claudeRequest.temperature, DEFAULT_TEMPERATURE),
|
||||||
top_p: checkAndAssignOrDefault(claudeRequest.top_p, DEFAULT_TOP_P),
|
top_p: checkAndAssignOrDefault(claudeRequest.top_p, DEFAULT_TOP_P),
|
||||||
// stream: claudeRequest.stream, // Stream mode is handled by different endpoint
|
stream: claudeRequest.stream, // Stream mode is handled by different endpoint
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Process tools
|
||||||
|
if (claudeRequest.tools) {
|
||||||
|
const openaiTools = [];
|
||||||
|
for (const tool of claudeRequest.tools) {
|
||||||
|
openaiTools.push({
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: tool.name || "",
|
||||||
|
description: tool.description || "",
|
||||||
|
parameters: _cleanJsonSchemaProperties(tool.input_schema || {}) // 使用清理函数
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
openaiRequest.tools = openaiTools;
|
||||||
|
openaiRequest.tool_choice = "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理思考预算转换 (Anthropic thinking -> OpenAI reasoning_effort + max_completion_tokens)
|
||||||
|
if (claudeRequest.thinking && claudeRequest.thinking.type === "enabled") {
|
||||||
|
const budgetTokens = claudeRequest.thinking.budget_tokens;
|
||||||
|
// 根据budget_tokens智能判断reasoning_effort等级
|
||||||
|
const reasoningEffort = _determineReasoningEffortFromBudget(budgetTokens);
|
||||||
|
openaiRequest.reasoning_effort = reasoningEffort;
|
||||||
|
|
||||||
|
// 处理max_completion_tokens的优先级逻辑
|
||||||
|
let maxCompletionTokens = null;
|
||||||
|
|
||||||
|
// 优先级1:客户端传入的max_tokens
|
||||||
|
if (claudeRequest.max_tokens !== undefined) {
|
||||||
|
maxCompletionTokens = claudeRequest.max_tokens;
|
||||||
|
delete openaiRequest.max_tokens; // 移除max_tokens,使用max_completion_tokens
|
||||||
|
console.info(`Using client max_tokens as max_completion_tokens: ${maxCompletionTokens}`);
|
||||||
|
} else {
|
||||||
|
// 优先级2:环境变量OPENAI_REASONING_MAX_TOKENS
|
||||||
|
const envMaxTokens = process.env.OPENAI_REASONING_MAX_TOKENS;
|
||||||
|
if (envMaxTokens) {
|
||||||
|
try {
|
||||||
|
maxCompletionTokens = parseInt(envMaxTokens, 10);
|
||||||
|
console.info(`Using OPENAI_REASONING_MAX_TOKENS from environment: ${maxCompletionTokens}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Invalid OPENAI_REASONING_MAX_TOKENS value '${envMaxTokens}', must be integer`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!envMaxTokens) {
|
||||||
|
// 优先级3:都没有则报错
|
||||||
|
throw new Error("For OpenAI reasoning models, max_completion_tokens is required. Please specify max_tokens in the request or set OPENAI_REASONING_MAX_TOKENS environment variable.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openaiRequest.max_completion_tokens = maxCompletionTokens;
|
||||||
|
console.info(`Anthropic thinking enabled -> OpenAI reasoning_effort='${reasoningEffort}', max_completion_tokens=${maxCompletionTokens}`);
|
||||||
|
if (budgetTokens) {
|
||||||
|
console.info(`Budget tokens: ${budgetTokens} -> reasoning_effort: '${reasoningEffort}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add system message at the beginning if present
|
// Add system message at the beginning if present
|
||||||
if (systemMessageContent) {
|
if (systemMessageContent) {
|
||||||
openaiRequest.messages.unshift({ role: 'system', content: systemMessageContent });
|
let stringifiedSystemMessageContent = systemMessageContent;
|
||||||
|
if(Array.isArray(systemMessageContent)){
|
||||||
|
stringifiedSystemMessageContent = systemMessageContent.map(item =>
|
||||||
|
typeof item === 'string' ? item : item.text).join('\n');
|
||||||
|
}
|
||||||
|
openaiRequest.messages.unshift({ role: 'system', content: stringifiedSystemMessageContent });
|
||||||
}
|
}
|
||||||
|
|
||||||
return openaiRequest;
|
return openaiRequest;
|
||||||
|
|
@ -595,6 +979,9 @@ function processClaudeContentToOpenAIContent(content) {
|
||||||
return contentArray;
|
return contentArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Gemini 相关转换函数
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts an OpenAI chat completion request body to a Gemini API request body.
|
* Converts an OpenAI chat completion request body to a Gemini API request body.
|
||||||
|
|
@ -770,9 +1157,24 @@ function processOpenAIContentToGeminiParts(content) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeParseJSON(str) {
|
function safeParseJSON(str) {
|
||||||
|
if (!str) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
let cleanedStr = str;
|
||||||
|
|
||||||
|
// 处理可能被截断的转义序列
|
||||||
|
if (cleanedStr.endsWith('\\') && !cleanedStr.endsWith('\\\\')) {
|
||||||
|
cleanedStr = cleanedStr.substring(0, cleanedStr.length - 1); // 移除悬挂的反斜杠
|
||||||
|
} else if (cleanedStr.endsWith('\\u') || cleanedStr.endsWith('\\u0') || cleanedStr.endsWith('\\u00')) {
|
||||||
|
// 不完整的Unicode转义序列
|
||||||
|
const idx = cleanedStr.lastIndexOf('\\u');
|
||||||
|
cleanedStr = cleanedStr.substring(0, idx);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(str || '{}');
|
return JSON.parse(cleanedStr || '{}');
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
// 如果清理后仍然无法解析,则返回原始字符串或进行其他错误处理
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -787,6 +1189,246 @@ function buildToolConfig(toolChoice) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 tool_result 字段构造 Gemini functionResponse
|
||||||
|
* @param {Object} item - 工具结果项
|
||||||
|
* @returns {Object|null} functionResponse 对象
|
||||||
|
*/
|
||||||
|
function _buildFunctionResponse(item) {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判定是否为工具结果
|
||||||
|
const isResult = (
|
||||||
|
item.type === "tool_result" ||
|
||||||
|
item.tool_use_id !== undefined ||
|
||||||
|
item.tool_output !== undefined ||
|
||||||
|
item.result !== undefined ||
|
||||||
|
item.content !== undefined
|
||||||
|
);
|
||||||
|
if (!isResult) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取函数名
|
||||||
|
let funcName = null;
|
||||||
|
|
||||||
|
// 方法1:从映射表中获取(Anthropic格式)
|
||||||
|
const toolUseId = item.tool_use_id || item.id;
|
||||||
|
// 这里需要注意,AnthropicConverter内部维护的_toolUseMapping是类的私有属性,在convert.js中无法直接访问
|
||||||
|
// 因此,这里需要依赖全局的toolStateManager
|
||||||
|
// if (toolUseId && this._toolUseMapping) { // 这行代码在convert.js中将无法使用
|
||||||
|
// funcName = this._toolUseMapping[toolUseId];
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 方法1.5:使用全局工具状态管理器
|
||||||
|
if (!funcName && toolUseId) {
|
||||||
|
// 先尝试从ID中提取可能的函数名
|
||||||
|
let potentialFuncName = null;
|
||||||
|
if (String(toolUseId).startsWith("call_")) {
|
||||||
|
const nameAndHash = toolUseId.substring(4); // 去掉 "call_" 前缀
|
||||||
|
potentialFuncName = nameAndHash.substring(0, nameAndHash.lastIndexOf("_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查全局管理器中是否有对应的映射
|
||||||
|
if (potentialFuncName) {
|
||||||
|
const storedId = toolStateManager.getToolId(potentialFuncName);
|
||||||
|
if (storedId === toolUseId) {
|
||||||
|
funcName = potentialFuncName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2:从 tool_use_id 中提取(OpenAI格式)
|
||||||
|
if (!funcName && toolUseId && String(toolUseId).startsWith("call_")) {
|
||||||
|
// 格式: call_<function_name>_<hash> ,函数名可能包含多个下划线
|
||||||
|
const nameAndHash = toolUseId.substring(4); // 去掉 "call_" 前缀
|
||||||
|
funcName = nameAndHash.substring(0, nameAndHash.lastIndexOf("_")); // 去掉最后一个 hash 段
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法3:直接从字段获取
|
||||||
|
if (!funcName) {
|
||||||
|
funcName = (
|
||||||
|
item.tool_name ||
|
||||||
|
item.name ||
|
||||||
|
item.function_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!funcName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取结果内容
|
||||||
|
let funcResponse = null;
|
||||||
|
|
||||||
|
// 尝试多个可能的结果字段
|
||||||
|
for (const key of ["content", "tool_output", "output", "response", "result"]) {
|
||||||
|
if (item[key] !== undefined) {
|
||||||
|
funcResponse = item[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 content 是列表,尝试提取文本
|
||||||
|
if (Array.isArray(funcResponse) && funcResponse.length > 0) {
|
||||||
|
const textParts = funcResponse
|
||||||
|
.filter(p => p && typeof p === 'object' && p.type === "text")
|
||||||
|
.map(p => p.text || "");
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
funcResponse = textParts.join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保有响应内容
|
||||||
|
if (funcResponse === null || funcResponse === undefined) {
|
||||||
|
funcResponse = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini 要求 response 为 JSON 对象,若为原始字符串则包装
|
||||||
|
if (typeof funcResponse !== 'object') {
|
||||||
|
funcResponse = { content: String(funcResponse) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
functionResponse: {
|
||||||
|
name: funcName,
|
||||||
|
response: funcResponse
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Gemini API model list response to a Claude API model list response.
|
||||||
|
* @param {Object} geminiModels - The Gemini API model list response object.
|
||||||
|
* @returns {Object} The formatted Claude API model list response.
|
||||||
|
*/
|
||||||
|
export function toClaudeModelListFromGemini(geminiModels) {
|
||||||
|
return {
|
||||||
|
models: geminiModels.models.map(m => ({
|
||||||
|
name: m.name.startsWith('models/') ? m.name.substring(7) : m.name, // 移除 'models/' 前缀作为 name
|
||||||
|
// Claude models 可能包含其他字段,这里使用默认值
|
||||||
|
description: "", // Gemini models 不提供描述
|
||||||
|
// Claude API 可能需要其他字段,根据实际 API 文档调整
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an OpenAI API model list response to a Claude API model list response.
|
||||||
|
* @param {Object} openaiModels - The OpenAI API model list response object.
|
||||||
|
* @returns {Object} The formatted Claude API model list response.
|
||||||
|
*/
|
||||||
|
export function toClaudeModelListFromOpenAI(openaiModels) {
|
||||||
|
return {
|
||||||
|
models: openaiModels.data.map(m => ({
|
||||||
|
name: m.id, // OpenAI 的 id 映射为 Claude 的 name
|
||||||
|
// Claude models 可能包含其他字段,这里使用默认值
|
||||||
|
description: "", // OpenAI models 不提供描述
|
||||||
|
// Claude API 可能需要其他字段,根据实际 API 文档调整
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从OpenAI文本中提取thinking内容,返回Anthropic格式的content blocks
|
||||||
|
* @param {string} text - 文本内容
|
||||||
|
* @returns {string|Array} 提取后的内容
|
||||||
|
*/
|
||||||
|
function _extractThinkingFromOpenAIText(text) {
|
||||||
|
// 匹配 <thinking>...</thinking> 标签
|
||||||
|
const thinkingPattern = /<thinking>\s*(.*?)\s*<\/thinking>/gs;
|
||||||
|
const matches = [...text.matchAll(thinkingPattern)];
|
||||||
|
|
||||||
|
const contentBlocks = [];
|
||||||
|
let lastEnd = 0;
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
// 添加thinking标签之前的文本(如果有)
|
||||||
|
const beforeText = text.substring(lastEnd, match.index).trim();
|
||||||
|
if (beforeText) {
|
||||||
|
contentBlocks.push({
|
||||||
|
type: "text",
|
||||||
|
text: beforeText
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加thinking内容
|
||||||
|
const thinkingText = match[1].trim();
|
||||||
|
if (thinkingText) {
|
||||||
|
contentBlocks.push({
|
||||||
|
type: "thinking",
|
||||||
|
thinking: thinkingText
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEnd = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加最后一个thinking标签之后的文本(如果有)
|
||||||
|
const afterText = text.substring(lastEnd).trim();
|
||||||
|
if (afterText) {
|
||||||
|
contentBlocks.push({
|
||||||
|
type: "text",
|
||||||
|
text: afterText
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到thinking标签,返回原文本
|
||||||
|
if (contentBlocks.length === 0) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只有一个文本块,返回字符串
|
||||||
|
if (contentBlocks.length === 1 && contentBlocks[0].type === "text") {
|
||||||
|
return contentBlocks[0].text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an OpenAI chat completion stream chunk to a Claude API messages stream chunk.
|
||||||
|
* @param {Object} openaiChunk - The OpenAI API chat completion stream chunk object.
|
||||||
|
* @param {string} [model] - Optional model name to include in the response.
|
||||||
|
* @returns {Object} The formatted Claude API messages stream chunk.
|
||||||
|
*/
|
||||||
|
export function toClaudeStreamChunkFromOpenAI(openaiChunk, model) {
|
||||||
|
if (!openaiChunk) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具调用
|
||||||
|
if ( Array.isArray(openaiChunk)) {
|
||||||
|
const toolCall = openaiChunk[0]; // 假设每次只处理一个工具调用
|
||||||
|
if (toolCall) {
|
||||||
|
if (toolCall.function && toolCall.function.name) {
|
||||||
|
const toolUseBlock = {
|
||||||
|
type: "tool_use",
|
||||||
|
id: toolCall.id || `call_${toolCall.function.name}_${Date.now()}`,
|
||||||
|
name: toolCall.function.name,
|
||||||
|
input: toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}
|
||||||
|
};
|
||||||
|
return { type: "content_block_start", index: 1, content_block: toolUseBlock };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本内容
|
||||||
|
if (typeof openaiChunk === 'string') {
|
||||||
|
return {
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
type: "text_delta",
|
||||||
|
text: openaiChunk
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildGenerationConfig({ temperature, max_tokens, top_p, stop }) {
|
function buildGenerationConfig({ temperature, max_tokens, top_p, stop }) {
|
||||||
const config = {};
|
const config = {};
|
||||||
config.temperature = checkAndAssignOrDefault(temperature, DEFAULT_TEMPERATURE);
|
config.temperature = checkAndAssignOrDefault(temperature, DEFAULT_TEMPERATURE);
|
||||||
|
|
@ -796,7 +1438,6 @@ function buildGenerationConfig({ temperature, max_tokens, top_p, stop }) {
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts an OpenAI chat completion request body to a Claude API request body.
|
* Converts an OpenAI chat completion request body to a Claude API request body.
|
||||||
* Handles system instructions, tool calls, and multimodal content.
|
* Handles system instructions, tool calls, and multimodal content.
|
||||||
|
|
@ -923,7 +1564,6 @@ function buildClaudeToolChoice(toolChoice) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts and combines all 'system' role messages into a single system instruction.
|
* Extracts and combines all 'system' role messages into a single system instruction.
|
||||||
* Filters out system messages and returns the remaining non-system messages.
|
* Filters out system messages and returns the remaining non-system messages.
|
||||||
|
|
@ -972,57 +1612,6 @@ export function extractTextFromMessageContent(content) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to detect MIME type from base64 data URL
|
|
||||||
* @param {string} dataUrl - Data URL string
|
|
||||||
* @returns {string} MIME type
|
|
||||||
*/
|
|
||||||
function detectMimeType(dataUrl) {
|
|
||||||
const match = dataUrl.match(/^data:([^;]+);base64,/);
|
|
||||||
return match ? match[1] : 'application/octet-stream';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to extract base64 data from data URL
|
|
||||||
* @param {string} dataUrl - Data URL string
|
|
||||||
* @returns {string} Base64 data
|
|
||||||
*/
|
|
||||||
function extractBase64Data(dataUrl) {
|
|
||||||
return dataUrl.replace(/^data:[^;]+;base64,/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to validate image MIME types
|
|
||||||
* @param {string} mimeType - MIME type to validate
|
|
||||||
* @returns {boolean} Whether it's a valid image type
|
|
||||||
*/
|
|
||||||
function isValidImageType(mimeType) {
|
|
||||||
const validTypes = [
|
|
||||||
'image/jpeg', 'image/jpg', 'image/png', 'image/gif',
|
|
||||||
'image/webp', 'image/bmp', 'image/tiff'
|
|
||||||
];
|
|
||||||
return validTypes.includes(mimeType.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to validate audio MIME types
|
|
||||||
* @param {string} mimeType - MIME type to validate
|
|
||||||
* @returns {boolean} Whether it's a valid audio type
|
|
||||||
*/
|
|
||||||
function isValidAudioType(mimeType) {
|
|
||||||
const validTypes = [
|
|
||||||
'audio/wav', 'audio/wave', 'audio/mp3', 'audio/mpeg',
|
|
||||||
'audio/ogg', 'audio/aac', 'audio/flac', 'audio/m4a'
|
|
||||||
];
|
|
||||||
return validTypes.includes(mimeType.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a Claude API request body to a Gemini API request body.
|
|
||||||
* Handles system instructions and multimodal content.
|
|
||||||
* @param {Object} claudeRequest - The request body from the Claude API.
|
|
||||||
* @returns {Object} The formatted request body for the Gemini API.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Converts a Claude API request body to a Gemini API request body.
|
* Converts a Claude API request body to a Gemini API request body.
|
||||||
* Handles system instructions and multimodal content.
|
* Handles system instructions and multimodal content.
|
||||||
|
|
@ -1380,91 +1969,6 @@ export function toClaudeStreamChunkFromGemini(geminiChunk, model) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different types of Gemini stream events
|
|
||||||
if (geminiChunk.candidates && geminiChunk.candidates.length > 0) {
|
|
||||||
const candidate = geminiChunk.candidates[0];
|
|
||||||
|
|
||||||
if (candidate.content && candidate.content.parts) {
|
|
||||||
const textParts = candidate.content.parts
|
|
||||||
.filter(part => part.text)
|
|
||||||
.map(part => part.text);
|
|
||||||
|
|
||||||
const functionCallPart = candidate.content.parts.find(part => part.functionCall);
|
|
||||||
|
|
||||||
if (functionCallPart) {
|
|
||||||
// Handle tool_use
|
|
||||||
return {
|
|
||||||
type: "content_block_start",
|
|
||||||
index: 0,
|
|
||||||
content_block: {
|
|
||||||
type: "tool_use",
|
|
||||||
id: `toolu_${uuidv4()}`, // Claude tool use ID format
|
|
||||||
name: functionCallPart.functionCall.name,
|
|
||||||
input: functionCallPart.functionCall.args || {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else if (textParts.length > 0) {
|
|
||||||
return {
|
|
||||||
type: "content_block_delta",
|
|
||||||
index: 0,
|
|
||||||
delta: {
|
|
||||||
type: "text_delta",
|
|
||||||
text: textParts.join('')
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle finish reason
|
|
||||||
if (candidate.finishReason) {
|
|
||||||
let stopReason = "end_turn";
|
|
||||||
switch (candidate.finishReason) {
|
|
||||||
case 'STOP':
|
|
||||||
stopReason = 'end_turn';
|
|
||||||
break;
|
|
||||||
case 'MAX_TOKENS':
|
|
||||||
stopReason = 'max_tokens';
|
|
||||||
break;
|
|
||||||
case 'SAFETY':
|
|
||||||
stopReason = 'safety';
|
|
||||||
break;
|
|
||||||
case 'RECITATION':
|
|
||||||
stopReason = 'recitation';
|
|
||||||
break;
|
|
||||||
case 'OTHER':
|
|
||||||
stopReason = 'other';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
stopReason = 'end_turn';
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: "message_delta",
|
|
||||||
delta: {
|
|
||||||
stop_reason: stopReason,
|
|
||||||
stop_sequence: null
|
|
||||||
},
|
|
||||||
usage: geminiChunk.usageMetadata ? {
|
|
||||||
output_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0
|
|
||||||
} : undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle usage metadata updates (only if no other content/finish reason)
|
|
||||||
if (geminiChunk.usageMetadata && (!geminiChunk.candidates || geminiChunk.candidates.length === 0)) {
|
|
||||||
return {
|
|
||||||
type: "message_delta",
|
|
||||||
delta: {},
|
|
||||||
usage: {
|
|
||||||
input_tokens: geminiChunk.usageMetadata.promptTokenCount || 0,
|
|
||||||
output_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default text delta for simple text chunks (should ideally be handled by candidate.content.parts)
|
|
||||||
// This case might occur if the geminiChunk is just a string, which is not typical for Gemini API.
|
|
||||||
// Added for robustness, but main logic should rely on geminiChunk.candidates.
|
|
||||||
if (typeof geminiChunk === 'string') {
|
if (typeof geminiChunk === 'string') {
|
||||||
return {
|
return {
|
||||||
type: "content_block_delta",
|
type: "content_block_delta",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
|
||||||
const CODE_ASSIST_API_VERSION = 'v1internal';
|
const CODE_ASSIST_API_VERSION = 'v1internal';
|
||||||
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
|
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
|
||||||
const OAUTH_CLIENT_SECRET = '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';
|
const OAUTH_CLIENT_SECRET = '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';
|
||||||
const GEMINI_MODELS = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-2.5-pro'];
|
const GEMINI_MODELS = ['gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.5-pro'];
|
||||||
|
|
||||||
function toGeminiApiResponse(codeAssistResponse) {
|
function toGeminiApiResponse(codeAssistResponse) {
|
||||||
if (!codeAssistResponse) return null;
|
if (!codeAssistResponse) return null;
|
||||||
|
|
@ -86,7 +86,8 @@ export class GeminiApiService {
|
||||||
console.log('[Gemini Auth] Token refresh response: ok');
|
console.log('[Gemini Auth] Token refresh response: ok');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'ENOENT') {
|
console.error('[Gemini Auth] Error initializing authentication:', error.code);
|
||||||
|
if (error.code === 'ENOENT' || error.code === 400) {
|
||||||
console.log(`[Gemini Auth] Credentials file '${credPath}' not found. Starting new authentication flow...`);
|
console.log(`[Gemini Auth] Credentials file '${credPath}' not found. Starting new authentication flow...`);
|
||||||
const newTokens = await this.getNewToken(credPath);
|
const newTokens = await this.getNewToken(credPath);
|
||||||
this.authClient.setCredentials(newTokens);
|
this.authClient.setCredentials(newTokens);
|
||||||
|
|
@ -99,7 +100,11 @@ export class GeminiApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNewToken(credPath) {
|
async getNewToken(credPath) {
|
||||||
const redirectUri = `http://${this.host}:${AUTH_REDIRECT_PORT}`;
|
let host = this.host;
|
||||||
|
if (!host || host === 'undefined') {
|
||||||
|
host = '127.0.0.1';
|
||||||
|
}
|
||||||
|
const redirectUri = `http://${host}:${AUTH_REDIRECT_PORT}`;
|
||||||
this.authClient.redirectUri = redirectUri;
|
this.authClient.redirectUri = redirectUri;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const authUrl = this.authClient.generateAuthUrl({ access_type: 'offline', scope: ['https://www.googleapis.com/auth/cloud-platform'] });
|
const authUrl = this.authClient.generateAuthUrl({ access_type: 'offline', scope: ['https://www.googleapis.com/auth/cloud-platform'] });
|
||||||
|
|
@ -206,7 +211,7 @@ export class GeminiApiService {
|
||||||
const res = await this.authClient.request(requestOptions);
|
const res = await this.authClient.request(requestOptions);
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response?.status === 401 && !isRetry) {
|
if ((error.response?.status === 400 || error.response?.status === 401) && !isRetry) {
|
||||||
console.log('[API] Received 401. Refreshing auth and retrying...');
|
console.log('[API] Received 401. Refreshing auth and retrying...');
|
||||||
await this.initializeAuth(true);
|
await this.initializeAuth(true);
|
||||||
return this.callApi(method, body, true, retryCount);
|
return this.callApi(method, body, true, retryCount);
|
||||||
|
|
@ -253,7 +258,7 @@ export class GeminiApiService {
|
||||||
}
|
}
|
||||||
yield* this.parseSSEStream(res.data);
|
yield* this.parseSSEStream(res.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response?.status === 401 && !isRetry) {
|
if ((error.response?.status === 400 || error.response?.status === 401) && !isRetry) {
|
||||||
console.log('[API] Received 401 during stream. Refreshing auth and retrying...');
|
console.log('[API] Received 401 during stream. Refreshing auth and retrying...');
|
||||||
await this.initializeAuth(true);
|
await this.initializeAuth(true);
|
||||||
yield* this.streamApi(method, body, true, retryCount);
|
yield* this.streamApi(method, body, true, retryCount);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ class OpenAIStrategy extends ProviderStrategy {
|
||||||
return choice.message.content;
|
return choice.message.content;
|
||||||
} else if (choice.delta && choice.delta.content) {
|
} else if (choice.delta && choice.delta.content) {
|
||||||
return choice.delta.content;
|
return choice.delta.content;
|
||||||
|
} else if (choice.delta && choice.delta.tool_calls && choice.delta.tool_calls.length > 0) {
|
||||||
|
return choice.delta.tool_calls;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import { randomUUID } from 'node:crypto';
|
||||||
const QWEN_DIR = '.qwen';
|
const QWEN_DIR = '.qwen';
|
||||||
const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json';
|
const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json';
|
||||||
const QWEN_LOCK_FILENAME = 'oauth_creds.lock';
|
const QWEN_LOCK_FILENAME = 'oauth_creds.lock';
|
||||||
|
const QWEN_MODEL_LIST = [
|
||||||
|
{ id: 'qwen3-coder-flash', name: 'Qwen3 Coder Flash' },
|
||||||
|
{ id: 'qwen3-coder-plus', name: 'Qwen3 Coder Plus' },
|
||||||
|
];
|
||||||
|
|
||||||
const TOKEN_REFRESH_BUFFER_MS = 30 * 1000;
|
const TOKEN_REFRESH_BUFFER_MS = 30 * 1000;
|
||||||
const LOCK_TIMEOUT_MS = 10000;
|
const LOCK_TIMEOUT_MS = 10000;
|
||||||
|
|
@ -193,7 +197,12 @@ export class QwenApiService {
|
||||||
async _authWithQwenDeviceFlow(client, config) {
|
async _authWithQwenDeviceFlow(client, config) {
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
const cancelHandler = () => { isCancelled = true; };
|
const cancelHandler = () => { isCancelled = true; };
|
||||||
|
const sigintHandler = () => {
|
||||||
|
isCancelled = true;
|
||||||
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
|
||||||
|
};
|
||||||
qwenOAuth2Events.once(QwenOAuth2Event.AuthCancel, cancelHandler);
|
qwenOAuth2Events.once(QwenOAuth2Event.AuthCancel, cancelHandler);
|
||||||
|
process.once('SIGINT', sigintHandler);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { code_verifier, code_challenge } = generatePKCEPair();
|
const { code_verifier, code_challenge } = generatePKCEPair();
|
||||||
|
|
@ -274,7 +283,20 @@ export class QwenApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the polling interval before the next attempt
|
// Wait for the polling interval before the next attempt
|
||||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
await new Promise(resolve => {
|
||||||
|
const timeoutId = setTimeout(resolve, pollInterval);
|
||||||
|
// If cancelled during wait, clear timeout and resolve immediately
|
||||||
|
if (isCancelled) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check again after waiting
|
||||||
|
if (isCancelled) {
|
||||||
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', 'Authentication cancelled by user.');
|
||||||
|
return { success: false, reason: 'cancelled' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { success: false, reason: 'timeout' };
|
return { success: false, reason: 'timeout' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -282,6 +304,7 @@ export class QwenApiService {
|
||||||
return { success: false, reason: 'error' };
|
return { success: false, reason: 'error' };
|
||||||
} finally {
|
} finally {
|
||||||
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
|
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
|
||||||
|
process.off('SIGINT', sigintHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -358,6 +381,37 @@ export class QwenApiService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes message content in the request body.
|
||||||
|
* If content is an array, it joins the elements with newlines.
|
||||||
|
* @param {Object} requestBody - The request body to process
|
||||||
|
* @returns {Object} The processed request body
|
||||||
|
*/
|
||||||
|
processMessageContent(requestBody) {
|
||||||
|
if (!requestBody || !requestBody.messages || !Array.isArray(requestBody.messages)) {
|
||||||
|
return requestBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedMessages = requestBody.messages.map(message => {
|
||||||
|
if (message.content && Array.isArray(message.content)) {
|
||||||
|
// Convert each item to JSON string before joining
|
||||||
|
const stringifiedContent = message.content.map(item =>
|
||||||
|
typeof item === 'string' ? item : item.text
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: stringifiedContent.join('\n')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...requestBody,
|
||||||
|
messages: processedMessages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async callApiWithAuthAndRetry(endpoint, body, isStream = false, retryCount = 0) {
|
async callApiWithAuthAndRetry(endpoint, body, isStream = false, retryCount = 0) {
|
||||||
const maxRetries = (this.config && this.config.REQUEST_MAX_RETRIES) || 3;
|
const maxRetries = (this.config && this.config.REQUEST_MAX_RETRIES) || 3;
|
||||||
const baseDelay = (this.config && this.config.REQUEST_BASE_DELAY) || 1000;
|
const baseDelay = (this.config && this.config.REQUEST_BASE_DELAY) || 1000;
|
||||||
|
|
@ -378,19 +432,27 @@ export class QwenApiService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Process message content before sending the request
|
||||||
|
const processedBody = body;//this.processMessageContent(body);
|
||||||
|
|
||||||
|
// Check if model in body is in QWEN_MODEL_LIST, if not, use the first model's id
|
||||||
|
if (processedBody.model && !QWEN_MODEL_LIST.some(model => model.id === processedBody.model)) {
|
||||||
|
processedBody.model = QWEN_MODEL_LIST[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
const defaultTools = [
|
const defaultTools = [
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "ext"
|
"name": "ext"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Merge tools if requestBody already has tools defined
|
// Merge tools if requestBody already has tools defined
|
||||||
const mergedTools = body.tools ? [...defaultTools, ...body.tools] : defaultTools;
|
const mergedTools = processedBody.tools ? [...defaultTools, ...processedBody.tools] : defaultTools;
|
||||||
|
|
||||||
const requestBody = isStream ? { ...body, stream: true, tools: mergedTools } : { ...body, tools: mergedTools };
|
const requestBody = isStream ? { ...processedBody, stream: true, tools: mergedTools } : { ...processedBody, tools: mergedTools };
|
||||||
const options = isStream ? { responseType: 'stream' } : {};
|
const options = isStream ? { responseType: 'stream' } : {};
|
||||||
const response = await this.currentAxiosInstance.post(endpoint, requestBody, options);
|
const response = await this.currentAxiosInstance.post(endpoint, requestBody, options);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|
@ -452,11 +514,7 @@ export class QwenApiService {
|
||||||
async listModels() {
|
async listModels() {
|
||||||
// Return the predefined models for Qwen
|
// Return the predefined models for Qwen
|
||||||
return {
|
return {
|
||||||
data: [
|
data: QWEN_MODEL_LIST
|
||||||
{ id: 'qwen3-coder-plus', name: 'Qwen3 Coder Plus' },
|
|
||||||
{ id: 'qwen3-coder-flash', name: 'Qwen3 Coder Flash' }
|
|
||||||
],
|
|
||||||
default_model: 'qwen3-coder-flash'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -503,8 +561,12 @@ class SharedTokenManager {
|
||||||
this.cleanupFunction = () => {
|
this.cleanupFunction = () => {
|
||||||
try { unlinkSync(this.getLockFilePath()); } catch (_error) { /* Ignore */ }
|
try { unlinkSync(this.getLockFilePath()); } catch (_error) { /* Ignore */ }
|
||||||
};
|
};
|
||||||
|
this.sigintHandler = () => {
|
||||||
|
try { unlinkSync(this.getLockFilePath()); } catch (_error) { /* Ignore */ }
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
process.on('exit', this.cleanupFunction);
|
process.on('exit', this.cleanupFunction);
|
||||||
process.on('SIGINT', this.cleanupFunction);
|
process.on('SIGINT', this.sigintHandler);
|
||||||
this.cleanupHandlersRegistered = true;
|
this.cleanupHandlersRegistered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue