From 99fcf50afed480c83a3fa2002476a2e9694fc2b7 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 29 Dec 2025 23:27:58 +0800 Subject: [PATCH 01/15] feat: enhance FormatConverter to support OpenAI tool responses and function calls --- src/core/FormatConverter.js | 246 +++++++++++++++++++++++++++++++++++- 1 file changed, 239 insertions(+), 7 deletions(-) diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index 981b55a..3c18ac3 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -46,6 +46,66 @@ class FormatConverter { for (const message of conversationMessages) { const googleParts = []; + // 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 }; + } + + googleParts.push({ + functionResponse: { + name: message.name || message.tool_call_id || "unknown_function", + response: responseContent, + }, + }); + + googleContents.push({ + parts: googleParts, + role: "user", // Gemini expects function responses as "user" role + }); + continue; + } + + // 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 (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) { + args = {}; + } + + googleParts.push({ + functionCall: { + args, + name: toolCall.function.name, + }, + }); + } + } + + if (googleParts.length > 0) { + googleContents.push({ + parts: googleParts, + role: "model", + }); + } + continue; + } + + // Handle regular text content if (typeof message.content === "string") { googleParts.push({ text: message.content }); } else if (Array.isArray(message.content)) { @@ -94,17 +154,19 @@ 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", + }); + } } // Build Google request const googleRequest = { contents: googleContents, ...(systemInstruction && { - systemInstruction: { parts: systemInstruction.parts }, + systemInstruction: { parts: systemInstruction.parts, role: "user" }, }), }; @@ -168,6 +230,94 @@ 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) + const convertParameterTypes = obj => { + if (!obj || typeof obj !== "object") return obj; + + const result = Array.isArray(obj) ? [] : {}; + + for (const key of Object.keys(obj)) { + 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] = 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) { @@ -295,6 +445,38 @@ class FormatConverter { delta.content = `![Generated Image](data:${image.mimeType};base64,${image.data})`; 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; + if (streamState) { + streamState.toolCallIndex = toolCallIndex + 1; + } + + delta.tool_calls = [ + { + function: { + arguments: JSON.stringify(funcCall.args || {}), + name: funcCall.name, + }, + id: toolCallId, + index: toolCallIndex, + type: "function", + }, + ]; + + // Mark that we have a function call for finish_reason + if (streamState) { + streamState.hasFunctionCall = true; + } + + this.logger.info( + `[Adapter] Converted Gemini functionCall to OpenAI tool_calls: ${funcCall.name} (index: ${toolCallIndex})` + ); + hasContent = true; } if (hasContent) { @@ -324,11 +506,28 @@ 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 { + // Map Gemini finishReason to OpenAI format + const reasonMap = { + max_tokens: "length", + other: "stop", + recitation: "stop", + safety: "content_filter", + stop: "stop", + }; + const lowerReason = candidate.finishReason.toLowerCase(); + finishReason = reasonMap[lowerReason] || "stop"; + } + const finalResponse = { choices: [ { delta: {}, - finish_reason: candidate.finishReason, + finish_reason: finishReason, index: 0, }, ], @@ -380,6 +579,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 +590,18 @@ class FormatConverter { } else if (part.inlineData) { const image = part.inlineData; content += `![Generated Image](data:${image.mimeType};base64,${image.data})`; + } else if (part.functionCall) { + // Convert Gemini functionCall to OpenAI tool_calls format + const funcCall = part.functionCall; + tool_calls.push({ + function: { + arguments: JSON.stringify(funcCall.args || {}), + name: funcCall.name, + }, + id: `call_${this._generateRequestId()}`, + type: "function", + }); + this.logger.info(`[Adapter] Converted Gemini functionCall to OpenAI tool_calls: ${funcCall.name}`); } } } @@ -398,11 +610,31 @@ 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 { + // Map Gemini finishReason to OpenAI format + const reasonMap = { + max_tokens: "length", + other: "stop", + recitation: "stop", + safety: "content_filter", + stop: "stop", + }; + const lowerReason = (candidate.finishReason || "stop").toLowerCase(); + finishReason = reasonMap[lowerReason] || "stop"; + } return { choices: [ { - finish_reason: candidate.finishReason || "stop", + finish_reason: finishReason, index: 0, message, }, From 184fcfca0cc9538112758f0bd645d0f0677b2084 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Tue, 30 Dec 2025 21:31:23 +0800 Subject: [PATCH 02/15] feat: implement thoughtSignature caching and encoding for multi-turn tool calls in FormatConverter --- src/core/FormatConverter.js | 262 +++++++++++++++++++++++++++++------- 1 file changed, 217 insertions(+), 45 deletions(-) diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index 3c18ac3..09ceb54 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -17,7 +17,12 @@ class FormatConverter { constructor(logger, serverSystem) { this.logger = logger; this.serverSystem = serverSystem; - this.streamUsage = null; // Cache for usage data in streams + // Cache for storing thoughtSignature from Gemini responses + // This allows multi-turn tool calling without client-side support + // Key: tool_call_id, Value: thoughtSignature + this.thoughtSignatureCache = new Map(); + // Max cache size to prevent memory leak + this.maxCacheSize = 1000; } /** @@ -26,7 +31,6 @@ 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 let systemInstruction = null; const googleContents = []; @@ -43,7 +47,79 @@ class FormatConverter { // Convert conversation messages const conversationMessages = openaiBody.messages.filter(msg => msg.role !== "system"); + + // Build toolIdToInfoMap from assistant messages with tool_calls + // This maps tool_call_id to {name, thoughtSignature} for later use when processing tool responses + const toolIdToInfoMap = new Map(); for (const message of conversationMessages) { + if (message.role === "assistant" && message.tool_calls && Array.isArray(message.tool_calls)) { + for (const toolCall of message.tool_calls) { + if (toolCall.id && toolCall.function?.name) { + // Extract thoughtSignature from tool_call_id if encoded (format: call_xxx::sig::base64) + let signature = toolCall.thoughtSignature || null; + let cleanId = toolCall.id; + + if (toolCall.id.includes("::sig::")) { + const parts = toolCall.id.split("::sig::"); + cleanId = parts[0]; + try { + signature = Buffer.from(parts[1], "base64").toString("utf-8"); + this.logger.info(`[Adapter] Extracted thoughtSignature from tool_call_id: ${cleanId}`); + } catch (e) { + this.logger.warn(`[Adapter] Failed to decode thoughtSignature from tool_call_id`); + } + } + + // Fallback: try server cache + if (!signature) { + const cachedSignature = this.thoughtSignatureCache.get(toolCall.id); + if (cachedSignature) { + signature = cachedSignature; + this.logger.info( + `[Adapter] Retrieved thoughtSignature from server cache for: ${toolCall.id}` + ); + } + } + + toolIdToInfoMap.set(toolCall.id, { + name: toolCall.function.name, + thoughtSignature: signature, + }); + } + } + } + } + + // 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 + // For Gemini 3: thoughtSignature should only be on the FIRST functionResponse part + const flushToolParts = () => { + if (pendingToolParts.length > 0) { + // Ensure only the first part has thoughtSignature + let signatureAttached = false; + for (const part of pendingToolParts) { + if (part.thoughtSignature) { + if (signatureAttached) { + // Remove signature from subsequent parts + delete part.thoughtSignature; + } else { + signatureAttached = true; + } + } + } + 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 = []; // Handle tool role (function execution result) @@ -58,23 +134,35 @@ class FormatConverter { responseContent = { result: message.content }; } - googleParts.push({ + // Resolve function name and thoughtSignature from toolIdToInfoMap + const toolInfo = message.tool_call_id && toolIdToInfoMap.get(message.tool_call_id); + const functionName = message.name || toolInfo?.name || "unknown_function"; + + // Add to buffer instead of pushing directly + // This allows merging consecutive tool messages into one user message + const functionResponsePart = { functionResponse: { - name: message.name || message.tool_call_id || "unknown_function", + name: functionName, response: responseContent, }, - }); - - googleContents.push({ - parts: googleParts, - role: "user", // Gemini expects function responses as "user" role - }); + }; + // Pass back thoughtSignature from the corresponding tool_call for Gemini 3 + if (toolInfo?.thoughtSignature) { + functionResponsePart.thoughtSignature = toolInfo.thoughtSignature; + this.logger.info(`[Adapter] Attached thoughtSignature to functionResponse: ${functionName}`); + } + 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; @@ -87,31 +175,35 @@ class FormatConverter { args = {}; } - googleParts.push({ + const functionCallPart = { functionCall: { args, name: toolCall.function.name, }, - }); + }; + // Pass back thoughtSignature only on the FIRST functionCall + if (toolCall.thoughtSignature && !signatureAttachedToCall) { + functionCallPart.thoughtSignature = toolCall.thoughtSignature; + signatureAttachedToCall = true; + this.logger.info( + `[Adapter] Attached thoughtSignature to first functionCall: ${toolCall.function.name}` + ); + } + googleParts.push(functionCallPart); } } - - if (googleParts.length > 0) { - googleContents.push({ - parts: googleParts, - role: "model", - }); - } - continue; + // Do not continue here; allow falling through to handle potential text content (e.g. thoughts) } // Handle regular text content - if (typeof message.content === "string") { - googleParts.push({ text: message.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,(.*)$/); @@ -162,7 +254,11 @@ class FormatConverter { } } + // Flush any remaining tool parts after the loop + flushToolParts(); + // Build Google request + this.logger.info(`[Adapter] Debug: googleContents length = ${googleContents.length}`); const googleRequest = { contents: googleContents, ...(systemInstruction && { @@ -236,15 +332,47 @@ class FormatConverter { 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)) { - if (key === "type" && typeof obj[key] === "string") { - // Convert lowercase type to uppercase for Gemini - result[key] = obj[key].toUpperCase(); + 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 { + result[key] = obj[key]; + } + } else if (key === "additionalProperties") { + // Handle additionalProperties: can be boolean or schema object + 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 { @@ -369,6 +497,11 @@ class FormatConverter { */ translateGoogleToOpenAIStream(googleChunk, modelName = "gemini-2.5-flash-lite", streamState = null) { console.log(`[Adapter] Received Google chunk: ${googleChunk}`); + + // Ensure streamState exists to properly track tool call indices + if (!streamState) { + streamState = {}; // Create default state to avoid index conflicts + } if (!googleChunk || googleChunk.trim() === "") { return null; } @@ -398,8 +531,9 @@ class FormatConverter { const created = streamState ? streamState.created : Math.floor(Date.now() / 1000); // Cache usage data whenever it arrives. - if (googleResponse.usageMetadata) { - this.streamUsage = this._parseUsage(googleResponse); + // Store in streamState to prevent concurrency issues between requests + if (googleResponse.usageMetadata && streamState) { + streamState.usage = this._parseUsage(googleResponse); } const candidate = googleResponse.candidates?.[0]; @@ -448,7 +582,17 @@ class FormatConverter { } else if (part.functionCall) { // Convert Gemini functionCall to OpenAI tool_calls format const funcCall = part.functionCall; - const toolCallId = `call_${this._generateRequestId()}`; + let toolCallId = `call_${this._generateRequestId()}`; + + // Encode thoughtSignature into tool_call_id for reliable pass-through + // Format: call_xxx::sig::base64(signature) + if (part.thoughtSignature) { + const encodedSig = Buffer.from(part.thoughtSignature).toString("base64"); + toolCallId = `${toolCallId}::sig::${encodedSig}`; + this.logger.info(`[Adapter] Encoded thoughtSignature into tool_call_id for: ${funcCall.name}`); + // Also cache for backward compatibility + this._cacheThoughtSignature(toolCallId, part.thoughtSignature); + } // Track tool call index for multiple function calls const toolCallIndex = streamState?.toolCallIndex ?? 0; @@ -456,17 +600,17 @@ class FormatConverter { streamState.toolCallIndex = toolCallIndex + 1; } - delta.tool_calls = [ - { - function: { - arguments: JSON.stringify(funcCall.args || {}), - name: funcCall.name, - }, - id: toolCallId, - index: toolCallIndex, - type: "function", + 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 if (streamState) { @@ -539,9 +683,8 @@ class FormatConverter { }; // Attach cached usage data to the very last message - if (this.streamUsage) { - finalResponse.usage = this.streamUsage; - this.streamUsage = null; + if (streamState && streamState.usage) { + finalResponse.usage = streamState.usage; } chunksToSend.push(`data: ${JSON.stringify(finalResponse)}\n\n`); } @@ -593,14 +736,28 @@ class FormatConverter { } else if (part.functionCall) { // Convert Gemini functionCall to OpenAI tool_calls format const funcCall = part.functionCall; - tool_calls.push({ + let toolCallId = `call_${this._generateRequestId()}`; + + // Encode thoughtSignature into tool_call_id for reliable pass-through + // Format: call_xxx::sig::base64(signature) + if (part.thoughtSignature) { + const encodedSig = Buffer.from(part.thoughtSignature).toString("base64"); + toolCallId = `${toolCallId}::sig::${encodedSig}`; + this.logger.info(`[Adapter] Encoded thoughtSignature into tool_call_id for: ${funcCall.name}`); + // Also cache for backward compatibility + this._cacheThoughtSignature(toolCallId, part.thoughtSignature); + } + + const toolCallObj = { function: { arguments: JSON.stringify(funcCall.args || {}), name: funcCall.name, }, - id: `call_${this._generateRequestId()}`, + 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}`); } } @@ -647,6 +804,21 @@ class FormatConverter { }; } + /** + * Cache thoughtSignature for server-side storage + * This allows multi-turn tool calling without client-side support + */ + _cacheThoughtSignature(toolCallId, signature) { + // Clean up old entries if cache is too large + if (this.thoughtSignatureCache.size >= this.maxCacheSize) { + // Remove oldest entries (first 100) + const keysToDelete = Array.from(this.thoughtSignatureCache.keys()).slice(0, 100); + keysToDelete.forEach(key => this.thoughtSignatureCache.delete(key)); + this.logger.info(`[Adapter] Cleaned up ${keysToDelete.length} old entries from thoughtSignature cache`); + } + this.thoughtSignatureCache.set(toolCallId, signature); + } + _generateRequestId() { return `${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; } From 0b721551138bb7f482f3306054f3a6bd82fb69de Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 8 Jan 2026 02:44:11 +0800 Subject: [PATCH 03/15] feat: remove thoughtSignature caching and encoding from FormatConverter --- src/core/FormatConverter.js | 111 ++++++------------------------------ 1 file changed, 16 insertions(+), 95 deletions(-) diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index 09ceb54..02a9a5e 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -17,12 +17,6 @@ class FormatConverter { constructor(logger, serverSystem) { this.logger = logger; this.serverSystem = serverSystem; - // Cache for storing thoughtSignature from Gemini responses - // This allows multi-turn tool calling without client-side support - // Key: tool_call_id, Value: thoughtSignature - this.thoughtSignatureCache = new Map(); - // Max cache size to prevent memory leak - this.maxCacheSize = 1000; } /** @@ -31,6 +25,8 @@ class FormatConverter { async translateOpenAIToGoogle(openaiBody) { // eslint-disable-line no-unused-vars this.logger.info("[Adapter] Starting translation of OpenAI request format to Google format..."); + // [DEBUG] Log incoming messages for troubleshooting + this.logger.info(`[Adapter] Debug: incoming messages = ${JSON.stringify(openaiBody.messages, null, 2)}`); let systemInstruction = null; const googleContents = []; @@ -48,48 +44,6 @@ class FormatConverter { // Convert conversation messages const conversationMessages = openaiBody.messages.filter(msg => msg.role !== "system"); - // Build toolIdToInfoMap from assistant messages with tool_calls - // This maps tool_call_id to {name, thoughtSignature} for later use when processing tool responses - const toolIdToInfoMap = new Map(); - for (const message of conversationMessages) { - if (message.role === "assistant" && message.tool_calls && Array.isArray(message.tool_calls)) { - for (const toolCall of message.tool_calls) { - if (toolCall.id && toolCall.function?.name) { - // Extract thoughtSignature from tool_call_id if encoded (format: call_xxx::sig::base64) - let signature = toolCall.thoughtSignature || null; - let cleanId = toolCall.id; - - if (toolCall.id.includes("::sig::")) { - const parts = toolCall.id.split("::sig::"); - cleanId = parts[0]; - try { - signature = Buffer.from(parts[1], "base64").toString("utf-8"); - this.logger.info(`[Adapter] Extracted thoughtSignature from tool_call_id: ${cleanId}`); - } catch (e) { - this.logger.warn(`[Adapter] Failed to decode thoughtSignature from tool_call_id`); - } - } - - // Fallback: try server cache - if (!signature) { - const cachedSignature = this.thoughtSignatureCache.get(toolCall.id); - if (cachedSignature) { - signature = cachedSignature; - this.logger.info( - `[Adapter] Retrieved thoughtSignature from server cache for: ${toolCall.id}` - ); - } - } - - toolIdToInfoMap.set(toolCall.id, { - name: toolCall.function.name, - thoughtSignature: signature, - }); - } - } - } - } - // Buffer for accumulating consecutive tool message parts // Gemini requires alternating roles, so consecutive tool messages must be merged let pendingToolParts = []; @@ -134,9 +88,8 @@ class FormatConverter { responseContent = { result: message.content }; } - // Resolve function name and thoughtSignature from toolIdToInfoMap - const toolInfo = message.tool_call_id && toolIdToInfoMap.get(message.tool_call_id); - const functionName = message.name || toolInfo?.name || "unknown_function"; + // 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 @@ -147,10 +100,10 @@ class FormatConverter { }, }; // Pass back thoughtSignature from the corresponding tool_call for Gemini 3 - if (toolInfo?.thoughtSignature) { - functionResponsePart.thoughtSignature = toolInfo.thoughtSignature; - this.logger.info(`[Adapter] Attached thoughtSignature to functionResponse: ${functionName}`); - } + // [PLACEHOLDER MODE] - Use dummy signature to skip validation for official Gemini API testing + // Official dummy signatures: "context_engineering_is_the_way_to_go" or "skip_thought_signature_validator" + functionResponsePart.thoughtSignature = "context_engineering_is_the_way_to_go"; + this.logger.info(`[Adapter] Using dummy thoughtSignature for functionResponse: ${functionName}`); pendingToolParts.push(functionResponsePart); continue; } @@ -182,11 +135,12 @@ class FormatConverter { }, }; // Pass back thoughtSignature only on the FIRST functionCall - if (toolCall.thoughtSignature && !signatureAttachedToCall) { - functionCallPart.thoughtSignature = toolCall.thoughtSignature; + // [PLACEHOLDER MODE] - Use dummy signature to skip validation for official Gemini API testing + if (!signatureAttachedToCall) { + functionCallPart.thoughtSignature = "context_engineering_is_the_way_to_go"; signatureAttachedToCall = true; this.logger.info( - `[Adapter] Attached thoughtSignature to first functionCall: ${toolCall.function.name}` + `[Adapter] Using dummy thoughtSignature for first functionCall: ${toolCall.function.name}` ); } googleParts.push(functionCallPart); @@ -259,6 +213,8 @@ class FormatConverter { // Build Google request this.logger.info(`[Adapter] Debug: googleContents length = ${googleContents.length}`); + // [DEBUG] Log full googleContents for troubleshooting thoughtSignature issue + this.logger.info(`[Adapter] Debug: googleContents = ${JSON.stringify(googleContents, null, 2)}`); const googleRequest = { contents: googleContents, ...(systemInstruction && { @@ -582,17 +538,7 @@ class FormatConverter { } else if (part.functionCall) { // Convert Gemini functionCall to OpenAI tool_calls format const funcCall = part.functionCall; - let toolCallId = `call_${this._generateRequestId()}`; - - // Encode thoughtSignature into tool_call_id for reliable pass-through - // Format: call_xxx::sig::base64(signature) - if (part.thoughtSignature) { - const encodedSig = Buffer.from(part.thoughtSignature).toString("base64"); - toolCallId = `${toolCallId}::sig::${encodedSig}`; - this.logger.info(`[Adapter] Encoded thoughtSignature into tool_call_id for: ${funcCall.name}`); - // Also cache for backward compatibility - this._cacheThoughtSignature(toolCallId, part.thoughtSignature); - } + const toolCallId = `call_${this._generateRequestId()}`; // Track tool call index for multiple function calls const toolCallIndex = streamState?.toolCallIndex ?? 0; @@ -736,17 +682,7 @@ class FormatConverter { } else if (part.functionCall) { // Convert Gemini functionCall to OpenAI tool_calls format const funcCall = part.functionCall; - let toolCallId = `call_${this._generateRequestId()}`; - - // Encode thoughtSignature into tool_call_id for reliable pass-through - // Format: call_xxx::sig::base64(signature) - if (part.thoughtSignature) { - const encodedSig = Buffer.from(part.thoughtSignature).toString("base64"); - toolCallId = `${toolCallId}::sig::${encodedSig}`; - this.logger.info(`[Adapter] Encoded thoughtSignature into tool_call_id for: ${funcCall.name}`); - // Also cache for backward compatibility - this._cacheThoughtSignature(toolCallId, part.thoughtSignature); - } + const toolCallId = `call_${this._generateRequestId()}`; const toolCallObj = { function: { @@ -804,21 +740,6 @@ class FormatConverter { }; } - /** - * Cache thoughtSignature for server-side storage - * This allows multi-turn tool calling without client-side support - */ - _cacheThoughtSignature(toolCallId, signature) { - // Clean up old entries if cache is too large - if (this.thoughtSignatureCache.size >= this.maxCacheSize) { - // Remove oldest entries (first 100) - const keysToDelete = Array.from(this.thoughtSignatureCache.keys()).slice(0, 100); - keysToDelete.forEach(key => this.thoughtSignatureCache.delete(key)); - this.logger.info(`[Adapter] Cleaned up ${keysToDelete.length} old entries from thoughtSignature cache`); - } - this.thoughtSignatureCache.set(toolCallId, signature); - } - _generateRequestId() { return `${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; } From 3dc1f6832f2fe37589942304aaaca274d01a9b4b Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 8 Jan 2026 02:57:36 +0800 Subject: [PATCH 04/15] feat: add ensureThoughtSignature method to handle missing thoughtSignature in Gemini requests --- src/core/FormatConverter.js | 44 +++++++++++++++++++++++++++++++++++++ src/core/RequestHandler.js | 6 +++++ 2 files changed, 50 insertions(+) diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index 02a9a5e..b1c9f4e 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -19,6 +19,50 @@ class FormatConverter { this.serverSystem = serverSystem; } + /** + * Ensure thoughtSignature is present in Gemini native format requests + * This handles direct Gemini API calls where functionCall/functionResponse may lack thoughtSignature + * @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 = "context_engineering_is_the_way_to_go"; + + for (const content of geminiBody.contents) { + if (!content.parts || !Array.isArray(content.parts)) continue; + + 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}` + ); + } + } + // Check for functionResponse without thoughtSignature + if (part.functionResponse && !part.thoughtSignature) { + if (!signatureAdded) { + part.thoughtSignature = DUMMY_SIGNATURE; + signatureAdded = true; + this.logger.info( + `[Adapter] Added dummy thoughtSignature for functionResponse: ${part.functionResponse.name}` + ); + } + } + } + } + + return geminiBody; + } + /** * Convert OpenAI request format to Google Gemini format */ diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index 2bc4099..162078e 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -979,6 +979,12 @@ class RequestHandler { } } + // Ensure thoughtSignature is present for functionCall/functionResponse in native Google requests + // This uses a dummy placeholder to skip validation for official Gemini API + if (req.method === "POST" && bodyObj && bodyObj.contents) { + this.formatConverter.ensureThoughtSignature(bodyObj); + } + // Force web search and URL context for native Google requests if ( (this.serverSystem.forceWebSearch || this.serverSystem.forceUrlContext) && From efe91b47eb1a0a2900d7d5a1974fe93a91375195 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 8 Jan 2026 19:48:59 +0800 Subject: [PATCH 05/15] feat: update thoughtSignature handling in FormatConverter to only require it for functionCall --- src/core/FormatConverter.js | 35 ++++++----------------------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index b1c9f4e..2136b58 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -21,7 +21,8 @@ class FormatConverter { /** * Ensure thoughtSignature is present in Gemini native format requests - * This handles direct Gemini API calls where functionCall/functionResponse may lack thoughtSignature + * 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 */ @@ -35,6 +36,7 @@ class FormatConverter { 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 @@ -47,16 +49,7 @@ class FormatConverter { ); } } - // Check for functionResponse without thoughtSignature - if (part.functionResponse && !part.thoughtSignature) { - if (!signatureAdded) { - part.thoughtSignature = DUMMY_SIGNATURE; - signatureAdded = true; - this.logger.info( - `[Adapter] Added dummy thoughtSignature for functionResponse: ${part.functionResponse.name}` - ); - } - } + // Note: functionResponse does NOT need thoughtSignature per official docs } } @@ -93,21 +86,9 @@ class FormatConverter { let pendingToolParts = []; // Helper function to flush pending tool parts as a single user message - // For Gemini 3: thoughtSignature should only be on the FIRST functionResponse part + // Note: functionResponse does NOT need thoughtSignature per official docs const flushToolParts = () => { if (pendingToolParts.length > 0) { - // Ensure only the first part has thoughtSignature - let signatureAttached = false; - for (const part of pendingToolParts) { - if (part.thoughtSignature) { - if (signatureAttached) { - // Remove signature from subsequent parts - delete part.thoughtSignature; - } else { - signatureAttached = true; - } - } - } googleContents.push({ parts: pendingToolParts, role: "user", // Gemini expects function responses as "user" role @@ -137,17 +118,13 @@ class FormatConverter { // 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, }, }; - // Pass back thoughtSignature from the corresponding tool_call for Gemini 3 - // [PLACEHOLDER MODE] - Use dummy signature to skip validation for official Gemini API testing - // Official dummy signatures: "context_engineering_is_the_way_to_go" or "skip_thought_signature_validator" - functionResponsePart.thoughtSignature = "context_engineering_is_the_way_to_go"; - this.logger.info(`[Adapter] Using dummy thoughtSignature for functionResponse: ${functionName}`); pendingToolParts.push(functionResponsePart); continue; } From 72798dc59d8fbc6fe26e6b66c9edcffc61bd4691 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 8 Jan 2026 20:53:51 +0800 Subject: [PATCH 06/15] chore: refactor streamState handling --- src/core/FormatConverter.js | 79 +++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index 2136b58..2b5108a 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -14,6 +14,9 @@ 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; @@ -31,7 +34,7 @@ class FormatConverter { return geminiBody; } - const DUMMY_SIGNATURE = "context_engineering_is_the_way_to_go"; + const DUMMY_SIGNATURE = FormatConverter.DUMMY_THOUGHT_SIGNATURE; for (const content of geminiBody.contents) { if (!content.parts || !Array.isArray(content.parts)) continue; @@ -146,6 +149,9 @@ class FormatConverter { ? 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 = {}; } @@ -158,7 +164,7 @@ class FormatConverter { // 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 = "context_engineering_is_the_way_to_go"; + functionCallPart.thoughtSignature = FormatConverter.DUMMY_THOUGHT_SIGNATURE; signatureAttachedToCall = true; this.logger.info( `[Adapter] Using dummy thoughtSignature for first functionCall: ${toolCall.function.name}` @@ -477,7 +483,10 @@ class FormatConverter { // Ensure streamState exists to properly track tool call indices if (!streamState) { - streamState = {}; // Create default state to avoid index conflicts + 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; @@ -500,16 +509,16 @@ 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 && streamState) { + if (googleResponse.usageMetadata) { streamState.usage = this._parseUsage(googleResponse); } @@ -545,7 +554,7 @@ class FormatConverter { if (part.thought === true) { if (part.text) { delta.reasoning_content = part.text; - if (streamState) streamState.inThought = true; + streamState.inThought = true; hasContent = true; } } else if (part.text) { @@ -562,10 +571,8 @@ class FormatConverter { const toolCallId = `call_${this._generateRequestId()}`; // Track tool call index for multiple function calls - const toolCallIndex = streamState?.toolCallIndex ?? 0; - if (streamState) { - streamState.toolCallIndex = toolCallIndex + 1; - } + const toolCallIndex = streamState.toolCallIndex ?? 0; + streamState.toolCallIndex = toolCallIndex + 1; const toolCallObj = { function: { @@ -580,9 +587,7 @@ class FormatConverter { delta.tool_calls = [toolCallObj]; // Mark that we have a function call for finish_reason - if (streamState) { - streamState.hasFunctionCall = true; - } + streamState.hasFunctionCall = true; this.logger.info( `[Adapter] Converted Gemini functionCall to OpenAI tool_calls: ${funcCall.name} (index: ${toolCallIndex})` @@ -592,7 +597,7 @@ class FormatConverter { 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; } @@ -619,19 +624,10 @@ class FormatConverter { if (candidate.finishReason) { // Determine the correct finish_reason for OpenAI format let finishReason; - if (streamState?.hasFunctionCall) { + if (streamState.hasFunctionCall) { finishReason = "tool_calls"; } else { - // Map Gemini finishReason to OpenAI format - const reasonMap = { - max_tokens: "length", - other: "stop", - recitation: "stop", - safety: "content_filter", - stop: "stop", - }; - const lowerReason = candidate.finishReason.toLowerCase(); - finishReason = reasonMap[lowerReason] || "stop"; + finishReason = this._mapFinishReason(candidate.finishReason); } const finalResponse = { @@ -650,7 +646,7 @@ class FormatConverter { }; // Attach cached usage data to the very last message - if (streamState && streamState.usage) { + if (streamState.usage) { finalResponse.usage = streamState.usage; } chunksToSend.push(`data: ${JSON.stringify(finalResponse)}\n\n`); @@ -733,16 +729,7 @@ class FormatConverter { if (tool_calls.length > 0) { finishReason = "tool_calls"; } else { - // Map Gemini finishReason to OpenAI format - const reasonMap = { - max_tokens: "length", - other: "stop", - recitation: "stop", - safety: "content_filter", - stop: "stop", - }; - const lowerReason = (candidate.finishReason || "stop").toLowerCase(); - finishReason = reasonMap[lowerReason] || "stop"; + finishReason = this._mapFinishReason(candidate.finishReason); } return { @@ -761,6 +748,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)}`; } From 14edf4a7bb98311728c691cf6eb0fdd2594644ef Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 8 Jan 2026 21:31:54 +0800 Subject: [PATCH 07/15] feat: enhance streamState handling in FormatConverter and RequestHandler --- src/core/FormatConverter.js | 3 +-- src/core/RequestHandler.js | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index 2b5108a..7b2221a 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -642,10 +642,9 @@ class FormatConverter { id: streamId, model: modelName, object: "chat.completion.chunk", - usage: null, }; - // Attach cached usage data to the very last message + // Attach cached usage data to the very last message (if available) if (streamState.usage) { finalResponse.usage = streamState.usage; } diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index 162078e..d330777 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) { From b7f54321479b7863aa79d5e5bda495b67599bd87 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 8 Jan 2026 23:58:27 +0800 Subject: [PATCH 08/15] chore: replace console.log with logger.info for Google chunk reception in FormatConverter --- src/core/FormatConverter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index 7b2221a..1ed2105 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -479,7 +479,7 @@ 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.info(`[Adapter] Received Google chunk: ${googleChunk}`); // Ensure streamState exists to properly track tool call indices if (!streamState) { From 64e542c1efaa38e4fd06ccfc977be8271bca4c23 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Fri, 9 Jan 2026 03:48:44 +0800 Subject: [PATCH 09/15] fix: add sanitizeGeminiTools method to clean unsupported fields in Gemini requests --- src/core/FormatConverter.js | 83 +++++++++++++++++++++++++++++++++---- src/core/RequestHandler.js | 14 +++++-- 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index 1ed2105..a9392a6 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -59,6 +59,65 @@ class FormatConverter { 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.info(`[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.info(`[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; + } + /** * Convert OpenAI request format to Google Gemini format */ @@ -67,6 +126,10 @@ class FormatConverter { this.logger.info("[Adapter] Starting translation of OpenAI request format to Google format..."); // [DEBUG] Log incoming messages for troubleshooting this.logger.info(`[Adapter] Debug: incoming messages = ${JSON.stringify(openaiBody.messages, null, 2)}`); + // [DEBUG] Log original OpenAI tools + if (openaiBody.tools && openaiBody.tools.length > 0) { + this.logger.info(`[Adapter] Debug: original OpenAI tools = ${JSON.stringify(openaiBody.tools, null, 2)}`); + } let systemInstruction = null; const googleContents = []; @@ -322,6 +385,12 @@ class FormatConverter { 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"] @@ -349,13 +418,6 @@ class FormatConverter { } else { result[key] = obj[key]; } - } else if (key === "additionalProperties") { - // Handle additionalProperties: can be boolean or schema object - 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 { @@ -468,6 +530,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.info( + `[Adapter] Debug: Sanitized Openai tools = ${JSON.stringify(googleRequest.tools, null, 2)}` + ); + } + this.logger.info("[Adapter] Translation complete."); return googleRequest; } diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index d330777..cbccd5a 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -984,10 +984,16 @@ class RequestHandler { } } - // Ensure thoughtSignature is present for functionCall/functionResponse in native Google requests - // This uses a dummy placeholder to skip validation for official Gemini API - if (req.method === "POST" && bodyObj && bodyObj.contents) { - this.formatConverter.ensureThoughtSignature(bodyObj); + // 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 From 6cffbd60f4da2efb7234f087e6ab366ad4f470b8 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Fri, 9 Jan 2026 17:33:38 +0800 Subject: [PATCH 10/15] fix: add support for nested object conversion in FormatConverter --- src/core/FormatConverter.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index a9392a6..314b4e8 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -415,6 +415,8 @@ class FormatConverter { } 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]; } @@ -623,7 +625,6 @@ class FormatConverter { if (part.thought === true) { if (part.text) { delta.reasoning_content = part.text; - streamState.inThought = true; hasContent = true; } } else if (part.text) { From ed25f5e1d100cfa50b499f17ad2aee60a28f6339 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Fri, 9 Jan 2026 18:40:21 +0800 Subject: [PATCH 11/15] feat: add debug logging and log level control in LoggingService --- src/core/FormatConverter.js | 16 ++++++++-------- src/routes/StatusRoutes.js | 14 ++++++++++++++ src/utils/LoggingService.js | 37 ++++++++++++++++++++++++++++++++++++- ui/app/pages/StatusPage.vue | 15 +++++++++++++++ ui/locales/en.json | 3 +++ ui/locales/zh.json | 3 +++ 6 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index 314b4e8..89599d7 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -71,7 +71,7 @@ class FormatConverter { } // [DEBUG] Log original Gemini tools before sanitization - this.logger.info(`[Adapter] Debug: original Gemini tools = ${JSON.stringify(geminiBody.tools, null, 2)}`); + 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) @@ -112,7 +112,7 @@ class FormatConverter { } // [DEBUG] Log sanitized Gemini tools after processing - this.logger.info(`[Adapter] Debug: sanitized Gemini tools = ${JSON.stringify(geminiBody.tools, null, 2)}`); + 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; @@ -125,10 +125,10 @@ class FormatConverter { // eslint-disable-line no-unused-vars this.logger.info("[Adapter] Starting translation of OpenAI request format to Google format..."); // [DEBUG] Log incoming messages for troubleshooting - this.logger.info(`[Adapter] Debug: incoming messages = ${JSON.stringify(openaiBody.messages, null, 2)}`); + 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.info(`[Adapter] Debug: original OpenAI tools = ${JSON.stringify(openaiBody.tools, null, 2)}`); + this.logger.debug(`[Adapter] Debug: original OpenAI tools = ${JSON.stringify(openaiBody.tools, null, 2)}`); } let systemInstruction = null; @@ -302,9 +302,9 @@ class FormatConverter { flushToolParts(); // Build Google request - this.logger.info(`[Adapter] Debug: googleContents length = ${googleContents.length}`); + this.logger.debug(`[Adapter] Debug: googleContents length = ${googleContents.length}`); // [DEBUG] Log full googleContents for troubleshooting thoughtSignature issue - this.logger.info(`[Adapter] Debug: googleContents = ${JSON.stringify(googleContents, null, 2)}`); + this.logger.debug(`[Adapter] Debug: googleContents = ${JSON.stringify(googleContents, null, 2)}`); const googleRequest = { contents: googleContents, ...(systemInstruction && { @@ -534,7 +534,7 @@ class FormatConverter { // [DEBUG] Log full request body for troubleshooting 400 errors if (googleRequest.tools && googleRequest.tools.length > 0) { - this.logger.info( + this.logger.debug( `[Adapter] Debug: Sanitized Openai tools = ${JSON.stringify(googleRequest.tools, null, 2)}` ); } @@ -550,7 +550,7 @@ class FormatConverter { * @param {object} streamState - Optional state object to track thought mode */ translateGoogleToOpenAIStream(googleChunk, modelName = "gemini-2.5-flash-lite", streamState = null) { - this.logger.info(`[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) { 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/LoggingService.js b/src/utils/LoggingService.js index 2d70042..306fab0 100644 --- a/src/utils/LoggingService.js +++ b/src/utils/LoggingService.js @@ -11,6 +11,39 @@ * 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 = LoggingService.LEVELS.INFO; // Default to 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 +106,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") }}) +
+ {{ t("logLevel") }} + + {{ logLevelText }} +
{{ state.logs }}