fix(gemini): preserve thoughtSignature via tool_call ID smuggling + fix ELOCKED mutex

- Encode thoughtSignature into tool_call.id using _TSIG_ delimiter and base64url
- Decode _TSIG_ on request to restore thoughtSignature for Gemini multi-turn thinking
- Track pendingThoughtSignature across parts for deferred signature attachment
- Add LocalMutex (2-layer locking) to prevent ELOCKED on concurrent DB access
- Increase lockfile retries from 5 to 15 for multi-process robustness
- Restore db.json seed on first run to prevent ENOENT on lockfile.lock
- Use process.env.BASE_URL fallback in models test route
- Remove gemini-3-flash-lite-preview from provider models

Co-authored-by: kwanLeeFrmVi <quanle96@outlook.com>
Closes #450

Made-with: Cursor
This commit is contained in:
kwanLeeFrmVi
2026-03-30 02:53:57 +07:00
committed by decolua
parent 1c160cc8d9
commit 054facb08b
5 changed files with 193 additions and 216 deletions

View File

@@ -1,6 +1,11 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
// Encode base64 → base64url (safe for tool_call IDs: only [a-zA-Z0-9_-])
function toBase64Url(b64) {
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
// Convert Gemini response chunk to OpenAI format
export function geminiToOpenAIResponse(chunk, state) {
if (!chunk) return null;
@@ -18,6 +23,7 @@ export function geminiToOpenAIResponse(chunk, state) {
state.messageId = response.responseId || `msg_${Date.now()}`;
state.model = response.modelVersion || "gemini";
state.functionIndex = 0;
state.pendingThoughtSignature = null;
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
@@ -34,66 +40,19 @@ export function geminiToOpenAIResponse(chunk, state) {
// Process parts
if (content?.parts) {
for (const part of content.parts) {
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
const partThoughtSig = part.thoughtSignature || part.thought_signature || "";
const isThought = part.thought === true;
// Handle thought signature (thinking mode)
if (hasThoughtSig) {
const hasTextContent = part.text !== undefined && part.text !== "";
const hasFunctionCall = !!part.functionCall;
if (hasTextContent) {
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [{
index: 0,
delta: isThought
? { reasoning_content: part.text }
: { content: part.text },
finish_reason: null
}]
});
}
if (hasFunctionCall) {
const rawName = part.functionCall.name;
// Restore original tool name from mapping (AG cloaking)
const fcName = state.toolNameMap?.get(rawName) || rawName;
const fcArgs = part.functionCall.args || {};
const toolCallIndex = state.functionIndex++;
const toolCall = {
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
index: toolCallIndex,
type: "function",
function: {
name: fcName,
arguments: JSON.stringify(fcArgs)
}
};
state.toolCalls.set(toolCallIndex, toolCall);
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [{
index: 0,
delta: { tool_calls: [toolCall] },
finish_reason: null
}]
});
}
continue;
// Accumulate thoughtSignature across parts: a sig-only part precedes the functionCall part
if (partThoughtSig) {
state.pendingThoughtSignature = partThoughtSig;
}
// Text content (non-thinking)
if (part.text !== undefined && part.text !== "") {
const hasTextContent = part.text !== undefined && part.text !== "";
const hasFunctionCall = !!part.functionCall;
// Emit reasoning/thought text
if (hasTextContent) {
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
@@ -101,22 +60,29 @@ export function geminiToOpenAIResponse(chunk, state) {
model: state.model,
choices: [{
index: 0,
delta: { content: part.text },
delta: isThought
? { reasoning_content: part.text }
: { content: part.text },
finish_reason: null
}]
});
}
// Function call
if (part.functionCall) {
const rawName = part.functionCall.name;
// Restore original tool name from mapping (AG cloaking)
const fcName = state.toolNameMap?.get(rawName) || rawName;
// Emit function call, attaching the best available thoughtSignature
if (hasFunctionCall) {
const fcName = part.functionCall.name;
const fcArgs = part.functionCall.args || {};
const toolCallIndex = state.functionIndex++;
// Use signature from this part, or the one carried from a preceding part
const thoughtSig = partThoughtSig || state.pendingThoughtSignature || "";
if (thoughtSig) state.pendingThoughtSignature = null; // consumed
// Encode signature using _TSIG_ delimiter and base64url for safe tool_call ID
const toolCallId = thoughtSig
? `${fcName}-${Date.now()}-${toolCallIndex}_TSIG_${toBase64Url(thoughtSig)}`
: `${fcName}-${Date.now()}-${toolCallIndex}`;
const toolCall = {
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
id: toolCallId,
index: toolCallIndex,
type: "function",
function: {
@@ -124,9 +90,9 @@ export function geminiToOpenAIResponse(chunk, state) {
arguments: JSON.stringify(fcArgs)
}
};
state.toolCalls.set(toolCallIndex, toolCall);
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",