Implement Gemini thought signatures (#4886) * Implement Gemini thought signatures * Implement streaming support for Gemini thought signatures * Implement OR support for Gemini thought signatures * Remove unnecessary extraction of thought sigs from response parts * Update thought sig comments to remove explicit Gemini mention * Fix thought_signature naming convention in message.extra * Add thought_signatures to ReasoningMessageExtra typedef * Prevent thought sigs being sent to incompatible endpoints * Move signatures to populateChatHistory, update for consistent casing * Code clean-up * Only send thought signatures if target model and API match original * Implement content-hash thought signature mapping * Change the data model + split for text/functions * Don't include signature to invocations if the model doesn't match * Fix function description * Remove misleading comment * Handle OpenRouter signatures * Improve message extra types * Prevent modifying original invocations when removing signatures * Fix return of openrouter non-streaming signatures * Remove redundant array check --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

2cd2bd4a4de3af6b6df4b0e5e0981a6e1d66c54c

mightytribble <246552901+mightytribble@users.noreply.github.com>

Signed
7 files changed, +258 -28Showing whitespace changes
public/global.d.ts+8 -2
@@ -6,7 +6,8 @@ import { oai_settings } from './scripts/openai';
66import { textgenerationwebui_settings } from './scripts/textgen-settings';
77import { FileAttachment } from './scripts/chats';
88import { ReasoningMessageExtra } from './scripts/reasoning';
99import { IGNORE_SYMBOL, OVERSWIPE_BEHAVIOR } from './scripts/constants';
10+import { ToolInvocation } from './scripts/tool-calling';
1011
1112declare global {
1213 // Custom types
@@ -83,13 +84,16 @@ declare global {
8384 }
8485
8586 interface BaseMessageExtra {
87+ api?: string;
88+ model?: string;
89+ type?: string;
8690 gen_id?: number;
8791 bias?: string;
8892 uses_system_ui?: boolean;
8993 memory?: string;
9094 display_text?: string;
9195 reasoning_display_text?: string;
9296 tool_invocations?: anyToolInvocation[];
9397 title?: string;
9498 isSmallSys?: boolean;
9599 token_count?: number;
@@ -115,6 +119,8 @@ declare global {
115119 generationType?: number;
116120 /** @deprecated Use `MediaAttachment.negative` instead */
117121 negative?: string;
122+ /** Will exclude this message from prompt processing */
123+ [IGNORE_SYMBOL]?: boolean;
118124 }
119125
120126 type MediaAttachment = MediaAttachmentProps & ImageGenerationAttachmentProps & ImageCaptionAttachmentProps;
public/script.js+20 -5
@@ -267,7 +267,7 @@ import { initServerHistory } from './scripts/server-history.js';
267267import { initSettingsSearch } from './scripts/setting-search.js';
268268import { initBulkEdit } from './scripts/bulk-edit.js';
269269import { getContext } from './scripts/st-context.js';
270270import { extractReasoningFromData, extractReasoningSignatureFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js';
271271import { accountStorage } from './scripts/util/AccountStorage.js';
272272import { initWelcomeScreen, openPermanentAssistantChat, openPermanentAssistantCard, getPermanentAssistantAvatar } from './scripts/welcome-screen.js';
273273import { initDataMaid } from './scripts/data-maid.js';
@@ -3388,6 +3388,8 @@ class StreamingProcessor {
33883388 this.promptReasoning = promptReasoning;
33893389 /** @type {string[]} */
33903390 this.images = [];
3391+ /** @type {string?} */
3392+ this.reasoningSignature = null;
33913393 }
33923394
33933395 /**
@@ -3581,6 +3583,12 @@ class StreamingProcessor {
35813583 appendMediaToMessage(message, $(this.messageDom));
35823584 }
35833585
3586+ // Store reasoning signature for models that support multi-turn context
3587+ if (this.reasoningSignature) {
3588+ message.extra = message.extra || {};
3589+ message.extra.reasoning_signature = this.reasoningSignature;
3590+ }
3591+
35843592 this.markUIGenStopped();
35853593
35863594 if (this.type !== 'impersonate') {
@@ -3675,6 +3683,7 @@ class StreamingProcessor {
36753683 // Get the updated reasoning string into the handler
36763684 this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning);
36773685 this.images = state?.images ?? [];
3686+ this.reasoningSignature = state?.signature ?? null;
36783687 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);
36793688 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));
36803689 }
@@ -5237,6 +5246,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
52375246 let title = extractTitleFromData(data);
52385247 let reasoning = extractReasoningFromData(data);
52395248 let imageUrls = extractImagesFromData(data);
5249+ const reasoningSignature = extractReasoningSignatureFromData(data);
52405250 kobold_horde_model = title;
52415251
52425252 const swipes = extractMultiSwipes(data, type);
@@ -5280,10 +5290,10 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
52805290 else {
52815291 // Without streaming we'll be having a full message on continuation. Treat it as a last chunk.
52825292 if (originalType !== 'continue') {
52835293 ({ type, getMessage } = await saveReply({ type, getMessage, title, swipes, reasoning, imageUrls, reasoningSignature }));
52845294 }
52855295 else {
52865296 ({ type, getMessage } = await saveReply({ type: 'appendFinal', getMessage, title, swipes, reasoning, imageUrls, reasoningSignature }));
52875297 }
52885298
52895299 // This relies on `saveReply` having been called to add the message to the chat, so it must be last.
@@ -6332,16 +6342,17 @@ async function processImageAttachment(message, { imageUrls }) {
63326342 * @property {string[]} [swipes] Extra swipes
63336343 * @property {string} [reasoning] Message reasoning
63346344 * @property {string[]} [imageUrls] Links to images
6345+ * @property {string?} [reasoningSignature] Encrypted signature of the reasoning text
63356346 *
63366347 * @typedef {object} SaveReplyResult
63376348 * @property {string} type Type of generation
63386349 * @property {string} getMessage Generated message
63396350 */
63406351export async function saveReply({ type, getMessage, fromStreaming = false, title = '', swipes = [], reasoning = '', imageUrls = [], reasoningSignature = null }) {
63416352 // Backward compatibility
63426353 if (arguments.length > 1 && typeof arguments[0] !== 'object') {
63436354 console.trace('saveReply called with positional arguments. Please use an object instead.');
63446355 [type, getMessage, fromStreaming, title, swipes, reasoning, imageUrls, reasoningSignature] = arguments;
63456356 }
63466357
63476358 if (type != 'append' && type != 'continue' && type != 'appendFinal' && chat.length && (chat[chat.length - 1]['swipe_id'] === undefined ||
@@ -6377,6 +6388,7 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
63776388 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
63786389 chat[chat.length - 1]['extra']['reasoning'] = reasoning;
63796390 chat[chat.length - 1]['extra']['reasoning_duration'] = null;
6391+ chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
63806392 await processImageAttachment(chat[chat.length - 1], { imageUrls });
63816393 if (power_user.message_token_count_enabled) {
63826394 const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
@@ -6401,6 +6413,7 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
64016413 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
64026414 chat[chat.length - 1]['extra']['reasoning'] = reasoning;
64036415 chat[chat.length - 1]['extra']['reasoning_duration'] = null;
6416+ chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
64046417 await processImageAttachment(chat[chat.length - 1], { imageUrls });
64056418 if (power_user.message_token_count_enabled) {
64066419 const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
@@ -6421,6 +6434,7 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
64216434 chat[chat.length - 1]['extra']['api'] = getGeneratingApi();
64226435 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
64236436 chat[chat.length - 1]['extra']['reasoning'] += reasoning;
6437+ chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
64246438 await processImageAttachment(chat[chat.length - 1], { imageUrls });
64256439 // We don't know if the reasoning duration extended, so we don't update it here on purpose.
64266440 if (power_user.message_token_count_enabled) {
@@ -6443,6 +6457,7 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
64436457 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
64446458 chat[chat.length - 1]['extra']['reasoning'] = reasoning;
64456459 chat[chat.length - 1]['extra']['reasoning_duration'] = null;
6460+ chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
64466461 if (power_user.trim_spaces) {
64476462 getMessage = getMessage.trim();
64486463 }
public/scripts/openai.js+71 -7
@@ -517,13 +517,17 @@ async function validateReverseProxy() {
517517
518518/**
519519 * Formats chat messages into chat completion messages.
520520 * @param {objectChatMessage[]} chat - Array containing all messages.
521521 * @returns {object[]} - Array containing all messages formatted for chat completion.
522522 */
523523function setOpenAIMessages(chat) {
524524 let j = 0;
525525 // clean openai msgs
526526 const messages = [];
527+ // Get current API and model for thought signature validation
528+ const currentApi = oai_settings.chat_completion_source;
529+ const currentModel = getChatCompletionModel();
530+
527531 for (let i = chat.length - 1; i >= 0; i--) {
528532 let role = chat[j]['is_user'] ? 'user' : 'assistant';
529533 let content = chat[j]['mes'];
@@ -567,8 +571,26 @@ function setOpenAIMessages(chat) {
567571 const media = chat[j]?.extra?.media;
568572 const mediaDisplay = getMediaDisplay(chat[j]);
569573 const mediaIndex = getMediaIndex(chat[j]);
570574 const invocations = chat[j]?.extra?.tool_invocations?.slice();
571- messages[i] = { 'role': role, 'content': content, name: name, 'media': media, 'mediaDisplay': mediaDisplay, 'mediaIndex': mediaIndex, 'invocations': invocations };
575+
576+ // Only send thought signatures if they were generated by the same API and model
577+ const originApi = chat[j]?.extra?.api;
578+ const originModel = chat[j]?.extra?.model;
579+ const isSameModel = originApi === currentApi && originModel === currentModel;
580+ const signature = isSameModel ? chat[j]?.extra?.reasoning_signature : null;
581+
582+ // Remove signatures from invocations if the API/model don't match
583+ if (Array.isArray(invocations) && invocations.length > 0) {
584+ invocations.forEach((invocation, index) => {
585+ if (invocation.signature && !isSameModel) {
586+ const cloneInvocation = structuredClone(invocation);
587+ delete cloneInvocation.signature;
588+ invocations[index] = cloneInvocation;
589+ }
590+ });
591+ }
592+
593+ messages[i] = { 'role': role, 'content': content, name: name, 'media': media, 'mediaDisplay': mediaDisplay, 'mediaIndex': mediaIndex, 'invocations': invocations, 'signature': signature };
572594 j++;
573595 }
574596
@@ -863,6 +885,7 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
863885 const videoInlining = isVideoInliningSupported();
864886 const audioInlining = isAudioInliningSupported();
865887 const canUseTools = ToolManager.isToolCallingSupported();
888+ const includeSignature = isReasoningSignatureSupported();
866889
867890 // Insert chat messages as long as there is budget available
868891 const chatPool = [...messages].reverse();
@@ -918,7 +941,7 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
918941 const invocations = chatPrompt.invocations;
919942 const toolCallMessage = await Message.createAsync(chatMessage.role, undefined, 'toolCall-' + chatMessage.identifier);
920943 const toolResultMessages = await Promise.all(invocations.slice().reverse().map((invocation) => Message.createAsync('tool', invocation.result || '[No content]', invocation.id)));
921944 await toolCallMessage.setToolCalls(invocations, includeSignature);
922945 if (chatCompletion.canAffordAll([toolCallMessage, ...toolResultMessages])) {
923946 for (const resultMessage of toolResultMessages) {
924947 chatCompletion.insertAtStart(resultMessage, 'chatHistory');
@@ -931,6 +954,10 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
931954 continue;
932955 }
933956
957+ if (includeSignature && chatPrompt.signature) {
958+ chatMessage.signature = chatPrompt.signature;
959+ }
960+
934961 if (chatCompletion.canAfford(chatMessage)) {
935962 chatCompletion.insertAtStart(chatMessage, 'chatHistory');
936963 } else {
@@ -2778,7 +2805,7 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
27782805 let text = '';
27792806 const swipes = [];
27802807 const toolCalls = [];
27812808 const state = { reasoning: '', images: [], signature: '', toolSignatures: {} };
27822809 while (true) {
27832810 const { done, value } = await reader.read();
27842811 if (done) return;
@@ -2795,7 +2822,7 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
27952822 text += getStreamingReply(parsed, state);
27962823 }
27972824
27982825 ToolManager.parseToolCalls(toolCalls, parsed, state.toolSignatures);
27992826
28002827 yield { text, swipes: swipes, logprobs: parseChatCompletionLogprobs(parsed), toolCalls: toolCalls, state: state };
28012828 }
@@ -2850,6 +2877,13 @@ export function getStreamingReply(data, state, { chatCompletionSource = null, ov
28502877 if (show_thoughts) {
28512878 state.reasoning += (data?.candidates?.[0]?.content?.parts?.filter(x => x.thought)?.map(x => x.text)?.[0] || '');
28522879 }
2880+ // Extract thought signatures from streaming chunks (typically in final chunk)
2881+ const parts = data?.candidates?.[0]?.content?.parts || [];
2882+ parts.forEach((part) => {
2883+ if (part.thoughtSignature && typeof part.text === 'string') {
2884+ state.signature = part.thoughtSignature;
2885+ }
2886+ });
28532887 return data?.candidates?.[0]?.content?.parts?.filter(x => !x.thought)?.map(x => x.text)?.[0] || '';
28542888 } else if (chat_completion_source === chat_completion_sources.COHERE) {
28552889 return data?.delta?.message?.content?.text || data?.delta?.message?.tool_plan || '';
@@ -2871,6 +2905,17 @@ export function getStreamingReply(data, state, { chatCompletionSource = null, ov
28712905 if (show_thoughts) {
28722906 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
28732907 }
2908+ // Extract thought signatures from OpenRouter streaming (reasoning_details in delta)
2909+ const reasoningDetails = data?.choices?.[0]?.delta?.reasoning_details || [];
2910+ reasoningDetails.forEach((detail) => {
2911+ if (detail.type === 'reasoning.encrypted' && detail.data) {
2912+ if (/^tool_/.test(detail.id)) {
2913+ state.toolSignatures[detail.id] = detail.data;
2914+ } else {
2915+ state.signature = detail.data;
2916+ }
2917+ }
2918+ });
28742919 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
28752920 } else if ([chat_completion_sources.CUSTOM, chat_completion_sources.POLLINATIONS, chat_completion_sources.AIMLAPI, chat_completion_sources.MOONSHOT, chat_completion_sources.COMETAPI, chat_completion_sources.ELECTRONHUB, chat_completion_sources.NANOGPT, chat_completion_sources.ZAI, chat_completion_sources.SILICONFLOW, chat_completion_sources.CHUTES].includes(chat_completion_source)) {
28762921 if (show_thoughts) {
@@ -3109,6 +3154,8 @@ class Message {
31093154 name;
31103155 /** @type {object} */
31113156 tool_call = null;
3157+ /** @type {string?} */
3158+ signature = null;
31123159
31133160 /**
31143161 * @constructor
@@ -3150,9 +3197,10 @@ class Message {
31503197 /**
31513198 * Reconstruct the message from a tool invocation.
31523199 * @param {import('./tool-calling.js').ToolInvocation[]} invocations - The tool invocations to reconstruct the message from.
3200+ * @param {boolean} includeSignature Whether to include the signature in the tool calls.
31533201 * @returns {Promise<void>}
31543202 */
31553203 async setToolCalls(invocations, includeSignature) {
31563204 this.tool_calls = invocations.map(i => ({
31573205 id: i.id,
31583206 type: 'function',
@@ -3160,6 +3208,7 @@ class Message {
31603208 arguments: i.parameters,
31613209 name: i.name,
31623210 },
3211+ ...(includeSignature && i.signature ? { signature: i.signature } : {}),
31633212 }));
31643213 this.tokens = await tokenHandler.countAsync({ role: this.role, tool_calls: JSON.stringify(this.tool_calls) });
31653214 }
@@ -3411,6 +3460,7 @@ class MessageCollection {
34113460 ...(message.name && { name: message.name }),
34123461 ...(message.tool_calls && { tool_calls: message.tool_calls }),
34133462 ...(message.role === 'tool' && { tool_call_id: message.identifier }),
3463+ ...(message.signature && { signature: message.signature }),
34143464 });
34153465 }
34163466 return acc;
@@ -3702,6 +3752,7 @@ export class ChatCompletion {
37023752 ...(item.name ? { name: item.name } : {}),
37033753 ...(item.tool_calls ? { tool_calls: item.tool_calls } : {}),
37043754 ...(item.role === 'tool' ? { tool_call_id: item.identifier } : {}),
3755+ ...(item.signature ? { signature: item.signature } : {}),
37053756 };
37063757 chat.push(message);
37073758 } else {
@@ -5873,6 +5924,19 @@ export function isAudioInliningSupported() {
58735924}
58745925
58755926/**
5927+ * Check if the model supports encrypted reasoning signatures.
5928+ * @param {ChatCompletionSettings} settings Settings object to use
5929+ * @returns {boolean} True if reasoning signatures should be included in the request
5930+ */
5931+export function isReasoningSignatureSupported(settings = oai_settings) {
5932+ // If it's Vertex AI or Makersuite, that's OK - convertGooglePrompt() will handle it later
5933+ const isGoogle = [chat_completion_sources.VERTEXAI, chat_completion_sources.MAKERSUITE].includes(settings.chat_completion_source);
5934+ // Need a more crunchy check for OpenRouter: look for Gemini models
5935+ const isOpenRouterGemini = settings.chat_completion_source === chat_completion_sources.OPENROUTER && /google\/gemini/i.test(settings.openrouter_model);
5936+ return isGoogle || isOpenRouterGemini;
5937+}
5938+
5939+/**
58765940 * Proxy stuff
58775941 */
58785942export function loadProxyPresets(settings) {
public/scripts/reasoning.js+48 -0
@@ -145,6 +145,53 @@ export function extractReasoningFromData(data, {
145145}
146146
147147/**
148+ * Extracts encrypted reasoning signature from the response data.
149+ * These signatures are used to maintain reasoning context across multi-turn conversations.
150+ * @param {object} data Response data
151+ * @param {object} [options] Optional parameters
152+ * @param {string|null} [options.mainApi] Override for main API
153+ * @param {string|null} [options.chatCompletionSource] Override for chat completion source
154+ * @returns {string?} Encrypted signature of the reasoning text
155+ */
156+export function extractReasoningSignatureFromData(data, {
157+ mainApi = null,
158+ chatCompletionSource = null,
159+} = {}) {
160+ // Only Gemini models use thought signatures (via MakerSuite/VertexAI or OpenRouter)
161+ if ((mainApi ?? main_api) !== 'openai') {
162+ return null;
163+ }
164+
165+ const source = chatCompletionSource ?? oai_settings.chat_completion_source;
166+ const isGemini = source === chat_completion_sources.MAKERSUITE || source === chat_completion_sources.VERTEXAI;
167+ const isOpenRouter = source === chat_completion_sources.OPENROUTER;
168+
169+ if (!isGemini && !isOpenRouter) {
170+ return null;
171+ }
172+
173+ // OpenRouter format: reasoning_details array with type "reasoning.encrypted" (exclude tool calls)
174+ if (isOpenRouter && Array.isArray(data?.choices?.[0]?.message?.reasoning_details)) {
175+ for (const detail of data.choices[0].message.reasoning_details) {
176+ if (!/^tool_/.test(detail.id) && detail.type === 'reasoning.encrypted' && detail.data) {
177+ return detail.data;
178+ }
179+ }
180+ }
181+
182+ // Direct Gemini format: Extract from responseContent.parts if available (only text parts)
183+ if (isGemini && Array.isArray(data?.responseContent?.parts)) {
184+ data.responseContent.parts.forEach((part) => {
185+ if (part.thoughtSignature && typeof part.text === 'string') {
186+ return part.thoughtSignature;
187+ }
188+ });
189+ }
190+
191+ return null;
192+}
193+
194+/**
148195 * Check if the model supports reasoning, but does not send back the reasoning
149196 * @returns {boolean} True if the model supports reasoning
150197 */
@@ -1296,6 +1343,7 @@ export function parseReasoningFromString(str, { strict = true } = {}, template =
12961343 * @property {string} reasoning Reasoning block
12971344 * @property {number} reasoning_duration Duration of the reasoning block
12981345 * @property {string} reasoning_type Type of reasoning block
1346+ * @property {string?} reasoning_signature Encrypted signature of the reasoning text
12991347 */
13001348export function parseReasoningInSwipes(swipes, swipeInfoArray, duration) {
13011349 if (!power_user.reasoning.auto_parse) {
public/scripts/tool-calling.js+29 -6
@@ -1,6 +1,6 @@
11import { DOMPurify } from '../lib.js';
22
33import { addOneMessage, chat, event_types, eventSource, getGeneratingApi, getGeneratingModel, main_api, saveChatConditional, system_avatar, systemUserName } from '../script.js';
44import { chat_completion_sources, custom_prompt_post_processing_types, getChatCompletionModel, model_list, oai_settings } from './openai.js';
55import { Popup } from './popup.js';
66import { SlashCommand } from './slash-commands/SlashCommand.js';
@@ -19,6 +19,7 @@ import { isTrueBoolean } from './utils.js';
1919 * @property {string} name - The name of the tool.
2020 * @property {string} parameters - The parameters for the tool invocation.
2121 * @property {string} result - The result of the tool invocation.
22+ * @property {string?} signature - The thought signature associated with the tool invocation.
2223 */
2324
2425/**
@@ -418,9 +419,10 @@ export class ToolManager {
418419 * Utility function to parse tool calls from a parsed response.
419420 * @param {any[]} toolCalls The tool calls to update.
420421 * @param {any} parsed The parsed response from the OpenAI API.
422+ * @param {object} toolSignatures Optional mapping of tool call IDs to thought signatures.
421423 * @returns {void}
422424 */
423425 static parseToolCalls(toolCalls, parsed, toolSignatures = {}) {
424426 if (!this.isToolCallingSupported()) {
425427 return;
426428 }
@@ -457,6 +459,11 @@ export class ToolManager {
457459 const targetToolCall = toolCalls[choiceIndex][toolCallIndex];
458460
459461 ToolManager.#applyToolCallDelta(targetToolCall, toolCallDelta);
462+
463+ // Transfer thought signature if available
464+ if (Object.hasOwn(toolSignatures, targetToolCall.id)) {
465+ targetToolCall.signature = toolSignatures[targetToolCall.id];
466+ }
460467 }
461468 }
462469 }
@@ -537,6 +544,9 @@ export class ToolManager {
537544 toolCalls[choiceIndex][toolCallIndex] = {};
538545 }
539546 const targetToolCall = toolCalls[choiceIndex][toolCallIndex];
547+ if (part.thoughtSignature) {
548+ targetToolCall.thoughtSignature = part.thoughtSignature;
549+ }
540550 ToolManager.#applyToolCallDelta(targetToolCall, part.functionCall);
541551 }
542552 }
@@ -680,7 +690,7 @@ export class ToolManager {
680690 const isClaudeToolCall = c => Array.isArray(c) ? c.filter(x => x).every(isClaudeToolCall) : c?.input && c?.name && c?.id;
681691 const isGoogleToolCall = c => Array.isArray(c) ? c.filter(x => x).every(isGoogleToolCall) : c?.name && c?.args;
682692 const convertClaudeToolCall = c => ({ id: c.id, function: { name: c.name, arguments: c.input } });
683693 const convertGoogleToolCall = (c, signature = null) => ({ id: getRandomId(), function: { name: c.name, arguments: c.args }, signature });
684694
685695 // Parsed tool calls from streaming data
686696 if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) {
@@ -689,7 +699,7 @@ export class ToolManager {
689699 }
690700
691701 if (isGoogleToolCall(data[0])) {
692702 return data[0].filter(x => x).map((c) => convertGoogleToolCall(c, c.thoughtSignature));
693703 }
694704
695705 if (typeof data[0]?.[0]?.tool_calls === 'object') {
@@ -701,7 +711,7 @@ export class ToolManager {
701711
702712 // Google AI Studio tool calls
703713 if (Array.isArray(data?.responseContent?.parts)) {
704714 return data.responseContent.parts.filter(p => p.functionCall).map(p => convertGoogleToolCall(p.functionCall, p.thoughtSignature));
705715 }
706716
707717 // Parsed tool calls from non-streaming data
@@ -709,7 +719,17 @@ export class ToolManager {
709719 // Find a choice with 0-index
710720 const choice = data.choices.find(choice => choice.index === 0);
711721
712- if (choice) {
722+ if (choice && typeof choice.message === 'object' && Array.isArray(choice.message.tool_calls)) {
723+ // Add OpenRouter signatures
724+ if (Array.isArray(choice.message.reasoning_details)) {
725+ for (const toolCall of choice.message.tool_calls) {
726+ const reasoningDetail = choice.message.reasoning_details.find(rd => rd.id === toolCall.id);
727+ if (reasoningDetail && reasoningDetail.type === 'reasoning.encrypted' && reasoningDetail.data) {
728+ toolCall.signature = reasoningDetail.data;
729+ }
730+ }
731+ }
732+
713733 return choice.message.tool_calls;
714734 }
715735 }
@@ -792,6 +812,7 @@ export class ToolManager {
792812 name,
793813 parameters: stringify(parameters),
794814 result: toolResult,
815+ signature: toolCall.signature || null,
795816 };
796817 result.invocations.push(invocation);
797818 }
@@ -853,6 +874,8 @@ export class ToolManager {
853874 extra: {
854875 isSmallSys: true,
855876 tool_invocations: invocations,
877+ api: getGeneratingApi(),
878+ model: getGeneratingModel(),
856879 },
857880 };
858881 chat.push(message);
src/endpoints/backends/chat-completions.js+3 -1
@@ -46,6 +46,7 @@ import {
4646 embedOpenRouterMedia,
4747 addReasoningContentToToolCalls,
4848 cachingSystemPromptForOpenRouterClaude,
49+ addOpenRouterSignatures,
4950} from '../../prompt-converters.js';
5051
5152import { readSecret, SECRET_KEYS } from '../secrets.js';
@@ -645,7 +646,7 @@ async function sendMakerSuiteRequest(request, response) {
645646 return response.send({ error: { message } });
646647 }
647648
648649 // Wrap it back to OAI format (responseContent includes thought signatures in parts array)
649650 const reply = { choices: [{ 'message': { 'content': responseText } }], responseContent };
650651 return response.send(reply);
651652 }
@@ -2048,6 +2049,7 @@ router.post('/generate', async function (request, response) {
20482049 const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m';
20492050 if (Array.isArray(request.body.messages)) {
20502051 embedOpenRouterMedia(request.body.messages);
2052+ addOpenRouterSignatures(request.body.messages, request.body.model);
20512053
20522054 if (isClaude3or4) {
20532055 if (enableSystemPromptCache) {
src/prompt-converters.js+79 -7
@@ -548,6 +548,7 @@ export function convertGooglePrompt(messages, model, useSysPrompt, names) {
548548 name: toolCall.function.name,
549549 args: tryParse(toolCall.function.arguments) ?? toolCall.function.arguments,
550550 },
551+ ...(toolCall.signature ? { thoughtSignature: toolCall.signature } : {}),
551552 });
552553
553554 toolNameMap[toolCall.id] = toolCall.function.name;
@@ -567,17 +568,28 @@ export function convertGooglePrompt(messages, model, useSysPrompt, names) {
567568 });
568569
569570 // https://ai.google.dev/gemini-api/docs/gemini-3#migrating_from_other_models
570- if (/gemini-3/.test(model)) {
571+ // Inject stored thought signatures, or fall back to bypass magic for Gemini 3
572+ if (/gemini-3/.test(model) || /gemini-2\.5/.test(model)) {
571573 const skipSignatureMagic = 'skip_thought_signature_validator';
572- parts.filter(p => p.functionCall).forEach(p => {
574+ const textSignature = message.signature;
573- p.thoughtSignature = skipSignatureMagic;
575+
574- });
576+ parts.forEach((part) => {
577+ if (textSignature && typeof part.text === 'string') {
578+ part.thoughtSignature = textSignature;
579+ } else if (/gemini-3/.test(model)) {
580+ // Gemini 3: Fall back to bypass magic for function calls (mandatory) and images
581+ if (part.functionCall && !part.thoughtSignature) {
582+ part.thoughtSignature = skipSignatureMagic;
583+ }
575584 if (/-image/.test(model) && message.role === 'model') {
576585 parts.filter(p => if (typeof ppart.text === 'string' || ppart.inlineData).forEach(p => {
577586 p part.thoughtSignature = skipSignatureMagic;
578- });
579587 }
580588 }
589+ }
590+ // Gemini 2.5 without stored signatures: signatures are optional, no bypass needed
591+ });
592+ }
581593
582594 // merge consecutive messages with the same role
583595 if (index > 0 && message.role === contents[contents.length - 1].role) {
@@ -1291,3 +1303,63 @@ export function addReasoningContentToToolCalls(messages) {
12911303 message.reasoning_content = '';
12921304 }
12931305}
1306+
1307+/**
1308+ * Converts reasoning signatures to OpenRouter format.
1309+ * @param {object[]} messages Array of messages
1310+ * @param {string} model Model name
1311+ * @return {void}
1312+ */
1313+export function addOpenRouterSignatures(messages, model) {
1314+ const getFormatForModel = () => {
1315+ if (/google\/gemini/.test(model)) {
1316+ return 'google-gemini-v1';
1317+ }
1318+ if (/anthropic\/claude/.test(model)) {
1319+ return 'anthropic-claude-v1';
1320+ }
1321+ if (/openai\/gpt/.test(model)) {
1322+ return 'openai-responses-v1';
1323+ }
1324+ if (/x-ai\/grok/.test(model)) {
1325+ return 'xai-responses-v1';
1326+ }
1327+ return 'unknown';
1328+ };
1329+
1330+ if (!Array.isArray(messages)) {
1331+ return;
1332+ }
1333+
1334+ for (const message of messages) {
1335+ const details = [];
1336+ const addDetail = (data, id) => {
1337+ if (typeof data !== 'string' || data.length === 0) {
1338+ return;
1339+ }
1340+ const detail = {
1341+ index: details.length,
1342+ id: id || `signature-${details.length}`,
1343+ type: 'reasoning.encrypted',
1344+ data: data,
1345+ format: getFormatForModel(),
1346+ };
1347+ details.push(detail);
1348+ };
1349+ if (typeof message.signature === 'string') {
1350+ addDetail(message.signature);
1351+ delete message.signature;
1352+ }
1353+ if (Array.isArray(message.tool_calls)) {
1354+ message.tool_calls.forEach((toolCall) => {
1355+ if (typeof toolCall.signature === 'string') {
1356+ addDetail(toolCall.signature, toolCall.id);
1357+ delete toolCall.signature;
1358+ }
1359+ });
1360+ }
1361+ if (details.length > 0) {
1362+ message.reasoning_details = details;
1363+ }
1364+ }
1365+}