Blame Raw
Cohee · 51ad27fb · · 1445 lines (54.1 KB)
4 contributors
1import crypto from 'node:crypto';
2import { getConfigValue, tryParse } from './util.js';
3
4const PROMPT_PLACEHOLDER = getConfigValue('promptPlaceholder', 'Let\'s get started.');
5
6const REASONING_EFFORT = {
7 auto: 'auto',
8 low: 'low',
9 medium: 'medium',
10 high: 'high',
11 min: 'min',
12 max: 'max',
13};
14
15export const PROMPT_PROCESSING_TYPE = {
16 NONE: '',
17 /** @deprecated Use MERGE instead. */
18 CLAUDE: 'claude',
19 MERGE: 'merge',
20 MERGE_TOOLS: 'merge_tools',
21 SEMI: 'semi',
22 SEMI_TOOLS: 'semi_tools',
23 STRICT: 'strict',
24 STRICT_TOOLS: 'strict_tools',
25 SINGLE: 'single',
26};
27
28// 'auto' is intentionally unmapped
29const GEMINI_MEDIA_RESOLUTION = {
30 low: 'media_resolution_low',
31 high: 'media_resolution_high',
32};
33
34const enableThoughtSignatures = !!getConfigValue('gemini.thoughtSignatures', true, 'boolean');
35
36/**
37 * @typedef {object} PromptNames
38 * @property {string} charName Character name
39 * @property {string} userName User name
40 * @property {string[]} groupNames Group member names
41 * @property {function(string): boolean} startsWithGroupName Check if a message starts with a group name
42 */
43
44/**
45 * Extracts the character name, user name, and group member names from the request.
46 * @param {import('express').Request} request Express request object
47 * @returns {PromptNames} Prompt names
48 */
49export function getPromptNames(request) {
50 return {
51 charName: String(request.body.char_name || ''),
52 userName: String(request.body.user_name || ''),
53 groupNames: Array.isArray(request.body.group_names) ? request.body.group_names.map(String) : [],
54 startsWithGroupName: function (message) {
55 return this.groupNames.some(name => message.startsWith(`${name}: `));
56 },
57 };
58}
59
60/**
61 * Adds an assistant prefix to the last message.
62 * @param {any[]} prompt Prompt messages array
63 * @param {any[]} tools Array of tool definitions
64 * @param {string} property The property to set the prefix on
65 * @returns {any[]} Transformed messages array
66 */
67export function addAssistantPrefix(prompt, tools, property) {
68 if (!prompt.length) {
69 return prompt;
70 }
71 const hasAnyTools = (Array.isArray(tools) && tools.length > 0) || prompt.some(x => x.role === 'tool');
72 if (!hasAnyTools && prompt[prompt.length - 1].role === 'assistant') {
73 prompt[prompt.length - 1][property] = true;
74 }
75 return prompt;
76}
77
78/**
79 * Applies a post-processing step to the generated messages.
80 * @param {object[]} messages Messages to post-process
81 * @param {string} type Prompt conversion type
82 * @param {PromptNames} names Prompt names
83 * @returns
84 */
85export function postProcessPrompt(messages, type, names) {
86 switch (type) {
87 case PROMPT_PROCESSING_TYPE.MERGE:
88 case PROMPT_PROCESSING_TYPE.CLAUDE:
89 return mergeMessages(messages, names, { strict: false, placeholders: false, single: false, tools: false });
90 case PROMPT_PROCESSING_TYPE.MERGE_TOOLS:
91 return mergeMessages(messages, names, { strict: false, placeholders: false, single: false, tools: true });
92 case PROMPT_PROCESSING_TYPE.SEMI:
93 return mergeMessages(messages, names, { strict: true, placeholders: false, single: false, tools: false });
94 case PROMPT_PROCESSING_TYPE.SEMI_TOOLS:
95 return mergeMessages(messages, names, { strict: true, placeholders: false, single: false, tools: true });
96 case PROMPT_PROCESSING_TYPE.STRICT:
97 return mergeMessages(messages, names, { strict: true, placeholders: true, single: false, tools: false });
98 case PROMPT_PROCESSING_TYPE.STRICT_TOOLS:
99 return mergeMessages(messages, names, { strict: true, placeholders: true, single: false, tools: true });
100 case PROMPT_PROCESSING_TYPE.SINGLE:
101 return mergeMessages(messages, names, { strict: true, placeholders: false, single: true, tools: false });
102 default:
103 return messages;
104 }
105}
106
107/**
108 * Convert a prompt from the ChatML objects to the format used by Claude.
109 * Mainly deprecated. Only used for counting tokens.
110 * @param {object[]} messages Array of messages
111 * @param {boolean} addAssistantPostfix Add Assistant postfix.
112 * @param {string} addAssistantPrefill Add Assistant prefill after the assistant postfix.
113 * @param {boolean} withSysPromptSupport Indicates if the Claude model supports the system prompt format.
114 * @param {boolean} useSystemPrompt Indicates if the system prompt format should be used.
115 * @param {boolean} excludePrefixes Exlude Human/Assistant prefixes.
116 * @param {string} addSysHumanMsg Add Human message between system prompt and assistant.
117 * @returns {string} Prompt for Claude
118 * @copyright Prompt Conversion script taken from RisuAI by kwaroran (GPLv3).
119 */
120export function convertClaudePrompt(messages, addAssistantPostfix, addAssistantPrefill, withSysPromptSupport, useSystemPrompt, addSysHumanMsg, excludePrefixes) {
121 //Prepare messages for claude.
122 //When 'Exclude Human/Assistant prefixes' checked, setting messages role to the 'system'(last message is exception).
123 if (messages.length > 0) {
124 messages.forEach((m) => {
125 if (!m.content) {
126 m.content = '';
127 }
128 if (m.tool_calls) {
129 m.content += JSON.stringify(m.tool_calls);
130 }
131 });
132 if (excludePrefixes) {
133 messages.slice(0, -1).forEach(message => message.role = 'system');
134 } else {
135 messages[0].role = 'system';
136 }
137 //Add the assistant's message to the end of messages.
138 if (addAssistantPostfix) {
139 messages.push({
140 role: 'assistant',
141 content: addAssistantPrefill || '',
142 });
143 }
144 // Find the index of the first message with an assistant role and check for a "'user' role/Human:" before it.
145 let hasUser = false;
146 const firstAssistantIndex = messages.findIndex((message, i) => {
147 if (i >= 0 && (message.role === 'user' || message.content.includes('\n\nHuman: '))) {
148 hasUser = true;
149 }
150 return message.role === 'assistant' && i > 0;
151 });
152 // When 2.1+ and 'Use system prompt' checked, switches to the system prompt format by setting the first message's role to the 'system'.
153 // Inserts the human's message before the first the assistant one, if there are no such message or prefix found.
154 if (withSysPromptSupport && useSystemPrompt) {
155 messages[0].role = 'system';
156 if (firstAssistantIndex > 0 && addSysHumanMsg && !hasUser) {
157 messages.splice(firstAssistantIndex, 0, {
158 role: 'user',
159 content: addSysHumanMsg,
160 });
161 }
162 } else {
163 // Otherwise, use the default message format by setting the first message's role to 'user'(compatible with all claude models including 2.1.)
164 messages[0].role = 'user';
165 // Fix messages order for default message format when(messages > Context Size) by merging two messages with "\n\nHuman: " prefixes into one, before the first Assistant's message.
166 if (firstAssistantIndex > 0 && !excludePrefixes) {
167 messages[firstAssistantIndex - 1].role = firstAssistantIndex - 1 !== 0 && messages[firstAssistantIndex - 1].role === 'user' ? 'FixHumMsg' : messages[firstAssistantIndex - 1].role;
168 }
169 }
170 }
171
172 // Convert messages to the prompt.
173 let requestPrompt = messages.map((v, i) => {
174 // Set prefix according to the role. Also, when "Exclude Human/Assistant prefixes" is checked, names are added via the system prefix.
175 let prefix = {
176 'assistant': '\n\nAssistant: ',
177 'user': '\n\nHuman: ',
178 'system': i === 0 ? '' : v.name === 'example_assistant' ? '\n\nA: ' : v.name === 'example_user' ? '\n\nH: ' : excludePrefixes && v.name ? `\n\n${v.name}: ` : '\n\n',
179 'FixHumMsg': '\n\nFirst message: ',
180 }[v.role] ?? '';
181 // Claude doesn't support message names, so we'll just add them to the message content.
182 return `${prefix}${v.name && v.role !== 'system' ? `${v.name}: ` : ''}${v.content}`;
183 }).join('');
184
185 return requestPrompt;
186}
187
188/**
189 * Convert ChatML objects into working with Anthropic's new Messaging API.
190 * @param {object[]} messages Array of messages
191 * @param {string} prefillString User determined prefill string
192 * @param {boolean} useSysPrompt See if we want to use a system prompt
193 * @param {boolean} useTools See if we want to use tools
194 * @param {PromptNames} names Prompt names
195 * @returns {{messages: object[], systemPrompt: object[]}} Prompt for Anthropic
196 */
197export function convertClaudeMessages(messages, prefillString, useSysPrompt, useTools, names) {
198 let systemPrompt = [];
199 if (useSysPrompt) {
200 // Collect all the system messages up until the first instance of a non-system message, and then remove them from the messages array.
201 let i;
202 for (i = 0; i < messages.length; i++) {
203 if (messages[i].role !== 'system') {
204 break;
205 }
206 // Append example names if not already done by the frontend (e.g. for group chats).
207 if (names.userName && messages[i].name === 'example_user') {
208 if (!messages[i].content.startsWith(`${names.userName}: `)) {
209 messages[i].content = `${names.userName}: ${messages[i].content}`;
210 }
211 }
212 if (names.charName && messages[i].name === 'example_assistant') {
213 if (!messages[i].content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(messages[i].content)) {
214 messages[i].content = `${names.charName}: ${messages[i].content}`;
215 }
216 }
217 systemPrompt.push({ type: 'text', text: messages[i].content });
218 }
219
220 messages.splice(0, i);
221
222 // Check if the first message in the array is of type user, if not, interject with humanMsgFix or a blank message.
223 // Also prevents erroring out if the messages array is empty.
224 if (messages.length === 0) {
225 messages.unshift({
226 role: 'user',
227 content: PROMPT_PLACEHOLDER,
228 });
229 }
230 }
231
232 // Now replace all further messages that have the role 'system' with the role 'user'. (or all if we're not using one)
233 const parse = (str) => typeof str === 'string' ? JSON.parse(str) : str;
234 messages.forEach((message) => {
235 if (message.role === 'assistant' && message.tool_calls) {
236 message.content = message.tool_calls.map((tc) => ({
237 type: 'tool_use',
238 id: tc.id,
239 name: tc.function.name,
240 input: parse(tc.function.arguments),
241 }));
242 }
243
244 if (message.role === 'tool') {
245 message.role = 'user';
246 message.content = [{
247 type: 'tool_result',
248 tool_use_id: message.tool_call_id,
249 content: message.content,
250 }];
251 }
252
253 if (message.role === 'system') {
254 if (names.userName && message.name === 'example_user') {
255 if (!message.content.startsWith(`${names.userName}: `)) {
256 message.content = `${names.userName}: ${message.content}`;
257 }
258 }
259 if (names.charName && message.name === 'example_assistant') {
260 if (!message.content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(message.content)) {
261 message.content = `${names.charName}: ${message.content}`;
262 }
263 }
264 message.role = 'user';
265
266 // Delete name here so it doesn't get added later
267 delete message.name;
268 }
269
270 // Convert everything to an array of it would be easier to work with
271 if (typeof message.content === 'string') {
272 // Take care of name properties since claude messages don't support them
273 if (message.name) {
274 message.content = `${message.name}: ${message.content}`;
275 }
276
277 message.content = [{ type: 'text', text: message.content }];
278 } else if (Array.isArray(message.content)) {
279 message.content = message.content.map((content) => {
280 if (content.type === 'image_url') {
281 const imageEntry = content?.image_url;
282 const imageData = imageEntry?.url;
283 const mimeType = imageData?.split(';')?.[0].split(':')?.[1];
284 const base64Data = imageData?.split(',')?.[1];
285
286 return {
287 type: 'image',
288 source: {
289 type: 'base64',
290 media_type: mimeType,
291 data: base64Data,
292 },
293 };
294 }
295
296 if (content.type === 'text') {
297 if (message.name) {
298 content.text = `${message.name}: ${content.text}`;
299 }
300
301 // If the text is empty, replace it with a zero-width space
302 return { type: 'text', text: content.text || '\u200b' };
303 }
304
305 return content;
306 });
307 }
308
309 // Remove offending properties
310 delete message.name;
311 delete message.tool_calls;
312 delete message.tool_call_id;
313 });
314
315 // Images in assistant messages should be moved to the next user message
316 for (let i = 0; i < messages.length; i++) {
317 if (messages[i].role === 'assistant' && messages[i].content.some(c => c.type === 'image')) {
318 // Find the next user message
319 let j = i + 1;
320 while (j < messages.length && messages[j].role !== 'user') {
321 j++;
322 }
323
324 // Move the images
325 if (j >= messages.length) {
326 // If there is no user message after the assistant message, add a new one
327 messages.splice(i + 1, 0, { role: 'user', content: [] });
328 }
329
330 messages[j].content.push(...messages[i].content.filter(c => c.type === 'image'));
331 messages[i].content = messages[i].content.filter(c => c.type !== 'image');
332 }
333 }
334
335 // Shouldn't be conditional anymore, messages api expects the last role to be user unless we're explicitly prefilling
336 if (prefillString) {
337 messages.push({
338 role: 'assistant',
339 // Dangling whitespace are not allowed for prefilling
340 content: [{ type: 'text', text: prefillString.trimEnd() }],
341 });
342 }
343
344 // Since the messaging endpoint only supports user assistant roles in turns, we have to merge messages with the same role if they follow eachother
345 // Also handle multi-modality, holy slop.
346 let mergedMessages = [];
347 messages.forEach((message) => {
348 if (mergedMessages.length > 0 && mergedMessages[mergedMessages.length - 1].role === message.role) {
349 mergedMessages[mergedMessages.length - 1].content.push(...message.content);
350 } else {
351 mergedMessages.push(message);
352 }
353 });
354
355 if (!useTools) {
356 mergedMessages.forEach((message) => {
357 message.content.forEach((content) => {
358 if (content.type === 'tool_use') {
359 content.type = 'text';
360 content.text = JSON.stringify(content.input);
361 delete content.id;
362 delete content.name;
363 delete content.input;
364 }
365 if (content.type === 'tool_result') {
366 content.type = 'text';
367 content.text = content.content;
368 delete content.tool_use_id;
369 delete content.content;
370 }
371 });
372 });
373 }
374
375 return { messages: mergedMessages, systemPrompt: systemPrompt };
376}
377
378/**
379 * Convert a prompt from the ChatML objects to the format used by Cohere.
380 * @param {object[]} messages Array of messages
381 * @param {PromptNames} names Prompt names
382 * @returns {{chatHistory: object[]}} Prompt for Cohere
383 */
384export function convertCohereMessages(messages, names) {
385 if (messages.length === 0) {
386 messages.unshift({
387 role: 'user',
388 content: PROMPT_PLACEHOLDER,
389 });
390 }
391
392 messages.forEach((msg, index) => {
393 // Tool calls require an assistent primer
394 if (Array.isArray(msg.tool_calls)) {
395 if (index > 0 && messages[index - 1].role === 'assistant') {
396 msg.content = messages[index - 1].content;
397 messages.splice(index - 1, 1);
398 } else {
399 msg.content = `I'm going to call a tool for that: ${msg.tool_calls.map(tc => tc?.function?.name).join(', ')}`;
400 }
401 }
402 // No names support (who would've thought)
403 if (msg.name) {
404 if (msg.role == 'system' && msg.name == 'example_assistant') {
405 if (names.charName && !msg.content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(msg.content)) {
406 msg.content = `${names.charName}: ${msg.content}`;
407 }
408 }
409 if (msg.role == 'system' && msg.name == 'example_user') {
410 if (names.userName && !msg.content.startsWith(`${names.userName}: `)) {
411 msg.content = `${names.userName}: ${msg.content}`;
412 }
413 }
414 if (msg.role !== 'system' && !msg.content.startsWith(`${msg.name}: `)) {
415 msg.content = `${msg.name}: ${msg.content}`;
416 }
417 delete msg.name;
418 }
419 });
420
421 return { chatHistory: messages };
422}
423
424/**
425 * Convert a prompt from the ChatML objects to the format used by Google MakerSuite models.
426 * @param {object[]} messages Array of messages
427 * @param {string} model Model name
428 * @param {boolean} useSysPrompt Use system prompt
429 * @param {PromptNames} names Prompt names
430 * @returns {{contents: *[], system_instruction: {parts: {text: string}[]}}} Prompt for Google MakerSuite models
431 */
432export function convertGooglePrompt(messages, model, useSysPrompt, names) {
433 const sysPrompt = [];
434
435 if (useSysPrompt) {
436 while (messages.length > 1 && messages[0].role === 'system') {
437 // Append example names if not already done by the frontend (e.g. for group chats).
438 if (names.userName && messages[0].name === 'example_user') {
439 if (!messages[0].content.startsWith(`${names.userName}: `)) {
440 messages[0].content = `${names.userName}: ${messages[0].content}`;
441 }
442 }
443 if (names.charName && messages[0].name === 'example_assistant') {
444 if (!messages[0].content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(messages[0].content)) {
445 messages[0].content = `${names.charName}: ${messages[0].content}`;
446 }
447 }
448 sysPrompt.push(messages[0].content);
449 messages.shift();
450 }
451 }
452
453 const system_instruction = { parts: sysPrompt.map(text => ({ text })) };
454 const toolNameMap = {};
455
456 const contents = [];
457 messages.forEach((message, index) => {
458 // fix the roles
459 if (message.role === 'system' || message.role === 'tool') {
460 message.role = 'user';
461 } else if (message.role === 'assistant') {
462 message.role = 'model';
463 }
464
465 // Convert the content to an array of parts
466 if (!Array.isArray(message.content)) {
467 const content = (() => {
468 const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
469 const hasToolCallId = typeof message.tool_call_id === 'string' && message.tool_call_id.length > 0;
470
471 if (hasToolCalls) {
472 return { type: 'tool_calls', tool_calls: message.tool_calls };
473 }
474
475 if (hasToolCallId) {
476 return { type: 'tool_call_id', tool_call_id: message.tool_call_id, content: String(message.content ?? '') };
477 }
478
479 return { type: 'text', text: String(message.content ?? '') };
480 })();
481 message.content = [content];
482 }
483
484 // similar story as claude
485 if (message.name) {
486 message.content.forEach((part) => {
487 if (part.type !== 'text') {
488 return;
489 }
490 if (message.name === 'example_user') {
491 if (names.userName && !part.text.startsWith(`${names.userName}: `)) {
492 part.text = `${names.userName}: ${part.text}`;
493 }
494 } else if (message.name === 'example_assistant') {
495 if (names.charName && !part.text.startsWith(`${names.charName}: `) && !names.startsWithGroupName(part.text)) {
496 part.text = `${names.charName}: ${part.text}`;
497 }
498 } else {
499 if (!part.text.startsWith(`${message.name}: `)) {
500 part.text = `${message.name}: ${part.text}`;
501 }
502 }
503 });
504
505 delete message.name;
506 }
507
508 //create the prompt parts
509 const parts = [];
510 message.content.forEach((part) => {
511 const addDataUrlPart = (/** @type {string} */ url, /** @type {string} */ defaultMimeType, /** @type {string?} */ detail = null) => {
512 if (url && url.startsWith('data:')) {
513 const [header, base64Data] = url.split(',');
514 const mimeType = header.match(/data:([^;]+)/)?.[1] || defaultMimeType;
515 const mediaResolution = GEMINI_MEDIA_RESOLUTION[detail] || null;
516
517 const part = {
518 inlineData: {
519 mimeType: mimeType,
520 data: base64Data,
521 },
522 };
523
524 // https://ai.google.dev/gemini-api/docs/gemini-3#media_resolution
525 if (/gemini-3/.test(model) && mediaResolution) {
526 part.mediaResolution = {
527 level: mediaResolution,
528 };
529 }
530
531 parts.push(part);
532 }
533 };
534
535 if (part.type === 'text') {
536 parts.push({ text: part.text });
537 } else if (part.type === 'tool_call_id') {
538 const name = toolNameMap[part.tool_call_id] ?? 'unknown';
539 parts.push({
540 functionResponse: {
541 name: name,
542 response: { name: name, content: part.content },
543 },
544 });
545 } else if (part.type === 'tool_calls') {
546 part.tool_calls.forEach((toolCall) => {
547 parts.push({
548 functionCall: {
549 name: toolCall.function.name,
550 args: tryParse(toolCall.function.arguments) ?? toolCall.function.arguments,
551 },
552 ...(toolCall.signature ? { thoughtSignature: toolCall.signature } : {}),
553 });
554
555 toolNameMap[toolCall.id] = toolCall.function.name;
556 });
557 } else if (part.type === 'image_url') {
558 const imageUrl = part.image_url?.url;
559 const detail = part.image_url?.detail;
560 addDataUrlPart(imageUrl, 'image/png', detail);
561 } else if (part.type === 'video_url') {
562 const videoUrl = part.video_url?.url;
563 const detail = part.video_url?.detail;
564 addDataUrlPart(videoUrl, 'video/mp4', detail);
565 } else if (part.type === 'audio_url') {
566 const audioUrl = part.audio_url?.url;
567 addDataUrlPart(audioUrl, 'audio/mpeg');
568 }
569 });
570
571 // https://ai.google.dev/gemini-api/docs/gemini-3#migrating_from_other_models
572 // Inject stored thought signatures, or fall back to bypass magic for Gemini 3
573 if (/gemini-3/.test(model) || /gemini-2\.5/.test(model)) {
574 const skipSignatureMagic = 'skip_thought_signature_validator';
575 const textSignature = message.signature;
576
577 parts.forEach((part) => {
578 if (enableThoughtSignatures && textSignature && typeof part.text === 'string') {
579 part.thoughtSignature = textSignature;
580 } else if (/gemini-3/.test(model)) {
581 // Gemini 3: Fall back to bypass magic for function calls (mandatory) and images
582 if (part.functionCall && !part.thoughtSignature) {
583 part.thoughtSignature = skipSignatureMagic;
584 }
585 if (/-image/.test(model) && message.role === 'model') {
586 if (typeof part.text === 'string' || part.inlineData) {
587 part.thoughtSignature = skipSignatureMagic;
588 }
589 }
590 }
591 // Gemini 2.5 without stored signatures: signatures are optional, no bypass needed
592 });
593 }
594
595 // merge consecutive messages with the same role
596 if (index > 0 && message.role === contents[contents.length - 1].role) {
597 parts.forEach((part) => {
598 if (part.text) {
599 const textPart = contents[contents.length - 1].parts.find(p => typeof p.text === 'string');
600 if (textPart) {
601 textPart.text += '\n\n' + part.text;
602 } else {
603 contents[contents.length - 1].parts.push(part);
604 }
605 }
606 if (part.inlineData || part.functionCall || part.functionResponse || part.thoughtSignature || part.mediaResolution) {
607 contents[contents.length - 1].parts.push(part);
608 }
609 });
610 } else {
611 contents.push({
612 role: message.role,
613 parts: parts,
614 });
615 }
616 });
617
618 return { contents: contents, system_instruction: system_instruction };
619}
620
621/**
622 * Convert AI21 prompt. Classic: system message squash, user/assistant message merge.
623 * @param {object[]} messages Array of messages
624 * @param {PromptNames} names Prompt names
625 * @returns {object[]} Prompt for AI21
626 */
627export function convertAI21Messages(messages, names) {
628 if (!Array.isArray(messages)) {
629 return [];
630 }
631
632 // Collect all the system messages up until the first instance of a non-system message, and then remove them from the messages array.
633 let i = 0, systemPrompt = '';
634
635 for (i = 0; i < messages.length; i++) {
636 if (messages[i].role !== 'system') {
637 break;
638 }
639 // Append example names if not already done by the frontend (e.g. for group chats).
640 if (names.userName && messages[i].name === 'example_user') {
641 if (!messages[i].content.startsWith(`${names.userName}: `)) {
642 messages[i].content = `${names.userName}: ${messages[i].content}`;
643 }
644 }
645 if (names.charName && messages[i].name === 'example_assistant') {
646 if (!messages[i].content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(messages[i].content)) {
647 messages[i].content = `${names.charName}: ${messages[i].content}`;
648 }
649 }
650 systemPrompt += `${messages[i].content}\n\n`;
651 }
652
653 messages.splice(0, i);
654
655 // Prevent erroring out if the messages array is empty.
656 if (messages.length === 0) {
657 messages.unshift({
658 role: 'user',
659 content: PROMPT_PLACEHOLDER,
660 });
661 }
662
663 if (systemPrompt) {
664 messages.unshift({
665 role: 'system',
666 content: systemPrompt.trim(),
667 });
668 }
669
670 // Doesn't support completion names, so prepend if not already done by the frontend (e.g. for group chats).
671 messages.forEach(msg => {
672 if ('name' in msg) {
673 if (msg.role !== 'system' && !msg.content.startsWith(`${msg.name}: `)) {
674 msg.content = `${msg.name}: ${msg.content}`;
675 }
676 delete msg.name;
677 }
678 });
679
680 // Since the messaging endpoint only supports alternating turns, we have to merge messages with the same role if they follow each other
681 let mergedMessages = [];
682 messages.forEach((message) => {
683 if (mergedMessages.length > 0 && mergedMessages[mergedMessages.length - 1].role === message.role) {
684 mergedMessages[mergedMessages.length - 1].content += '\n\n' + message.content;
685 } else {
686 mergedMessages.push(message);
687 }
688 });
689
690 return mergedMessages;
691}
692
693/**
694 * Convert a prompt from the ChatML objects to the format used by MistralAI.
695 * @param {object[]} messages Array of messages
696 * @param {PromptNames} names Prompt names
697 * @returns {object[]} Prompt for MistralAI
698 */
699export function convertMistralMessages(messages, names) {
700 if (!Array.isArray(messages)) {
701 return [];
702 }
703
704 // Make the last assistant message a prefill
705 const prefixEnabled = getConfigValue('mistral.enablePrefix', false, 'boolean');
706 const lastMsg = messages[messages.length - 1];
707 if (prefixEnabled && messages.length > 0 && lastMsg?.role === 'assistant') {
708 lastMsg.prefix = true;
709 }
710
711 const sanitizeToolId = (id) => crypto.createHash('sha512').update(id).digest('hex').slice(0, 9);
712
713 // Doesn't support completion names, so prepend if not already done by the frontend (e.g. for group chats).
714 messages.forEach(msg => {
715 if ('tool_calls' in msg && Array.isArray(msg.tool_calls)) {
716 msg.tool_calls.forEach(tool => {
717 tool.id = sanitizeToolId(tool.id);
718 });
719 }
720 if ('tool_call_id' in msg && msg.role === 'tool') {
721 msg.tool_call_id = sanitizeToolId(msg.tool_call_id);
722 }
723 if (msg.role === 'system' && msg.name === 'example_assistant') {
724 if (names.charName && !msg.content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(msg.content)) {
725 msg.content = `${names.charName}: ${msg.content}`;
726 }
727 delete msg.name;
728 }
729
730 if (msg.role === 'system' && msg.name === 'example_user') {
731 if (names.userName && !msg.content.startsWith(`${names.userName}: `)) {
732 msg.content = `${names.userName}: ${msg.content}`;
733 }
734 delete msg.name;
735 }
736
737 if (msg.name && msg.role !== 'system' && !msg.content.startsWith(`${msg.name}: `)) {
738 msg.content = `${msg.name}: ${msg.content}`;
739 delete msg.name;
740 }
741 });
742
743 // If user role message immediately follows a tool message, append it to the last user message
744 const fixToolMessages = () => {
745 let rerun = true;
746 while (rerun) {
747 rerun = false;
748 messages.forEach((message, i) => {
749 if (i === messages.length - 1) {
750 return;
751 }
752 if (message.role === 'tool' && messages[i + 1].role === 'user') {
753 const lastUserMessage = messages.slice(0, i).findLastIndex(m => m.role === 'user' && m.content);
754 if (lastUserMessage !== -1) {
755 messages[lastUserMessage].content += '\n\n' + messages[i + 1].content;
756 messages.splice(i + 1, 1);
757 rerun = true;
758 }
759 }
760 });
761 }
762 };
763 fixToolMessages();
764
765 // If system role message immediately follows an assistant message, change its role to user
766 for (let i = 0; i < messages.length - 1; i++) {
767 if (messages[i].role === 'assistant' && messages[i + 1].role === 'system') {
768 messages[i + 1].role = 'user';
769 }
770 }
771
772 return messages;
773}
774
775/**
776 * Convert a prompt from the messages objects to the format used by xAI.
777 * @param {object[]} messages Array of messages
778 * @param {PromptNames} names Prompt names
779 * @returns {object[]} Prompt for xAI
780 */
781export function convertXAIMessages(messages, names) {
782 if (!Array.isArray(messages)) {
783 return [];
784 }
785
786 messages.forEach(msg => {
787 if (!msg.name || msg.role === 'user') {
788 return;
789 }
790
791 const needsCharNamePrefix = [
792 { role: 'assistant', condition: names.charName && !msg.content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(msg.content) },
793 { role: 'system', name: 'example_assistant', condition: names.charName && !msg.content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(msg.content) },
794 { role: 'system', name: 'example_user', condition: names.userName && !msg.content.startsWith(`${names.userName}: `) },
795 ];
796
797 const matchingRule = needsCharNamePrefix.find(rule =>
798 msg.role === rule.role && (!rule.name || msg.name === rule.name) && rule.condition,
799 );
800
801 if (matchingRule) {
802 const prefix = msg.role === 'system' && msg.name === 'example_user' ? names.userName : names.charName;
803 msg.content = `${prefix}: ${msg.content}`;
804 }
805
806 delete msg.name;
807 });
808
809 return messages;
810}
811
812/**
813 * Merge messages with the same consecutive role, removing names if they exist.
814 * @param {any[]} messages Messages to merge
815 * @param {PromptNames} names Prompt names
816 * @param {object} options Options for merging
817 * @param {boolean} [options.strict] Enable strict mode: only allow one system message at the start, force user first message
818 * @param {boolean} [options.placeholders] Add user placeholders to the messages in strict mode
819 * @param {boolean} [options.single] Force every role to be user, merging all messages into one
820 * @param {boolean} [options.tools] Allow tool calls in the prompt. If false, tool call messages are removed.
821 * @returns {any[]} Merged messages
822 */
823export function mergeMessages(messages, names, { strict = false, placeholders = false, single = false, tools = false } = {}) {
824 let mergedMessages = [];
825
826 /** @type {Map<string,object>} */
827 const contentTokens = new Map();
828
829 // Remove names from the messages
830 messages.forEach((message) => {
831 if (!message.content) {
832 message.content = '';
833 }
834 // Flatten contents and replace image URLs with random tokens
835 if (Array.isArray(message.content)) {
836 const text = message.content.map((content) => {
837 if (content.type === 'text') {
838 return content.text;
839 }
840 // Could be extended with other non-text types
841 if (['image_url', 'video_url', 'audio_url'].includes(content.type)) {
842 const token = crypto.randomBytes(32).toString('base64');
843 contentTokens.set(token, content);
844 return token;
845 }
846 return '';
847 }).join('\n\n');
848 message.content = text;
849 }
850 if (message.role === 'system' && message.name === 'example_assistant') {
851 if (names.charName && !message.content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(message.content)) {
852 message.content = `${names.charName}: ${message.content}`;
853 }
854 }
855 if (message.role === 'system' && message.name === 'example_user') {
856 if (names.userName && !message.content.startsWith(`${names.userName}: `)) {
857 message.content = `${names.userName}: ${message.content}`;
858 }
859 }
860 if (message.name && message.role !== 'system') {
861 if (!message.content.startsWith(`${message.name}: `)) {
862 message.content = `${message.name}: ${message.content}`;
863 }
864 }
865 if (message.role === 'tool' && !tools) {
866 message.role = 'user';
867 }
868 if (single) {
869 if (message.role === 'assistant') {
870 if (names.charName && !message.content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(message.content)) {
871 message.content = `${names.charName}: ${message.content}`;
872 }
873 }
874 if (message.role === 'user') {
875 if (names.userName && !message.content.startsWith(`${names.userName}: `)) {
876 message.content = `${names.userName}: ${message.content}`;
877 }
878 }
879
880 message.role = 'user';
881 }
882 delete message.name;
883 if (!tools) {
884 delete message.tool_calls;
885 delete message.tool_call_id;
886 }
887 });
888
889 // Squash consecutive messages with the same role
890 messages.forEach((message) => {
891 if (mergedMessages.length > 0 && mergedMessages[mergedMessages.length - 1].role === message.role && message.content && message.role !== 'tool') {
892 mergedMessages[mergedMessages.length - 1].content += '\n\n' + message.content;
893 } else {
894 mergedMessages.push(message);
895 }
896 });
897
898 // Prevent erroring out if the mergedMessages array is empty.
899 if (mergedMessages.length === 0) {
900 mergedMessages.unshift({
901 role: 'user',
902 content: PROMPT_PLACEHOLDER,
903 });
904 }
905
906 // Check for content tokens and replace them with the actual content objects
907 if (contentTokens.size > 0) {
908 mergedMessages.forEach((message) => {
909 const hasValidToken = Array.from(contentTokens.keys()).some(token => message.content.includes(token));
910
911 if (hasValidToken) {
912 const splitContent = message.content.split('\n\n');
913 const mergedContent = [];
914
915 splitContent.forEach((content) => {
916 if (contentTokens.has(content)) {
917 mergedContent.push(contentTokens.get(content));
918 } else {
919 if (mergedContent.length > 0 && mergedContent[mergedContent.length - 1].type === 'text') {
920 mergedContent[mergedContent.length - 1].text += `\n\n${content}`;
921 } else {
922 mergedContent.push({ type: 'text', text: content });
923 }
924 }
925 });
926
927 message.content = mergedContent;
928 }
929 });
930 }
931
932 if (strict) {
933 for (let i = 0; i < mergedMessages.length; i++) {
934 // Force mid-prompt system messages to be user messages
935 if (i > 0 && mergedMessages[i].role === 'system') {
936 mergedMessages[i].role = 'user';
937 }
938 }
939 if (mergedMessages.length && placeholders) {
940 if (mergedMessages[0].role === 'system' && (mergedMessages.length === 1 || mergedMessages[1].role !== 'user')) {
941 mergedMessages.splice(1, 0, { role: 'user', content: PROMPT_PLACEHOLDER });
942 } else if (mergedMessages[0].role !== 'system' && mergedMessages[0].role !== 'user') {
943 mergedMessages.unshift({ role: 'user', content: PROMPT_PLACEHOLDER });
944 }
945 }
946 return mergeMessages(mergedMessages, names, { strict: false, placeholders, single: false, tools });
947 }
948
949 return mergedMessages;
950}
951
952/**
953 * Convert a prompt from the ChatML objects to the format used by Text Completion API.
954 * @param {object[]} messages Array of messages
955 * @returns {string} Prompt for Text Completion API
956 */
957export function convertTextCompletionPrompt(messages) {
958 if (typeof messages === 'string') {
959 return messages;
960 }
961
962 const messageStrings = [];
963 messages.forEach(m => {
964 if (m.role === 'system' && m.name === undefined) {
965 messageStrings.push('System: ' + m.content);
966 } else if (m.role === 'system' && m.name !== undefined) {
967 messageStrings.push(m.name + ': ' + m.content);
968 } else {
969 messageStrings.push(m.role + ': ' + m.content);
970 }
971 });
972 return messageStrings.join('\n') + '\nassistant:';
973}
974
975/**
976 * Append cache_control object to a Claude messages at depth. Directly modifies the messages array.
977 * @param {any[]} messages Messages to modify
978 * @param {number} cachingAtDepth Depth at which caching is supposed to occur
979 * @param {string} ttl TTL value
980 */
981export function cachingAtDepthForClaude(messages, cachingAtDepth, ttl) {
982 let passedThePrefill = false;
983 let depth = 0;
984 let previousRoleName = '';
985
986 for (let i = messages.length - 1; i >= 0; i--) {
987 if (!passedThePrefill && messages[i].role === 'assistant') {
988 continue;
989 }
990
991 passedThePrefill = true;
992
993 if (messages[i].role !== previousRoleName) {
994 if (depth === cachingAtDepth || depth === cachingAtDepth + 2) {
995 const content = messages[i].content;
996 content[content.length - 1].cache_control = { type: 'ephemeral', ttl: ttl };
997 }
998
999 if (depth === cachingAtDepth + 2) {
1000 break;
1001 }
1002
1003 depth += 1;
1004 previousRoleName = messages[i].role;
1005 }
1006 }
1007}
1008
1009/**
1010 * Append cache_control headers to an OpenRouter request at depth. Directly modifies the
1011 * messages array.
1012 * @param {object[]} messages Array of messages
1013 * @param {number} cachingAtDepth Depth at which caching is supposed to occur
1014 * @param {string} ttl TTL value
1015 */
1016export function cachingAtDepthForOpenRouterClaude(messages, cachingAtDepth, ttl) {
1017 //caching the prefill is a terrible idea in general
1018 let passedThePrefill = false;
1019 //depth here is the number of message role switches
1020 let depth = 0;
1021 let previousRoleName = '';
1022 for (let i = messages.length - 1; i >= 0; i--) {
1023 if (!passedThePrefill && messages[i].role === 'assistant') {
1024 continue;
1025 }
1026
1027 passedThePrefill = true;
1028
1029 // Skip system messages so they don't affect depth counting or receive cache breakpoints
1030 if (messages[i].role === 'system') {
1031 continue;
1032 }
1033
1034 if (messages[i].role !== previousRoleName) {
1035 if (depth === cachingAtDepth || depth === cachingAtDepth + 2) {
1036 const content = messages[i].content;
1037 if (typeof content === 'string') {
1038 messages[i].content = [{
1039 type: 'text',
1040 text: content,
1041 cache_control: { type: 'ephemeral', ttl: ttl },
1042 }];
1043 } else if (content?.length > 0) {
1044 const contentPartCount = content.length;
1045 content[contentPartCount - 1].cache_control = {
1046 type: 'ephemeral',
1047 ttl: ttl,
1048 };
1049 }
1050 }
1051
1052 if (depth === cachingAtDepth + 2) {
1053 break;
1054 }
1055
1056 depth += 1;
1057 previousRoleName = messages[i].role;
1058 }
1059 }
1060}
1061
1062/**
1063 * Adds cache_control to the system prompt for OpenRouter requests.
1064 *
1065 * @param {object[]} messages Array of messages
1066 * @param {string} [ttl] TTL value (optional)
1067 */
1068export function cachingSystemPromptForOpenRouter(messages, ttl = undefined) {
1069 if (!Array.isArray(messages) || messages.length === 0) {
1070 return;
1071 }
1072
1073 // Find the first system message
1074 const systemMessage = messages.find(msg => msg.role === 'system');
1075 if (!systemMessage) {
1076 return;
1077 }
1078
1079 // Check if it already has cache_control (at message level)
1080 if (systemMessage.cache_control) {
1081 return;
1082 }
1083
1084 const cacheControl = ttl
1085 ? { type: 'ephemeral', ttl }
1086 : { type: 'ephemeral' };
1087
1088 if (Array.isArray(systemMessage.content)) {
1089 const hasExistingCacheControl = systemMessage.content.some(part => part?.cache_control);
1090 if (hasExistingCacheControl) {
1091 return;
1092 }
1093
1094 for (let i = systemMessage.content.length - 1; i >= 0; i--) {
1095 if (systemMessage.content[i]?.type === 'text') {
1096 systemMessage.content[i].cache_control = cacheControl;
1097 return;
1098 }
1099 }
1100 } else if (typeof systemMessage.content === 'string') {
1101 systemMessage.content = [
1102 {
1103 type: 'text',
1104 text: systemMessage.content,
1105 cache_control: cacheControl,
1106 },
1107 ];
1108 }
1109}
1110
1111/**
1112 * Calculate the Claude budget tokens for a given reasoning effort.
1113 * Returns a string effort level for adaptive thinking (Opus 4.6+), a number for traditional thinking, or null for auto.
1114 * @param {number} maxTokens Maximum tokens
1115 * @param {string} reasoningEffort Reasoning effort
1116 * @param {boolean} stream If streaming is enabled
1117 * @param {boolean} isAdaptiveModel If the model supports adaptive thinking (Opus 4.6+)
1118 * @returns {number|string|null} Budget tokens, effort string, or null
1119 */
1120export function calculateClaudeBudgetTokens(maxTokens, reasoningEffort, stream, isAdaptiveModel) {
1121 // Adaptive thinking for Opus 4.6+: return effort string (like Gemini 3)
1122 if (isAdaptiveModel) {
1123 switch (reasoningEffort) {
1124 case REASONING_EFFORT.auto:
1125 return null;
1126 case REASONING_EFFORT.min:
1127 return 'low';
1128 case REASONING_EFFORT.low:
1129 return 'low';
1130 case REASONING_EFFORT.medium:
1131 return 'medium';
1132 case REASONING_EFFORT.high:
1133 return 'high';
1134 case REASONING_EFFORT.max:
1135 return 'max';
1136 }
1137 return null;
1138 }
1139
1140 let budgetTokens = 0;
1141
1142 switch (reasoningEffort) {
1143 case REASONING_EFFORT.auto:
1144 return null;
1145 case REASONING_EFFORT.min:
1146 budgetTokens = 1024;
1147 break;
1148 case REASONING_EFFORT.low:
1149 budgetTokens = Math.floor(maxTokens * 0.1);
1150 break;
1151 case REASONING_EFFORT.medium:
1152 budgetTokens = Math.floor(maxTokens * 0.25);
1153 break;
1154 case REASONING_EFFORT.high:
1155 budgetTokens = Math.floor(maxTokens * 0.5);
1156 break;
1157 case REASONING_EFFORT.max:
1158 budgetTokens = Math.floor(maxTokens * 0.95);
1159 break;
1160 }
1161
1162 budgetTokens = Math.max(budgetTokens, 1024);
1163
1164 if (!stream) {
1165 budgetTokens = Math.min(budgetTokens, 21333);
1166 }
1167
1168 return budgetTokens;
1169}
1170
1171/**
1172 * Calculate the Google budget tokens for a given reasoning effort.
1173 * @param {number} maxTokens Maximum tokens
1174 * @param {string} reasoningEffort Reasoning effort
1175 * @param {string} model Model name
1176 * @returns {number|string|null} Budget tokens
1177 */
1178export function calculateGoogleBudgetTokens(maxTokens, reasoningEffort, model) {
1179 function getFlashBudget() {
1180 let budgetTokens = 0;
1181
1182 switch (reasoningEffort) {
1183 case REASONING_EFFORT.auto:
1184 return -1;
1185 case REASONING_EFFORT.min:
1186 return 0;
1187 case REASONING_EFFORT.low:
1188 budgetTokens = Math.floor(maxTokens * 0.1);
1189 break;
1190 case REASONING_EFFORT.medium:
1191 budgetTokens = Math.floor(maxTokens * 0.25);
1192 break;
1193 case REASONING_EFFORT.high:
1194 budgetTokens = Math.floor(maxTokens * 0.5);
1195 break;
1196 case REASONING_EFFORT.max:
1197 budgetTokens = maxTokens;
1198 break;
1199 }
1200
1201 budgetTokens = Math.min(budgetTokens, 24576);
1202
1203 return budgetTokens;
1204 }
1205
1206 function getFlashLiteBudget() {
1207 let budgetTokens = 0;
1208
1209 switch (reasoningEffort) {
1210 case REASONING_EFFORT.auto:
1211 return -1;
1212 case REASONING_EFFORT.min:
1213 return 0;
1214 case REASONING_EFFORT.low:
1215 budgetTokens = Math.floor(maxTokens * 0.1);
1216 break;
1217 case REASONING_EFFORT.medium:
1218 budgetTokens = Math.floor(maxTokens * 0.25);
1219 break;
1220 case REASONING_EFFORT.high:
1221 budgetTokens = Math.floor(maxTokens * 0.5);
1222 break;
1223 case REASONING_EFFORT.max:
1224 budgetTokens = maxTokens;
1225 break;
1226 }
1227
1228 budgetTokens = Math.max(Math.min(budgetTokens, 24576), 512);
1229
1230 return budgetTokens;
1231 }
1232
1233 function getProBudget() {
1234 let budgetTokens = 0;
1235
1236 switch (reasoningEffort) {
1237 case REASONING_EFFORT.auto:
1238 return -1;
1239 case REASONING_EFFORT.min:
1240 budgetTokens = 128;
1241 break;
1242 case REASONING_EFFORT.low:
1243 budgetTokens = Math.floor(maxTokens * 0.1);
1244 break;
1245 case REASONING_EFFORT.medium:
1246 budgetTokens = Math.floor(maxTokens * 0.25);
1247 break;
1248 case REASONING_EFFORT.high:
1249 budgetTokens = Math.floor(maxTokens * 0.5);
1250 break;
1251 case REASONING_EFFORT.max:
1252 budgetTokens = maxTokens;
1253 break;
1254 }
1255
1256 budgetTokens = Math.max(Math.min(budgetTokens, 32768), 128);
1257
1258 return budgetTokens;
1259 }
1260
1261 function getGemini3FlashBudget() {
1262 switch (reasoningEffort) {
1263 case REASONING_EFFORT.auto:
1264 return null;
1265 case REASONING_EFFORT.min:
1266 return 'minimal';
1267 case REASONING_EFFORT.low:
1268 return 'low';
1269 case REASONING_EFFORT.medium:
1270 return 'medium';
1271 case REASONING_EFFORT.high:
1272 return 'high';
1273 case REASONING_EFFORT.max:
1274 return 'high';
1275 }
1276
1277 return null;
1278 }
1279
1280 function getGemini3ProBudget() {
1281 switch (reasoningEffort) {
1282 case REASONING_EFFORT.auto:
1283 return null;
1284 case REASONING_EFFORT.min:
1285 return 'low';
1286 case REASONING_EFFORT.low:
1287 return 'low';
1288 case REASONING_EFFORT.medium:
1289 return 'low';
1290 case REASONING_EFFORT.high:
1291 return 'high';
1292 case REASONING_EFFORT.max:
1293 return 'high';
1294 }
1295
1296 return null;
1297 }
1298
1299 if (/gemini-3[.\d]*-pro/.test(model)) {
1300 return getGemini3ProBudget();
1301 }
1302
1303 if (/gemini-3[.\d]*-flash/.test(model)) {
1304 return getGemini3FlashBudget();
1305 }
1306
1307 if (/flash-lite/.test(model)) {
1308 return getFlashLiteBudget();
1309 }
1310
1311 if (/flash/.test(model)) {
1312 return getFlashBudget();
1313 }
1314
1315 if (/pro/.test(model)) {
1316 return getProBudget();
1317 }
1318
1319 return null;
1320}
1321
1322/**
1323 * Embed media content in OpenRouter messages (OpenAI-compatible).
1324 * @param {object[]} messages Array of messages
1325 * @param {object} options Options for embedding
1326 * @param {boolean} [options.audio] Enable audio embedding (default: true)
1327 * @param {boolean} [options.video] Enable video embedding (default: true)
1328 * @returns {void}
1329 */
1330export function embedOpenRouterMedia(messages, { audio = true, video = true } = { audio: true, video: true }) {
1331 if (!Array.isArray(messages)) {
1332 return;
1333 }
1334
1335 for (const message of messages) {
1336 if (!Array.isArray(message.content)) {
1337 continue;
1338 }
1339
1340 for (const contentPart of message.content) {
1341 if (video && contentPart?.type === 'video_url' && contentPart.video_url?.url?.startsWith('data:')) {
1342 contentPart.type = 'video_url';
1343 }
1344
1345 if (audio && contentPart?.type === 'audio_url' && contentPart.audio_url?.url?.startsWith('data:')) {
1346 const formatMap = {
1347 'audio/mpeg': 'mp3',
1348 'audio/wav': 'wav',
1349 };
1350
1351 const [header, base64Data] = contentPart.audio_url.url.split(',');
1352 const mimeType = header.match(/data:([^;]+)/)?.[1] || 'audio/mpeg';
1353
1354 contentPart.type = 'input_audio';
1355 contentPart.input_audio = {
1356 format: formatMap[mimeType] || 'mp3',
1357 data: base64Data,
1358 };
1359
1360 delete contentPart.audio_url;
1361 }
1362 }
1363 }
1364}
1365
1366/**
1367 * Adds a dummy reasoning_content field to messages with tool calls for DeepSeek reasoner.
1368 * @param {object[]} messages Array of messages
1369 * @returns {void}
1370 */
1371export function addReasoningContentToToolCalls(messages) {
1372 if (!Array.isArray(messages)) {
1373 return;
1374 }
1375
1376 for (const message of messages) {
1377 if (!Array.isArray(message.tool_calls) || 'reasoning_content' in message) {
1378 continue;
1379 }
1380
1381 message.reasoning_content = '';
1382 }
1383}
1384
1385/**
1386 * Converts reasoning signatures to OpenRouter format.
1387 * @param {object[]} messages Array of messages
1388 * @param {string} model Model name
1389 * @return {void}
1390 */
1391export function addOpenRouterSignatures(messages, model) {
1392 const getFormatForModel = () => {
1393 if (/google\/gemini/.test(model)) {
1394 return 'google-gemini-v1';
1395 }
1396 if (/anthropic\/claude/.test(model)) {
1397 return 'anthropic-claude-v1';
1398 }
1399 if (/openai\/gpt/.test(model)) {
1400 return 'openai-responses-v1';
1401 }
1402 if (/x-ai\/grok/.test(model)) {
1403 return 'xai-responses-v1';
1404 }
1405 return 'unknown';
1406 };
1407
1408 if (!Array.isArray(messages)) {
1409 return;
1410 }
1411
1412 for (const message of messages) {
1413 const details = [];
1414 const addDetail = (data, id) => {
1415 if (typeof data !== 'string' || data.length === 0) {
1416 return;
1417 }
1418 const detail = {
1419 index: details.length,
1420 id: id || `signature-${details.length}`,
1421 type: 'reasoning.encrypted',
1422 data: data,
1423 format: getFormatForModel(),
1424 };
1425 details.push(detail);
1426 };
1427 if (typeof message.signature === 'string') {
1428 if (enableThoughtSignatures) {
1429 addDetail(message.signature);
1430 }
1431 delete message.signature;
1432 }
1433 if (Array.isArray(message.tool_calls)) {
1434 message.tool_calls.forEach((toolCall) => {
1435 if (typeof toolCall.signature === 'string') {
1436 addDetail(toolCall.signature, toolCall.id);
1437 delete toolCall.signature;
1438 }
1439 });
1440 }
1441 if (details.length > 0) {
1442 message.reasoning_details = details;
1443 }
1444 }
1445}