feat(convert): 增强协议转换功能,支持更多模型和工具调用

- 新增OpenAI到Claude的协议转换支持
- 添加工具调用状态管理器和JSON Schema清理功能
- 实现智能的reasoning_effort等级判断
- 优化错误处理和日志记录
- 重构代码结构,增加注释和辅助函数
This commit is contained in:
hex2077 2025-09-05 18:03:34 +08:00
parent 1b7c143971
commit 66f758d741
5 changed files with 755 additions and 178 deletions

View file

@ -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) {
let fullResponseText = '';
let fullResponseJson = '';
let responseClosed = false;
await handleUnifiedResponse(res, '', true);
// fs.writeFile('request'+Date.now()+'.json', JSON.stringify(requestBody));
// The service returns a stream in its native format (toProvider).
const nativeStream = await service.generateContentStream(model, requestBody);
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) {
// Convert chunk to the client's format (fromProvider), if necessary.
const chunkText = extractResponseText(nativeChunk, toProvider);
if (chunkText) {
if (chunkText && !Array.isArray(chunkText)) {
fullResponseText += chunkText;
}
@ -233,6 +235,7 @@ export async function handleStreamRequest(res, service, model, requestBody, from
// console.log(`event: ${chunkToSend.type}\n`);
}
// fullResponseJson += JSON.stringify(chunkToSend)+"\n";
res.write(`data: ${JSON.stringify(chunkToSend)}\n\n`);
// console.log(`data: ${JSON.stringify(chunkToSend)}\n`);
}
@ -262,6 +265,7 @@ export async function handleStreamRequest(res, service, model, requestBody, from
res.end();
}
await logConversation('output', fullResponseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
// fs.writeFile('response'+Date.now()+'.json', fullResponseJson);
}
}

View file

