diff --git a/.github/ISSUE_TEMPLATE/tool_call_error_cn.yml b/.github/ISSUE_TEMPLATE/tool_call_error_cn.yml new file mode 100644 index 0000000..e89b258 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/tool_call_error_cn.yml @@ -0,0 +1,67 @@ +name: 工具调用错误报告 +description: 报告工具调用相关的错误 (中文) +title: "[工具错误]: " +labels: ["🐛 Bug"] +body: + - type: markdown + attributes: + value: | + 如果您遇到了工具调用(Tool Calls)相关的错误,请务必按照以下格式提交一个新的 Issue,以便我们能够快速定位和解决问题。 + + **请注意:** 为了排查工具调用问题,我们需要您提供详细的上下文日志。请在复现问题时开启调试模式,并提供完整的请求和响应日志。 + + - type: input + id: version + attributes: + label: 版本号 + description: 您当前使用的版本 + placeholder: "例如: v0.2.11" + validations: + required: true + + - type: dropdown + id: api_endpoint + attributes: + label: 请求接口 + options: + - OpenAI + - Gemini + validations: + required: true + + - type: input + id: model_name + attributes: + label: 模型名称 + placeholder: "例如: gemini-3-pro-preview / gemini-3-flash-preview" + validations: + required: true + + - type: input + id: client_tool + attributes: + label: 客户端/工具 + placeholder: "例如: NextChat / LobeChat / OpenCode" + validations: + required: true + + - type: textarea + id: problem_description + attributes: + label: 问题描述 + description: "请简要描述您遇到的错误现象,例如:请求成功但参数解析错误、返回 400 错误等" + placeholder: 描述错误的具体表现... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: 日志信息 (关键) + description: | + **请务必开启调试模式 (Debug Mode) 并重新发送请求以获取完整日志。** 请提供**该次请求的所有相关日志**(可以通过日期/时间来确定范围)。 + > ⚠️ **注意**: 请去除日志中的敏感信息(如 API Key),但**请务必保留日志的整体结构和格式**(尤其是 JSON 结构、空格和换行),这对排查解析错误至关重要。敏感内容可以用 `******` 替代。 + render: text + placeholder: 在这里粘贴详细日志... + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/tool_call_error_en.yml b/.github/ISSUE_TEMPLATE/tool_call_error_en.yml new file mode 100644 index 0000000..4be1da7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/tool_call_error_en.yml @@ -0,0 +1,67 @@ +name: Tool Call Error Report +description: Report an error related to tool calls (English) +title: "[Tool Error]: " +labels: ["🐛 Bug"] +body: + - type: markdown + attributes: + value: | + If you encounter errors related to Tool Calls, please submit a new issue using the format below so that we can quickly identify and resolve the problem. + + **Note:** To troubleshoot tool call issues, we need detailed context logs. Please enable debug mode when reproducing the issue and provide full request and response logs. + + - type: input + id: version + attributes: + label: Version + description: The version you are currently using + placeholder: "e.g., v0.2.11" + validations: + required: true + + - type: dropdown + id: api_endpoint + attributes: + label: API Endpoint + options: + - OpenAI + - Gemini + validations: + required: true + + - type: input + id: model_name + attributes: + label: Model Name + placeholder: "e.g., gemini-3-pro-preview / gemini-3-flash-preview" + validations: + required: true + + - type: input + id: client_tool + attributes: + label: Client/Tool + placeholder: "e.g., NextChat / LobeChat / OpenCode" + validations: + required: true + + - type: textarea + id: problem_description + attributes: + label: Problem Description + description: "Briefly describe the error you encountered (e.g., request succeeded but parameter parsing failed, returned 400 error, etc.)" + placeholder: Describe the error details... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Log Information (Critical) + description: | + **Please make sure to enable Debug Mode and resend the request to capture full logs.** Please provide **all logs related to this specific request** (you can determine the range by date/time). + > ⚠️ **Attention**: Please remove sensitive information (such as API Keys) from the logs, but **please preserve the overall structure and format of the logs** (especially JSON structure, spacing, and newlines). This is crucial for debugging parsing errors. You can replace sensitive content with `******`. + render: text + placeholder: Paste detailed logs here... + validations: + required: true diff --git a/README.md b/README.md index 173ad75..3f7ee9f 100644 --- a/README.md +++ b/README.md @@ -189,9 +189,10 @@ sudo docker compose down | `HOST` | 服务器监听的主机地址。 | `0.0.0.0` | | `ICON_URL` | 用于自定义控制台的 favicon 图标。支持 ICO, PNG, SVG 等格式。 | `/AIStudio_logo.svg` | | `SECURE_COOKIES` | 是否启用安全 Cookie。`true` 表示仅支持 HTTPS 协议访问控制台。 | `false` | -| `RATE_LIMIT_MAX_ATTEMPTS` | 时间窗口内控制台允许的最大失败登录尝试次数(设为 0 禁用)。 | `5` | +| `RATE_LIMIT_MAX_ATTEMPTS` | 时间窗口内控制台允许的最大失败登录尝试次数(设为 `0` 禁用)。 | `5` | | `RATE_LIMIT_WINDOW_MINUTES` | 速率限制的时间窗口长度(分钟)。 | `15` | | `CHECK_UPDATE` | 是否在页面加载时检查版本更新。设为 `false` 可禁用。 | `true` | +| `LOG_LEVEL` | 日志输出等级。设为 `DEBUG` 启用详细调试日志。 | `INFO` | #### 🌐 代理配置 @@ -200,8 +201,8 @@ sudo docker compose down | `INITIAL_AUTH_INDEX` | 启动时使用的初始身份验证索引。 | `0` | | `MAX_RETRIES` | 请求失败后的最大重试次数(仅对假流式和非流式生效)。 | `3` | | `RETRY_DELAY` | 两次重试之间的间隔(毫秒)。 | `2000` | -| `SWITCH_ON_USES` | 自动切换帐户前允许的请求次数(设为 0 禁用)。 | `40` | -| `FAILURE_THRESHOLD` | 切换帐户前允许的连续失败次数(设为 0 禁用)。 | `3` | +| `SWITCH_ON_USES` | 自动切换帐户前允许的请求次数(设为 `0` 禁用)。 | `40` | +| `FAILURE_THRESHOLD` | 切换帐户前允许的连续失败次数(设为 `0` 禁用)。 | `3` | | `IMMEDIATE_SWITCH_STATUS_CODES` | 触发立即切换帐户的 HTTP 状态码(逗号分隔)。 | `429,503` | #### 🗒️ 其他配置 diff --git a/README_EN.md b/README_EN.md index e423b5c..b7d5be9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -189,9 +189,10 @@ This endpoint is forwarded to the official Gemini API format endpoint. | `HOST` | Server listening host address. | `0.0.0.0` | | `ICON_URL` | Custom favicon URL for the console. Supports ICO, PNG, SVG, etc. | `/AIStudio_logo.svg` | | `SECURE_COOKIES` | Enable secure cookies. `true` for HTTPS only, `false` for both HTTP and HTTPS. | `false` | -| `RATE_LIMIT_MAX_ATTEMPTS` | Maximum failed login attempts allowed within the time window (0 to disable). | `5` | +| `RATE_LIMIT_MAX_ATTEMPTS` | Maximum failed login attempts allowed within the time window (`0` to disable). | `5` | | `RATE_LIMIT_WINDOW_MINUTES` | Time window for rate limiting in minutes. | `15` | | `CHECK_UPDATE` | Enable version update check on page load. Set to `false` to disable. | `true` | +| `LOG_LEVEL` | Logging output level. Set to `DEBUG` for detailed debug logs. | `INFO` | #### 🌐 Proxy Configuration @@ -200,8 +201,8 @@ This endpoint is forwarded to the official Gemini API format endpoint. | `INITIAL_AUTH_INDEX` | Initial authentication index to use on startup. | `0` | | `MAX_RETRIES` | Maximum number of retries for failed requests (only effective for fake streaming and non-streaming). | `3` | | `RETRY_DELAY` | Delay between retries in milliseconds. | `2000` | -| `SWITCH_ON_USES` | Number of requests before automatically switching accounts (0 to disable). | `40` | -| `FAILURE_THRESHOLD` | Number of consecutive failures before switching accounts (0 to disable). | `3` | +| `SWITCH_ON_USES` | Number of requests before automatically switching accounts (`0` to disable). | `40` | +| `FAILURE_THRESHOLD` | Number of consecutive failures before switching accounts (`0` to disable). | `3` | | `IMMEDIATE_SWITCH_STATUS_CODES` | HTTP status codes that trigger immediate account switching (comma-separated). | `429,503` | #### 🗒️ Other Configuration diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index 981b55a..89599d7 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -14,10 +14,108 @@ const mime = require("mime-types"); * Handles conversion between OpenAI and Google Gemini API formats */ class FormatConverter { + // Placeholder signature for Gemini 3 functionCall validation + static DUMMY_THOUGHT_SIGNATURE = "context_engineering_is_the_way_to_go"; + constructor(logger, serverSystem) { this.logger = logger; this.serverSystem = serverSystem; - this.streamUsage = null; // Cache for usage data in streams + } + + /** + * Ensure thoughtSignature is present in Gemini native format requests + * This handles direct Gemini API calls where functionCall may lack thoughtSignature + * Note: Only functionCall needs thoughtSignature, functionResponse does NOT need it + * @param {object} geminiBody - Gemini API request body + * @returns {object} - Modified request body with thoughtSignature placeholders + */ + ensureThoughtSignature(geminiBody) { + if (!geminiBody || !geminiBody.contents || !Array.isArray(geminiBody.contents)) { + return geminiBody; + } + + const DUMMY_SIGNATURE = FormatConverter.DUMMY_THOUGHT_SIGNATURE; + + for (const content of geminiBody.contents) { + if (!content.parts || !Array.isArray(content.parts)) continue; + + // Only add signature to functionCall, not functionResponse + let signatureAdded = false; + for (const part of content.parts) { + // Check for functionCall without thoughtSignature + if (part.functionCall && !part.thoughtSignature) { + if (!signatureAdded) { + part.thoughtSignature = DUMMY_SIGNATURE; + signatureAdded = true; + this.logger.info( + `[Adapter] Added dummy thoughtSignature for functionCall: ${part.functionCall.name}` + ); + } + } + // Note: functionResponse does NOT need thoughtSignature per official docs + } + } + + return geminiBody; + } + + /** + * Sanitize tools in native Gemini requests by removing unsupported JSON Schema fields + * like $schema and additionalProperties + * @param {object} geminiBody - Gemini format request body + * @returns {object} - Modified request body with sanitized tools + */ + sanitizeGeminiTools(geminiBody) { + if (!geminiBody || !geminiBody.tools || !Array.isArray(geminiBody.tools)) { + return geminiBody; + } + + // [DEBUG] Log original Gemini tools before sanitization + this.logger.debug(`[Adapter] Debug: original Gemini tools = ${JSON.stringify(geminiBody.tools, null, 2)}`); + + // Helper function to recursively sanitize schema: + // 1. Remove unsupported fields ($schema, additionalProperties) + // 2. Convert lowercase type to uppercase (object -> OBJECT, string -> STRING, etc.) + const sanitizeSchema = obj => { + if (!obj || typeof obj !== "object") return obj; + + const result = Array.isArray(obj) ? [] : {}; + + for (const key of Object.keys(obj)) { + // Skip fields not supported by Gemini API + if (key === "$schema" || key === "additionalProperties") { + continue; + } + + if (key === "type" && typeof obj[key] === "string") { + // Convert lowercase type to uppercase for Gemini + result[key] = obj[key].toUpperCase(); + } else if (typeof obj[key] === "object" && obj[key] !== null) { + result[key] = sanitizeSchema(obj[key]); + } else { + result[key] = obj[key]; + } + } + + return result; + }; + + // Process each tool + for (const tool of geminiBody.tools) { + if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) { + for (const funcDecl of tool.functionDeclarations) { + if (funcDecl.parameters) { + funcDecl.parameters = sanitizeSchema(funcDecl.parameters); + } + } + } + } + + // [DEBUG] Log sanitized Gemini tools after processing + this.logger.debug(`[Adapter] Debug: sanitized Gemini tools = ${JSON.stringify(geminiBody.tools, null, 2)}`); + + this.logger.info("[Adapter] Sanitized Gemini tools (removed unsupported fields, converted type to uppercase)"); + return geminiBody; } /** @@ -26,7 +124,12 @@ class FormatConverter { async translateOpenAIToGoogle(openaiBody) { // eslint-disable-line no-unused-vars this.logger.info("[Adapter] Starting translation of OpenAI request format to Google format..."); - this.streamUsage = null; // Reset usage cache for new stream + // [DEBUG] Log incoming messages for troubleshooting + this.logger.debug(`[Adapter] Debug: incoming messages = ${JSON.stringify(openaiBody.messages, null, 2)}`); + // [DEBUG] Log original OpenAI tools + if (openaiBody.tools && openaiBody.tools.length > 0) { + this.logger.debug(`[Adapter] Debug: original OpenAI tools = ${JSON.stringify(openaiBody.tools, null, 2)}`); + } let systemInstruction = null; const googleContents = []; @@ -43,15 +146,108 @@ class FormatConverter { // Convert conversation messages const conversationMessages = openaiBody.messages.filter(msg => msg.role !== "system"); - for (const message of conversationMessages) { + + // Buffer for accumulating consecutive tool message parts + // Gemini requires alternating roles, so consecutive tool messages must be merged + let pendingToolParts = []; + + // Helper function to flush pending tool parts as a single user message + // Note: functionResponse does NOT need thoughtSignature per official docs + const flushToolParts = () => { + if (pendingToolParts.length > 0) { + googleContents.push({ + parts: pendingToolParts, + role: "user", // Gemini expects function responses as "user" role + }); + pendingToolParts = []; + } + }; + + for (let msgIndex = 0; msgIndex < conversationMessages.length; msgIndex++) { + const message = conversationMessages[msgIndex]; const googleParts = []; - if (typeof message.content === "string") { - googleParts.push({ text: message.content }); + // Handle tool role (function execution result) + if (message.role === "tool") { + // Convert OpenAI tool response to Gemini functionResponse + let responseContent; + try { + responseContent = + typeof message.content === "string" ? JSON.parse(message.content) : message.content; + } catch (e) { + // If content is not valid JSON, wrap it + responseContent = { result: message.content }; + } + + // Use function name from tool message (OpenAI format always includes name) + const functionName = message.name || "unknown_function"; + + // Add to buffer instead of pushing directly + // This allows merging consecutive tool messages into one user message + // Note: functionResponse does NOT need thoughtSignature per official docs + const functionResponsePart = { + functionResponse: { + name: functionName, + response: responseContent, + }, + }; + pendingToolParts.push(functionResponsePart); + continue; + } + + // Before processing non-tool messages, flush any pending tool parts + flushToolParts(); + + // Handle assistant messages with tool_calls + if (message.role === "assistant" && message.tool_calls && Array.isArray(message.tool_calls)) { + // Convert OpenAI tool_calls to Gemini functionCall + // For Gemini 3: thoughtSignature should only be on the FIRST functionCall part + let signatureAttachedToCall = false; + for (const toolCall of message.tool_calls) { + if (toolCall.type === "function" && toolCall.function) { + let args; + try { + args = + typeof toolCall.function.arguments === "string" + ? JSON.parse(toolCall.function.arguments) + : toolCall.function.arguments; + } catch (e) { + this.logger.warn( + `[Adapter] Failed to parse tool function arguments for "${toolCall.function.name}": ${e.message}` + ); + args = {}; + } + + const functionCallPart = { + functionCall: { + args, + name: toolCall.function.name, + }, + }; + // Pass back thoughtSignature only on the FIRST functionCall + // [PLACEHOLDER MODE] - Use dummy signature to skip validation for official Gemini API testing + if (!signatureAttachedToCall) { + functionCallPart.thoughtSignature = FormatConverter.DUMMY_THOUGHT_SIGNATURE; + signatureAttachedToCall = true; + this.logger.info( + `[Adapter] Using dummy thoughtSignature for first functionCall: ${toolCall.function.name}` + ); + } + googleParts.push(functionCallPart); + } + } + // Do not continue here; allow falling through to handle potential text content (e.g. thoughts) + } + + // Handle regular text content + if (typeof message.content === "string" && message.content.length > 0) { + const textPart = { text: message.content }; + googleParts.push(textPart); } else if (Array.isArray(message.content)) { for (const part of message.content) { if (part.type === "text") { - googleParts.push({ text: part.text }); + const textPart = { text: part.text }; + googleParts.push(textPart); } else if (part.type === "image_url" && part.image_url) { const dataUrl = part.image_url.url; const match = dataUrl.match(/^data:(image\/.*?);base64,(.*)$/); @@ -94,17 +290,25 @@ class FormatConverter { } } - googleContents.push({ - parts: googleParts, - role: message.role === "assistant" ? "model" : "user", - }); + if (googleParts.length > 0) { + googleContents.push({ + parts: googleParts, + role: message.role === "assistant" ? "model" : "user", + }); + } } + // Flush any remaining tool parts after the loop + flushToolParts(); + // Build Google request + this.logger.debug(`[Adapter] Debug: googleContents length = ${googleContents.length}`); + // [DEBUG] Log full googleContents for troubleshooting thoughtSignature issue + this.logger.debug(`[Adapter] Debug: googleContents = ${JSON.stringify(googleContents, null, 2)}`); const googleRequest = { contents: googleContents, ...(systemInstruction && { - systemInstruction: { parts: systemInstruction.parts }, + systemInstruction: { parts: systemInstruction.parts, role: "user" }, }), }; @@ -168,6 +372,127 @@ class FormatConverter { googleRequest.generationConfig = generationConfig; + // Convert OpenAI tools to Gemini functionDeclarations + const openaiTools = openaiBody.tools || openaiBody.functions; + if (openaiTools && Array.isArray(openaiTools) && openaiTools.length > 0) { + const functionDeclarations = []; + + // Helper function to convert OpenAI parameter types to Gemini format (uppercase) + // Also handles nullable types like ["string", "null"] -> type: "STRING", nullable: true + const convertParameterTypes = obj => { + if (!obj || typeof obj !== "object") return obj; + + const result = Array.isArray(obj) ? [] : {}; + + for (const key of Object.keys(obj)) { + // Skip fields not supported by Gemini API + // Gemini only supports: type, description, enum, items, properties, required, nullable + if (key === "$schema" || key === "additionalProperties") { + continue; + } + + if (key === "type") { + if (Array.isArray(obj[key])) { + // Handle nullable types like ["string", "null"] + const types = obj[key]; + const nonNullTypes = types.filter(t => t !== "null"); + const hasNull = types.includes("null"); + + if (hasNull) { + result.nullable = true; + } + + if (nonNullTypes.length === 1) { + // Single non-null type: use it directly + result[key] = nonNullTypes[0].toUpperCase(); + } else if (nonNullTypes.length > 1) { + // Multiple non-null types: keep as array (uppercase) + result[key] = nonNullTypes.map(t => t.toUpperCase()); + } else { + // Only null type, default to STRING + result[key] = "STRING"; + } + } else if (typeof obj[key] === "string") { + // Convert lowercase type to uppercase for Gemini + result[key] = obj[key].toUpperCase(); + } else if (typeof obj[key] === "object" && obj[key] !== null) { + result[key] = convertParameterTypes(obj[key]); + } else { + result[key] = obj[key]; + } + } else if (typeof obj[key] === "object" && obj[key] !== null) { + result[key] = convertParameterTypes(obj[key]); + } else { + result[key] = obj[key]; + } + } + + return result; + }; + + for (const tool of openaiTools) { + // Handle OpenAI tools format: { type: "function", function: {...} } + // Also handle legacy functions format: { name, description, parameters } + const funcDef = tool.function || tool; + + if (funcDef && funcDef.name) { + const declaration = { + name: funcDef.name, + }; + + if (funcDef.description) { + declaration.description = funcDef.description; + } + + if (funcDef.parameters) { + // Convert parameter types from lowercase to uppercase + declaration.parameters = convertParameterTypes(funcDef.parameters); + } + + functionDeclarations.push(declaration); + } + } + + if (functionDeclarations.length > 0) { + if (!googleRequest.tools) { + googleRequest.tools = []; + } + googleRequest.tools.push({ functionDeclarations }); + this.logger.info( + `[Adapter] Converted ${functionDeclarations.length} OpenAI tool(s) to Gemini functionDeclarations` + ); + } + } + + // Convert OpenAI tool_choice to Gemini toolConfig.functionCallingConfig + const toolChoice = openaiBody.tool_choice || openaiBody.function_call; + if (toolChoice) { + const functionCallingConfig = {}; + + if (toolChoice === "auto") { + functionCallingConfig.mode = "AUTO"; + } else if (toolChoice === "none") { + functionCallingConfig.mode = "NONE"; + } else if (toolChoice === "required") { + functionCallingConfig.mode = "ANY"; + } else if (typeof toolChoice === "object") { + // Handle { type: "function", function: { name: "xxx" } } + // or legacy { name: "xxx" } + const funcName = toolChoice.function?.name || toolChoice.name; + if (funcName) { + functionCallingConfig.mode = "ANY"; + functionCallingConfig.allowedFunctionNames = [funcName]; + } + } + + if (Object.keys(functionCallingConfig).length > 0) { + googleRequest.toolConfig = { functionCallingConfig }; + this.logger.info( + `[Adapter] Converted tool_choice to Gemini toolConfig: ${JSON.stringify(functionCallingConfig)}` + ); + } + } + // Force web search and URL context if (this.serverSystem.forceWebSearch || this.serverSystem.forceUrlContext) { if (!googleRequest.tools) { @@ -207,6 +532,13 @@ class FormatConverter { { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE" }, ]; + // [DEBUG] Log full request body for troubleshooting 400 errors + if (googleRequest.tools && googleRequest.tools.length > 0) { + this.logger.debug( + `[Adapter] Debug: Sanitized Openai tools = ${JSON.stringify(googleRequest.tools, null, 2)}` + ); + } + this.logger.info("[Adapter] Translation complete."); return googleRequest; } @@ -218,7 +550,15 @@ class FormatConverter { * @param {object} streamState - Optional state object to track thought mode */ translateGoogleToOpenAIStream(googleChunk, modelName = "gemini-2.5-flash-lite", streamState = null) { - console.log(`[Adapter] Received Google chunk: ${googleChunk}`); + this.logger.debug(`[Adapter] Debug: Received Google chunk: ${googleChunk}`); + + // Ensure streamState exists to properly track tool call indices + if (!streamState) { + this.logger.warn( + "[Adapter] streamState not provided, creating default state. This may cause issues with tool call tracking." + ); + streamState = {}; + } if (!googleChunk || googleChunk.trim() === "") { return null; } @@ -240,16 +580,17 @@ class FormatConverter { return null; } - if (streamState && !streamState.id) { + if (!streamState.id) { streamState.id = `chatcmpl-${this._generateRequestId()}`; streamState.created = Math.floor(Date.now() / 1000); } - const streamId = streamState ? streamState.id : `chatcmpl-${this._generateRequestId()}`; - const created = streamState ? streamState.created : Math.floor(Date.now() / 1000); + const streamId = streamState.id; + const created = streamState.created; // Cache usage data whenever it arrives. + // Store in streamState to prevent concurrency issues between requests if (googleResponse.usageMetadata) { - this.streamUsage = this._parseUsage(googleResponse); + streamState.usage = this._parseUsage(googleResponse); } const candidate = googleResponse.candidates?.[0]; @@ -284,7 +625,6 @@ class FormatConverter { if (part.thought === true) { if (part.text) { delta.reasoning_content = part.text; - if (streamState) streamState.inThought = true; hasContent = true; } } else if (part.text) { @@ -295,11 +635,39 @@ class FormatConverter { delta.content = ``; this.logger.info("[Adapter] Successfully parsed image from streaming response chunk."); hasContent = true; + } else if (part.functionCall) { + // Convert Gemini functionCall to OpenAI tool_calls format + const funcCall = part.functionCall; + const toolCallId = `call_${this._generateRequestId()}`; + + // Track tool call index for multiple function calls + const toolCallIndex = streamState.toolCallIndex ?? 0; + streamState.toolCallIndex = toolCallIndex + 1; + + const toolCallObj = { + function: { + arguments: JSON.stringify(funcCall.args || {}), + name: funcCall.name, + }, + id: toolCallId, + index: toolCallIndex, + type: "function", + }; + + delta.tool_calls = [toolCallObj]; + + // Mark that we have a function call for finish_reason + streamState.hasFunctionCall = true; + + this.logger.info( + `[Adapter] Converted Gemini functionCall to OpenAI tool_calls: ${funcCall.name} (index: ${toolCallIndex})` + ); + hasContent = true; } if (hasContent) { // The 'role' should only be sent in the first chunk with content. - if (streamState && !streamState.roleSent) { + if (!streamState.roleSent) { delta.role = "assistant"; streamState.roleSent = true; } @@ -324,11 +692,19 @@ class FormatConverter { // Handle the final chunk with finish_reason and usage if (candidate.finishReason) { + // Determine the correct finish_reason for OpenAI format + let finishReason; + if (streamState.hasFunctionCall) { + finishReason = "tool_calls"; + } else { + finishReason = this._mapFinishReason(candidate.finishReason); + } + const finalResponse = { choices: [ { delta: {}, - finish_reason: candidate.finishReason, + finish_reason: finishReason, index: 0, }, ], @@ -336,13 +712,11 @@ class FormatConverter { id: streamId, model: modelName, object: "chat.completion.chunk", - usage: null, }; - // Attach cached usage data to the very last message - if (this.streamUsage) { - finalResponse.usage = this.streamUsage; - this.streamUsage = null; + // Attach cached usage data to the very last message (if available) + if (streamState.usage) { + finalResponse.usage = streamState.usage; } chunksToSend.push(`data: ${JSON.stringify(finalResponse)}\n\n`); } @@ -380,6 +754,7 @@ class FormatConverter { let content = ""; let reasoning_content = ""; + const tool_calls = []; if (candidate.content && Array.isArray(candidate.content.parts)) { for (const part of candidate.content.parts) { @@ -390,6 +765,22 @@ class FormatConverter { } else if (part.inlineData) { const image = part.inlineData; content += ``; + } else if (part.functionCall) { + // Convert Gemini functionCall to OpenAI tool_calls format + const funcCall = part.functionCall; + const toolCallId = `call_${this._generateRequestId()}`; + + const toolCallObj = { + function: { + arguments: JSON.stringify(funcCall.args || {}), + name: funcCall.name, + }, + id: toolCallId, + index: tool_calls.length, + type: "function", + }; + tool_calls.push(toolCallObj); + this.logger.info(`[Adapter] Converted Gemini functionCall to OpenAI tool_calls: ${funcCall.name}`); } } } @@ -398,11 +789,22 @@ class FormatConverter { if (reasoning_content) { message.reasoning_content = reasoning_content; } + if (tool_calls.length > 0) { + message.tool_calls = tool_calls; + } + + // Determine finish_reason + let finishReason; + if (tool_calls.length > 0) { + finishReason = "tool_calls"; + } else { + finishReason = this._mapFinishReason(candidate.finishReason); + } return { choices: [ { - finish_reason: candidate.finishReason || "stop", + finish_reason: finishReason, index: 0, message, }, @@ -415,6 +817,22 @@ class FormatConverter { }; } + /** + * Map Gemini finishReason to OpenAI format + * @param {string} geminiReason - Gemini finish reason + * @returns {string} - OpenAI finish reason + */ + _mapFinishReason(geminiReason) { + const reasonMap = { + max_tokens: "length", + other: "stop", + recitation: "stop", + safety: "content_filter", + stop: "stop", + }; + return reasonMap[(geminiReason || "stop").toLowerCase()] || "stop"; + } + _generateRequestId() { return `${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; } diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index 2bc4099..cbccd5a 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -418,7 +418,12 @@ class RequestHandler { } if (message.data) fullBody += message.data; } - const translatedChunk = this.formatConverter.translateGoogleToOpenAIStream(fullBody, model); + const streamState = {}; + const translatedChunk = this.formatConverter.translateGoogleToOpenAIStream( + fullBody, + model, + streamState + ); if (translatedChunk) res.write(translatedChunk); res.write("data: [DONE]\n\n"); this.logger.info("[Adapter] Fake mode: Complete content sent at once."); @@ -838,7 +843,7 @@ class RequestHandler { } async _streamOpenAIResponse(messageQueue, res, model) { - const streamState = { inThought: false }; + const streamState = {}; let streaming = true; while (streaming) { @@ -979,6 +984,18 @@ class RequestHandler { } } + // Pre-process native Google requests + // 1. Ensure thoughtSignature for functionCall (not functionResponse) + // 2. Sanitize tools (remove unsupported fields, convert type to uppercase) + if (req.method === "POST" && bodyObj) { + if (bodyObj.contents) { + this.formatConverter.ensureThoughtSignature(bodyObj); + } + if (bodyObj.tools) { + this.formatConverter.sanitizeGeminiTools(bodyObj); + } + } + // Force web search and URL context for native Google requests if ( (this.serverSystem.forceWebSearch || this.serverSystem.forceUrlContext) && diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index 6b8f2b2..b0b2645 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -9,6 +9,7 @@ const fs = require("fs"); const path = require("path"); const VersionChecker = require("../utils/VersionChecker"); +const LoggingService = require("../utils/LoggingService"); /** * Status Routes Manager @@ -254,6 +255,18 @@ class StatusRoutes { res.status(200).json({ message: "settingUpdateSuccess", setting: "forceUrlContext", value: statusText }); }); + app.put("/api/settings/debug-mode", isAuthenticated, (req, res) => { + const currentLevel = LoggingService.getLevel(); + const newLevel = currentLevel === "DEBUG" ? "INFO" : "DEBUG"; + LoggingService.setLevel(newLevel); + this.logger.info(`[WebUI] Log level switched to: ${newLevel}`); + res.status(200).json({ + message: "settingUpdateSuccess", + setting: "logLevel", + value: newLevel === "DEBUG" ? "debug" : "normal", + }); + }); + app.post("/api/files", isAuthenticated, (req, res) => { const { content } = req.body; // Ignore req.body.filename - auto rename @@ -357,6 +370,7 @@ class StatusRoutes { browserConnected: !!browserManager.browser, currentAccountName, currentAuthIndex, + debugMode: LoggingService.isDebugEnabled(), failureCount, forceThinking: this.serverSystem.forceThinking, forceUrlContext: this.serverSystem.forceUrlContext, diff --git a/src/utils/ConfigLoader.js b/src/utils/ConfigLoader.js index 501439f..184f452 100644 --- a/src/utils/ConfigLoader.js +++ b/src/utils/ConfigLoader.js @@ -1,6 +1,6 @@ /** * File: src/utils/ConfigLoader.js - * Description: Configuration loader that reads and validates system settings from config.json and environment variables + * Description: Configuration loader that reads and validates system settings from environment variables * * Maintainers: iBenzene, bbbugg * Original Author: Ellinav @@ -11,7 +11,7 @@ const path = require("path"); /** * Configuration Loader Module - * Responsible for loading system configuration from config.json and environment variables + * Responsible for loading system configuration from environment variables */ class ConfigLoader { constructor(logger) { @@ -19,7 +19,7 @@ class ConfigLoader { } loadConfiguration() { - let config = { + const config = { apiKeys: [], apiKeySource: "Not set", browserExecutablePath: null, @@ -37,17 +37,6 @@ class ConfigLoader { wsPort: 9998, }; - const configPath = path.join(process.cwd(), "config.json"); - try { - if (fs.existsSync(configPath)) { - const fileConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); - config = { ...config, ...fileConfig }; - this.logger.info("[System] Configuration loaded from config.json."); - } - } catch (error) { - this.logger.warn(`[System] Unable to read or parse config.json: ${error.message}`); - } - // Environment variable overrides if (process.env.PORT) config.httpPort = parseInt(process.env.PORT, 10) || config.httpPort; if (process.env.HOST) config.host = process.env.HOST; @@ -76,7 +65,7 @@ class ConfigLoader { if (!rawCodes && config.immediateSwitchStatusCodes && Array.isArray(config.immediateSwitchStatusCodes)) { rawCodes = config.immediateSwitchStatusCodes.join(","); - codesSource = "config.json file or default value"; + codesSource = "default value"; } if (rawCodes && typeof rawCodes === "string") { @@ -140,6 +129,9 @@ class ConfigLoader { this.logger.info(` HTTP Server Port: ${config.httpPort}`); this.logger.info(` Listening Address: ${config.host}`); this.logger.info(` Streaming Mode: ${config.streamingMode}`); + this.logger.info(` Force Thinking: ${config.forceThinking}`); + this.logger.info(` Force Web Search: ${config.forceWebSearch}`); + this.logger.info(` Force URL Context: ${config.forceUrlContext}`); this.logger.info( ` Usage-based Switch Threshold: ${ config.switchOnUses > 0 ? `Switch after every ${config.switchOnUses} requests` : "Disabled" diff --git a/src/utils/LoggingService.js b/src/utils/LoggingService.js index 2d70042..3478d27 100644 --- a/src/utils/LoggingService.js +++ b/src/utils/LoggingService.js @@ -11,6 +11,40 @@ * Responsible for formatting and recording system logs */ class LoggingService { + // Log levels: DEBUG < INFO < WARN < ERROR + static LEVELS = { DEBUG: 0, ERROR: 3, INFO: 1, WARN: 2 }; + static currentLevel = + process.env.LOG_LEVEL?.toUpperCase() === "DEBUG" ? LoggingService.LEVELS.DEBUG : LoggingService.LEVELS.INFO; + + /** + * Set the global log level + * @param {string} level - 'DEBUG', 'INFO', 'WARN', or 'ERROR' + */ + static setLevel(level) { + const upperLevel = level.toUpperCase(); + if (LoggingService.LEVELS[upperLevel] !== undefined) { + LoggingService.currentLevel = LoggingService.LEVELS[upperLevel]; + } + } + + /** + * Get the current log level name + * @returns {string} Current level name + */ + static getLevel() { + return Object.keys(LoggingService.LEVELS).find( + key => LoggingService.LEVELS[key] === LoggingService.currentLevel + ); + } + + /** + * Check if debug mode is enabled + * @returns {boolean} + */ + static isDebugEnabled() { + return LoggingService.currentLevel <= LoggingService.LEVELS.DEBUG; + } + constructor(serviceName = "ProxyServer") { this.serviceName = serviceName; this.logBuffer = []; @@ -73,7 +107,9 @@ class LoggingService { } debug(message) { - console.debug(this._formatMessage("DEBUG", message)); + if (LoggingService.currentLevel <= LoggingService.LEVELS.DEBUG) { + console.debug(this._formatMessage("DEBUG", message)); + } } } diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 1c62b89..858fd02 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -283,6 +283,15 @@ {{ t("entries") }}) +
{{ state.logs }}