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:
@@ -197,7 +197,6 @@ export const PROVIDER_MODELS = {
|
||||
{ id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image Preview" },
|
||||
// Gemini 3 series
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
||||
{ id: "gemini-3-flash-lite-preview", name: "Gemini 3 Flash Lite Preview" },
|
||||
// Gemini 2.5 series
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
@@ -215,11 +214,13 @@ export const PROVIDER_MODELS = {
|
||||
// { id: "openrouter/free", name: "Free Models (Auto)" },
|
||||
],
|
||||
glm: [
|
||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
|
||||
],
|
||||
"glm-cn": [
|
||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "glm-4.7", name: "GLM-4.7" },
|
||||
{ id: "glm-4.6", name: "GLM-4.6" },
|
||||
@@ -311,10 +312,6 @@ export const PROVIDER_MODELS = {
|
||||
nvidia: [
|
||||
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "z-ai/glm4.7", name: "GLM 4.7" },
|
||||
{ id: "deepseek-ai/deepseek-v3.2", name: "DeepSeek V3.2" },
|
||||
{ id: "nvidia/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
|
||||
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1" },
|
||||
],
|
||||
nebius: [
|
||||
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" },
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
// import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
|
||||
import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/appConstants.js";
|
||||
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
|
||||
|
||||
// Decode base64url → standard base64 (for restoring thoughtSignature)
|
||||
function fromBase64Url(b64url) {
|
||||
let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
// Restore padding
|
||||
while (b64.length % 4) b64 += "=";
|
||||
return b64;
|
||||
}
|
||||
|
||||
function generateUUID() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
@@ -58,26 +65,32 @@ function openaiToGeminiBase(model, body, stream) {
|
||||
result.generationConfig.maxOutputTokens = body.max_tokens;
|
||||
}
|
||||
|
||||
// Build tool_call_id -> name map
|
||||
// Strip embedded thoughtSignature from a tool_call id ("rawId_TSIG_sig" → "rawId")
|
||||
const stripSig = (id) => {
|
||||
const sep = id ? id.indexOf("_TSIG_") : -1;
|
||||
return sep !== -1 ? id.slice(0, sep) : id;
|
||||
};
|
||||
|
||||
// Build tool_call_id -> name map (keyed by rawId)
|
||||
const tcID2Name = {};
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role === "assistant" && msg.tool_calls) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.type === "function" && tc.id && tc.function?.name) {
|
||||
tcID2Name[tc.id] = tc.function.name;
|
||||
tcID2Name[stripSig(tc.id)] = tc.function.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build tool responses cache
|
||||
// Build tool responses cache (keyed by rawId)
|
||||
const toolResponses = {};
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role === "tool" && msg.tool_call_id) {
|
||||
toolResponses[msg.tool_call_id] = msg.content;
|
||||
toolResponses[stripSig(msg.tool_call_id)] = msg.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,18 +115,6 @@ function openaiToGeminiBase(model, body, stream) {
|
||||
} else if (role === "assistant") {
|
||||
const parts = [];
|
||||
|
||||
// Thinking/reasoning → thought part
|
||||
if (msg.reasoning_content) {
|
||||
parts.push({
|
||||
thought: true,
|
||||
text: msg.reasoning_content
|
||||
});
|
||||
// parts.push({
|
||||
// thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
|
||||
// text: ""
|
||||
// });
|
||||
}
|
||||
|
||||
if (content) {
|
||||
const text = typeof content === "string" ? content : extractTextContent(content);
|
||||
if (text) {
|
||||
@@ -127,15 +128,22 @@ function openaiToGeminiBase(model, body, stream) {
|
||||
if (tc.type !== "function") continue;
|
||||
|
||||
const args = tryParseJSON(tc.function?.arguments || "{}");
|
||||
parts.push({
|
||||
// thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
|
||||
// Extract thoughtSignature embedded in ID as "rawId_TSIG_base64urlsig"
|
||||
const sepIdx = tc.id.indexOf("_TSIG_");
|
||||
const rawId = sepIdx !== -1 ? tc.id.slice(0, sepIdx) : tc.id;
|
||||
const encodedSig = sepIdx !== -1 ? tc.id.slice(sepIdx + 6) : "";
|
||||
const fcPart = {
|
||||
functionCall: {
|
||||
id: tc.id,
|
||||
id: rawId,
|
||||
name: sanitizeGeminiFunctionName(tc.function.name),
|
||||
args: args
|
||||
}
|
||||
});
|
||||
toolCallIds.push(tc.id);
|
||||
};
|
||||
if (encodedSig) {
|
||||
fcPart.thoughtSignature = fromBase64Url(encodedSig);
|
||||
}
|
||||
parts.push(fcPart);
|
||||
toolCallIds.push(rawId);
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user