@ -1,6 +1,10 @@
import { v4 as uuidv4 } from 'uuid';
import { MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from './common.js';
// =============================================================================
// 常量和辅助函数定义
// =============================================================================
// 定义默认常量
const DEFAULT_MAX_TOKENS = 8192;
const DEFAULT_GEMINI_MAX_TOKENS = 65536;
@ -15,6 +19,162 @@ function checkAndAssignOrDefault(value, 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.
* @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.GEMINI]: toClaudeChatCompletionFromGemini, // from Gemini protocol
[MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeChatCompletionFromOpenAI, // from OpenAI protocol
},
},
streamChunk: {
@ -57,6 +218,7 @@ export function convertData(data, type, fromProvider, toProvider, model) {
},
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
[MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeStreamChunkFromGemini, // from Gemini protocol
[MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeStreamChunkFromOpenAI, // from OpenAI protocol
},
},
modelList: {
@ -64,6 +226,10 @@ export function convertData(data, type, fromProvider, toProvider, model) {
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIModelListFromGemini, // from Gemini 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
},
}
};
@ -71,7 +237,7 @@ export function convertData(data, type, fromProvider, toProvider, model) {
if (!targetConversions) {
throw new Error(`Unsupported conversion type: ${type}`);
}
const toConversions = targetConversions[getProtocolPrefix(toProvider)];
if (!toConversions) {
throw new Error(`No conversions defined for target protocol: ${getProtocolPrefix(toProvider)} for type: ${type}`);
@ -81,7 +247,7 @@ export function convertData(data, type, fromProvider, toProvider, model) {
if (!conversionFunction) {
throw new Error(`No conversion function found from ${fromProvider} to ${toProvider} for type: ${type}`);
}
console.log(conversionFunction);
if (type === 'response' || type === 'streamChunk' || type === 'modelList') {
return conversionFunction(data, model);
@ -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.
@ -199,7 +368,6 @@ function processGeminiPartsToOpenAIContent(parts) {
: contentArray;
}
export function toOpenAIModelListFromGemini(geminiModels) {
return {
object: "list",
@ -455,8 +623,6 @@ export function getOpenAIStreamChunkStop(model) {
};
}
/**
* 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.
@ -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.
@ -488,29 +733,107 @@ export function toOpenAIRequestFromClaude(claudeRequest) {
const openaiMessages = [];
let systemMessageContent = '';
// Claude system message handling
// Add system message if present
if (claudeRequest.system) {
systemMessageContent = claudeRequest.system;
}
// Process messages
if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) {
claudeRequest.messages.forEach(message => {
const openaiRole = message.role === 'assistant' ? 'assistant' : 'user';
const content = message.content; // Claude content can be string or array
const tempOpenAIMessages = [];
for (const msg of claudeRequest.messages) {
const role = msg.role;
if (typeof content === 'string') {
openaiMessages.push({ role: openaiRole, content: content });
} else if (Array.isArray(content)) {
// Process multimodal content
const processedContent = processClaudeContentToOpenAIContent(content);
if (processedContent && processedContent.length > 0) {
openaiMessages.push({
role: openaiRole,
content: processedContent
});
// 处理用户的工具结果消息
if (role === "user" && Array.isArray(msg.content)) {
const hasToolResult = msg.content.some(
item => item && typeof item === 'object' && item.type === "tool_result"
);
if (hasToolResult) {
for (const item of msg.content) {
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 = {
@ -519,12 +842,73 @@ export function toOpenAIRequestFromClaude(claudeRequest) {
max_tokens: checkAndAssignOrDefault(claudeRequest.max_tokens, DEFAULT_MAX_TOKENS),
temperature: checkAndAssignOrDefault(claudeRequest.temperature, DEFAULT_TEMPERATURE),
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
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;
@ -595,6 +979,9 @@ function processClaudeContentToOpenAIContent(content) {
return contentArray;
}
// =============================================================================
// Gemini 相关转换函数
// =============================================================================
/**
* Converts an OpenAI chat completion request body to a Gemini API request body.
@ -770,9 +1157,24 @@ function processOpenAIContentToGeminiParts(content) {
}
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 {
return JSON.parse(str || '{}');
} catch {
return JSON.parse(cleanedStr || '{}');
} catch (e) {
// 如果清理后仍然无法解析,则返回原始字符串或进行其他错误处理
return str;
}
}
@ -787,6 +1189,246 @@ function buildToolConfig(toolChoice) {
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 }) {
const config = {};
config.temperature = checkAndAssignOrDefault(temperature, DEFAULT_TEMPERATURE);
@ -796,7 +1438,6 @@ function buildGenerationConfig({ temperature, max_tokens, top_p, stop }) {
return config;
}
/**
* Converts an OpenAI chat completion request body to a Claude API request body.
* Handles system instructions, tool calls, and multimodal content.
@ -923,7 +1564,6 @@ function buildClaudeToolChoice(toolChoice) {
return undefined;
}
/**
* Extracts and combines all 'system' role messages into a single system instruction.
* Filters out system messages and returns the remaining non-system messages.
@ -972,57 +1612,6 @@ export function extractTextFromMessageContent(content) {
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.
* Handles system instructions and multimodal content.
@ -1380,91 +1969,6 @@ export function toClaudeStreamChunkFromGemini(geminiChunk, model) {
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') {
return {
type: "content_block_delta",

View file

@ -14,7 +14,7 @@ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
const CODE_ASSIST_API_VERSION = 'v1internal';
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
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) {
if (!codeAssistResponse) return null;
@ -86,7 +86,8 @@ export class GeminiApiService {
console.log('[Gemini Auth] Token refresh response: ok');
}
} 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...`);
const newTokens = await this.getNewToken(credPath);
this.authClient.setCredentials(newTokens);
@ -99,7 +100,11 @@ export class GeminiApiService {
}
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;
return new Promise((resolve, reject) => {
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);
return res.data;
} 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...');
await this.initializeAuth(true);
return this.callApi(method, body, true, retryCount);
@ -253,7 +258,7 @@ export class GeminiApiService {
}
yield* this.parseSSEStream(res.data);
} 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...');
await this.initializeAuth(true);
yield* this.streamApi(method, body, true, retryCount);

View file

@ -21,6 +21,8 @@ class OpenAIStrategy extends ProviderStrategy {
return choice.message.content;
} else if (choice.delta && 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 '';

View file

@ -11,6 +11,10 @@ import { randomUUID } from 'node:crypto';
const QWEN_DIR = '.qwen';
const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json';
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 LOCK_TIMEOUT_MS = 10000;
@ -193,7 +197,12 @@ export class QwenApiService {
async _authWithQwenDeviceFlow(client, config) {
let isCancelled = false;
const cancelHandler = () => { isCancelled = true; };
const sigintHandler = () => {
isCancelled = true;
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
};
qwenOAuth2Events.once(QwenOAuth2Event.AuthCancel, cancelHandler);
process.once('SIGINT', sigintHandler);
try {
const { code_verifier, code_challenge } = generatePKCEPair();
@ -274,7 +283,20 @@ export class QwenApiService {
}
// 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' };
} catch (error) {
@ -282,6 +304,7 @@ export class QwenApiService {
return { success: false, reason: 'error' };
} finally {
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) {
const maxRetries = (this.config && this.config.REQUEST_MAX_RETRIES) || 3;
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 = [
{
"type": "function",
"function": {
"name": "ext"
"name": "ext"
}
}
];
// 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 response = await this.currentAxiosInstance.post(endpoint, requestBody, options);
return response.data;
@ -452,11 +514,7 @@ export class QwenApiService {
async listModels() {
// Return the predefined models for Qwen
return {
data: [
{ id: 'qwen3-coder-plus', name: 'Qwen3 Coder Plus' },
{ id: 'qwen3-coder-flash', name: 'Qwen3 Coder Flash' }
],
default_model: 'qwen3-coder-flash'
data: QWEN_MODEL_LIST
};
}
@ -503,8 +561,12 @@ class SharedTokenManager {
this.cleanupFunction = () => {
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('SIGINT', this.cleanupFunction);
process.on('SIGINT', this.sigintHandler);
this.cleanupHandlersRegistered = true;
}