Files
Octopus aecbb9a2ee feat: add MiniMax as a chat completion provider (#5452)
* feat: add MiniMax as a chat completion provider

Add MiniMax (https://www.minimax.io) as a first-class chat completion
provider. MiniMax already has TTS integration in SillyTavern; this
extends support to LLM chat completions via their OpenAI-compatible API.

Supported models:
- MiniMax-M2.5 (default) — 204K context
- MiniMax-M2.5-highspeed — same capability, faster inference

Key implementation details:
- Reuses existing SECRET_KEYS.MINIMAX (shared with TTS)
- API endpoint: https://api.minimax.io/v1
- Temperature clamped to (0.0, 1.0] as required by MiniMax API
- Returns hardcoded model list since MiniMax doesn't expose /v1/models
- Full UI integration: model selector, sampler parameters, streaming

Co-Authored-By: octo-patch <octo-patch@users.noreply.github.com>

* feat: upgrade MiniMax default model to M2.7

- Add MiniMax-M2.7 and MiniMax-M2.7-highspeed to model list
- Set MiniMax-M2.7 as default model
- Keep all previous models as alternatives

* feat: independent request function, vision support, temp clamping for MiniMax

- Extract sendMinimaxRequest() following Chutes pattern (PR #4844)
  with function calling and JSON Schema structured output support
- Clamp temperature to (0.01, 1.0] on backend; limit frontend UI max to 1.0
- Enable image inlining for MiniMax M2.7 model
- Add MiniMax to slash-commands model selector and tokenizer mapping
- Add minimax_model to default preset

* feat: add VLM-based vision support for MiniMax M2.7

M2.7 does not natively accept image input. When images are detected
in messages, pre-process them via the MiniMax VLM endpoint
(/v1/coding_plan/vlm) to convert images to text descriptions before
sending to the chat completions API. Uses the same API key.

* feat: add M2-her model to MiniMax provider

M2-her is MiniMax's dialogue/roleplay-optimized model with 64K context
and 2048 max completion tokens. Text-only (no vision).

* feat: add MiniMax China endpoint (minimaxi.com) support

Add endpoint selector (Global/China) for MiniMax, mirroring the
SiliconFlow pattern. Users can now choose between api.minimax.io
(international) and api.minimaxi.com (China domestic).

* fix: merge consecutive same-role messages for MiniMax

MiniMax API rejects consecutive messages with the same role with
error 'invalid chat setting (2013)'. Merge them before sending.

* review: address PR feedback on MiniMax provider

Backend (src/endpoints/backends/chat-completions.js):
- Drop the entire MiniMax VLM image-preprocessing path; vision is no
  longer advertised for this provider, so M2.7 messages now go straight
  to /chat/completions without a separate VLM round-trip.
- Drop the json_schema -> response_format mapping (MiniMax does not
  document structured-output support; relying on it was speculative).
- Drop the backend temperature clamp; the same clamp now lives in the
  frontend so the wire payload matches what the user sees.
- Drop the MINIMAX branch in /status that returned a hard-coded model
  list; the frontend hardcodes the same list and bypasses /status via
  noValidateSources, so the round-trip was wasted.
- Add a streaming Transform + non-streaming helper that move
  <think>...</think> blocks from delta.content / message.content to
  reasoning_content. MiniMax M2.x emit chain-of-thought inline in
  content; without this transform the raw <think> tags leak into the
  rendered chat. Includes a state machine that holds back partial
  marker bytes so a marker split across SSE chunks is still detected.

Frontend:
- public/scripts/openai.js: add MINIMAX to noValidateSources so the key
  is accepted without a /models call; remove the dead saveModelList
  branch; clamp temperature to (0.0, 1.0] in createGenerationParameters.
- public/scripts/reasoning.js: add MINIMAX to the non-streaming
  reasoning_content extraction case (the backend transform now produces
  this field for MiniMax responses).
- public/scripts/slash-commands.js: add MINIMAX to the /api enum and
  add a MiniMax case to /api-url so users can switch endpoint by
  command.
- public/scripts/custom-request.js: pass minimax_endpoint through the
  override-payload merge alongside the other per-source endpoint fields.
- public/scripts/tokenizers.js: stop returning openai_model (which was
  always a MiniMax model id and thus an unknown tokenizer); fall back
  to gpt-3.5-turbo for a coarse but functional estimate.
- public/scripts/tool-calling.js: add MINIMAX to supportedSources so
  function-calling settings are exposed.
- public/index.html: drop the "-- Connect to the API --" placeholder
  option from the model select (the model list is hardcoded and always
  populated); remove minimax from the vision data-source attributes
  on the inline-media controls.
- public/img/minimax.svg: replace the multicolor brand SVG with a
  single-color currentColor version that matches the other provider
  icons in the connect panel.

* review: drop backend <think> parsing, defer to frontend

Per reviewer feedback: SillyTavern's reasoningHandler / reasoning_auto_parse
setting already extracts <think>...</think> blocks on the client side, so the
backend doesn't need to rewrite MiniMax responses. Removes the SSE Transform,
the non-streaming helper, and the corresponding case in reasoning.js.

* fix: remove isImageInliningSupported declaration for MINIMAX

* fix: remove MINIMAX from stream reasoning parsing

* fix: add to autoconnect logic

* fix: add missing MINIMAX models from docs

* fix: freq. and pres. pen aren't supported for MINIMAX

* fix: use clamp function for adjusting temperature

* fix: pass minimax_endpoint from connection profile to ChatCompletionService

* fix: update supported APIs in slash command documentation

* fix: replace bespoke merge with standard MERGE_TOOLS processing

* fix: add data-i18n attributes for headers

---------

Co-authored-by: octo-patch <octo-patch@users.noreply.github.com>
Co-authored-by: octo-patch <octo-patch@github.com>
Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
2026-04-24 00:43:05 +03:00

1164 lines
47 KiB
JavaScript

import { DOMPurify } from '../lib.js';
import { addOneMessage, chat, event_types, eventSource, getGeneratingApi, getGeneratingModel, main_api, saveChatConditional, system_avatar, systemUserName } from '../script.js';
import { chat_completion_sources, custom_prompt_post_processing_types, getChatCompletionModel, model_list, oai_settings } from './openai.js';
import { Popup } from './popup.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
import { isTrueBoolean } from './utils.js';
/**
* @typedef {object} ToolInvocation
* @property {string} id - A unique identifier for the tool invocation.
* @property {string} displayName - The display name of the tool.
* @property {string} name - The name of the tool.
* @property {string} parameters - The parameters for the tool invocation.
* @property {string} result - The result of the tool invocation.
* @property {string?} signature - The thought signature associated with the tool invocation.
* @property {string?} reasoning - The plaintext reasoning associated with this tool call turn.
* @property {boolean} [error] - Whether the tool invocation failed.
*/
/**
* @typedef {object} ToolInvocationResult
* @property {ToolInvocation[]} invocations Tool invocations (both successful and failed)
* @property {Error[]} errors Errors that occurred during tool invocation
* @property {string[]} stealthCalls Names of stealth tools that were invoked
*/
/**
* @typedef {object} ToolRegistration
* @property {string} name - The name of the tool.
* @property {string} displayName - The display name of the tool.
* @property {string} description - A description of the tool.
* @property {object} parameters - The parameters for the tool.
* @property {function} action - The action to perform when the tool is invoked.
* @property {function} [formatMessage] - A function to format the tool call message.
* @property {function} [shouldRegister] - A function to determine if the tool should be registered.
* @property {boolean} [stealth] - A tool call result will not be shown in the chat. No follow-up generation will be performed.
*/
/**
* @typedef {object} ToolDefinitionOpenAI
* @property {string} type - The type of the tool.
* @property {object} function - The function definition.
* @property {string} function.name - The name of the function.
* @property {string} function.description - The description of the function.
* @property {object} function.parameters - The parameters of the function.
* @property {function} toString - A function to convert the tool to a string.
*/
/**
* Assigns nested variables to a scope.
* @param {import('./slash-commands/SlashCommandScope.js').SlashCommandScope} scope The scope to assign variables to.
* @param {object} arg Object to assign variables from.
* @param {string} prefix Prefix for the variable names.
*/
function assignNestedVariables(scope, arg, prefix) {
Object.entries(arg).forEach(([key, value]) => {
const newPrefix = `${prefix}.${key}`;
if (typeof value === 'object' && value !== null) {
if (Array.isArray(value)) {
scope.letVariable(newPrefix, JSON.stringify(value));
}
assignNestedVariables(scope, value, newPrefix);
} else {
scope.letVariable(newPrefix, value);
}
});
}
/**
* Checks if a string is a valid JSON string.
* @param {string} str The string to check
* @returns {boolean} If the string is a valid JSON string
*/
function isJson(str) {
try {
JSON.parse(str);
return true;
} catch {
return false;
}
}
/**
* Tries to parse a string as JSON, returning the original string if parsing fails.
* @param {string} str The string to try to parse
* @returns {object|string} Parsed JSON or the original string
*/
function tryParse(str) {
try {
return JSON.parse(str);
} catch {
return str;
}
}
/**
* Stringifies an object if it is not already a string.
* @param {any} obj The object to stringify
* @returns {string} A JSON string representation of the object.
*/
function stringify(obj) {
return typeof obj === 'string' ? obj : JSON.stringify(obj);
}
/**
* A class that represents a tool definition.
*/
class ToolDefinition {
/**
* A unique name for the tool.
* @type {string}
*/
#name;
/**
* A user-friendly display name for the tool.
* @type {string}
*/
#displayName;
/**
* A description of what the tool does.
* @type {string}
*/
#description;
/**
* A JSON schema for the parameters that the tool accepts.
* @type {object}
*/
#parameters;
/**
* A function that will be called when the tool is executed.
* @type {function}
*/
#action;
/**
* A function that will be called to format the tool call toast.
* @type {function}
*/
#formatMessage;
/**
* A function that will be called to determine if the tool should be registered.
* @type {function}
*/
#shouldRegister;
/**
* A tool call result will not be shown in the chat. No follow-up generation will be performed.
* @type {boolean}
*/
#stealth;
/**
* Creates a new ToolDefinition.
* @param {string} name A unique name for the tool.
* @param {string} displayName A user-friendly display name for the tool.
* @param {string} description A description of what the tool does.
* @param {object} parameters A JSON schema for the parameters that the tool accepts.
* @param {function} action A function that will be called when the tool is executed.
* @param {function} formatMessage A function that will be called to format the tool call toast.
* @param {function} shouldRegister A function that will be called to determine if the tool should be registered.
* @param {boolean} stealth A tool call result will not be shown in the chat. No follow-up generation will be performed.
*/
constructor(name, displayName, description, parameters, action, formatMessage, shouldRegister, stealth) {
this.#name = name;
this.#displayName = displayName;
this.#description = description;
this.#parameters = parameters;
this.#action = action;
this.#formatMessage = formatMessage;
this.#shouldRegister = shouldRegister;
this.#stealth = stealth;
}
/**
* Converts the ToolDefinition to an OpenAI API representation
* @returns {ToolDefinitionOpenAI} OpenAI API representation of the tool.
*/
toFunctionOpenAI() {
return {
type: 'function',
function: {
name: this.#name,
description: this.#description,
parameters: this.#parameters,
},
toString: function () {
return `<div><b>${this.function.name}</b></div><div><small>${this.function.description}</small></div><pre class="justifyLeft wordBreakAll"><code class="flex padding5">${JSON.stringify(this.function.parameters, null, 2)}</code></pre><hr>`;
},
};
}
/**
* Invokes the tool with the given parameters.
* @param {object} parameters The parameters to pass to the tool.
* @returns {Promise<any>} The result of the tool's action function.
*/
async invoke(parameters) {
return await this.#action(parameters);
}
/**
* Formats a message with the tool invocation.
* @param {object} parameters The parameters to pass to the tool.
* @returns {Promise<string>} The formatted message.
*/
async formatMessage(parameters) {
return typeof this.#formatMessage === 'function'
? await this.#formatMessage(parameters)
: `Invoking tool: ${this.#displayName || this.#name}`;
}
async shouldRegister() {
return typeof this.#shouldRegister === 'function'
? await this.#shouldRegister()
: true;
}
get displayName() {
return this.#displayName;
}
get stealth() {
return this.#stealth;
}
}
/**
* A class that manages the registration and invocation of tools.
*/
export class ToolManager {
/**
* A map of tool names to tool definitions.
* @type {Map<string, ToolDefinition>}
*/
static #tools = new Map();
static #INPUT_DELTA_KEY = '__input_json_delta';
/**
* The maximum number of times to recurse when parsing tool calls.
* @type {number}
*/
static RECURSE_LIMIT = 5;
/**
* Returns an Array of all tools that have been registered.
* @type {ToolDefinition[]}
*/
static get tools() {
return Array.from(this.#tools.values());
}
/**
* Registers a new tool with the tool registry.
* @param {ToolRegistration} tool The tool to register.
*/
static registerFunctionTool({ name, displayName, description, parameters, action, formatMessage, shouldRegister, stealth }) {
// Convert WIP arguments
if (typeof arguments[0] !== 'object') {
[name, description, parameters, action] = arguments;
}
if (this.#tools.has(name)) {
console.warn(`[ToolManager] A tool with the name "${name}" has already been registered. The definition will be overwritten.`);
}
const definition = new ToolDefinition(
name,
displayName,
description,
parameters,
action,
formatMessage,
shouldRegister,
stealth,
);
this.#tools.set(name, definition);
console.log('[ToolManager] Registered function tool:', definition);
}
/**
* Removes a tool from the tool registry.
* @param {string} name The name of the tool to unregister.
*/
static unregisterFunctionTool(name) {
if (!this.#tools.has(name)) {
return;
}
this.#tools.delete(name);
console.log(`[ToolManager] Unregistered function tool: ${name}`);
}
/**
* Parse tool call parameters -- they're usually JSON, but they can also be empty strings (which are not valid JSON apparently).
* @param {object} parameters The parameters for a tool call, usually a string with JSON inside
* @returns {object} The parsed parameters
*/
static #parseParameters(parameters) {
return parameters === ''
? {}
: typeof parameters === 'string'
? JSON.parse(parameters)
: parameters;
}
/**
* Invokes a tool by name. Returns the result of the tool's action function.
* @param {string} name The name of the tool to invoke.
* @param {object} parameters Function parameters. For example, if the tool requires a "name" parameter, you would pass {name: "value"}.
* @returns {Promise<string|Error>} The result of the tool's action function. If an error occurs, null is returned. Non-string results are JSON-stringified.
*/
static async invokeFunctionTool(name, parameters) {
try {
if (!this.#tools.has(name)) {
throw new Error(`No tool with the name "${name}" has been registered.`);
}
const invokeParameters = this.#parseParameters(parameters);
const tool = this.#tools.get(name);
const result = await tool.invoke(invokeParameters);
return typeof result === 'string' ? result : JSON.stringify(result);
} catch (error) {
console.error(`[ToolManager] An error occurred while invoking the tool "${name}":`, error);
if (error instanceof Error) {
error.cause = name;
return error;
}
return new Error('Unknown error occurred while invoking the tool.', { cause: name });
}
}
/**
* Checks if a tool is a stealth tool.
* @param {string} name The name of the tool to check.
* @returns {boolean} Whether the tool is a stealth tool.
*/
static isStealthTool(name) {
if (!this.#tools.has(name)) {
return false;
}
const tool = this.#tools.get(name);
return !!tool.stealth;
}
/**
* Formats a message for a tool call by name.
* @param {string} name The name of the tool to format the message for.
* @param {object} parameters Function tool call parameters.
* @returns {Promise<string>} The formatted message for the tool call.
*/
static async formatToolCallMessage(name, parameters) {
if (!this.#tools.has(name)) {
return `Invoked unknown tool: ${name}`;
}
try {
const tool = this.#tools.get(name);
const formatParameters = this.#parseParameters(parameters);
return await tool.formatMessage(formatParameters);
} catch (error) {
console.error(`[ToolManager] An error occurred while formatting the tool call message for "${name}":`, error);
return `Invoking tool: ${name}`;
}
}
/**
* Gets the display name of a tool by name.
* @param {string} name
* @returns {string} The display name of the tool.
*/
static getDisplayName(name) {
if (!this.#tools.has(name)) {
return name;
}
const tool = this.#tools.get(name);
return tool.displayName || name;
}
/**
* Register function tools for the next chat completion request.
* @param {object} data Generation data
*/
static async registerFunctionToolsOpenAI(data) {
const tools = [];
for (const tool of ToolManager.tools) {
const register = await tool.shouldRegister();
if (!register) {
console.log('[ToolManager] Skipping tool registration:', tool);
continue;
}
tools.push(tool.toFunctionOpenAI());
}
if (tools.length) {
console.log('[ToolManager] Registered function tools:', tools);
data.tools = tools;
data.tool_choice = 'auto';
}
}
/**
* Utility function to parse tool calls from a parsed response.
* @param {any[]} toolCalls The tool calls to update.
* @param {any} parsed The parsed response from the OpenAI API.
* @param {object} toolSignatures Optional mapping of tool call IDs to thought signatures.
* @returns {void}
*/
static parseToolCalls(toolCalls, parsed, toolSignatures = {}) {
if (!this.isToolCallingSupported()) {
return;
}
if (Array.isArray(parsed?.choices)) {
for (const choice of parsed.choices) {
const choiceIndex = (typeof choice.index === 'number') ? choice.index : null;
const choiceDelta = choice.delta;
if (choiceIndex === null || !choiceDelta) {
continue;
}
const toolCallDeltas = choiceDelta?.tool_calls;
if (!Array.isArray(toolCallDeltas)) {
continue;
}
if (!Array.isArray(toolCalls[choiceIndex])) {
toolCalls[choiceIndex] = [];
}
for (const toolCallDelta of toolCallDeltas) {
const toolCallIndex = toolCallDelta?.index >= 0 ? toolCallDelta.index : toolCallDeltas.indexOf(toolCallDelta);
if (isNaN(toolCallIndex)) {
continue;
}
if (toolCalls[choiceIndex][toolCallIndex] === undefined) {
toolCalls[choiceIndex][toolCallIndex] = {};
}
const targetToolCall = toolCalls[choiceIndex][toolCallIndex];
ToolManager.#applyToolCallDelta(targetToolCall, toolCallDelta);
// Transfer thought signature if available
if (Object.hasOwn(toolSignatures, targetToolCall.id)) {
targetToolCall.signature = toolSignatures[targetToolCall.id];
}
}
}
}
const cohereToolEvents = ['message-start', 'tool-call-start', 'tool-call-delta', 'tool-call-end'];
if (cohereToolEvents.includes(parsed?.type) && typeof parsed?.delta?.message === 'object') {
const choiceIndex = 0;
const toolCallIndex = parsed?.index ?? 0;
if (!Array.isArray(toolCalls[choiceIndex])) {
toolCalls[choiceIndex] = [];
}
if (toolCalls[choiceIndex][toolCallIndex] === undefined) {
toolCalls[choiceIndex][toolCallIndex] = {};
}
const targetToolCall = toolCalls[choiceIndex][toolCallIndex];
ToolManager.#applyToolCallDelta(targetToolCall, parsed.delta.message);
}
if (typeof parsed?.content_block === 'object') {
const choiceIndex = 0;
const toolCallIndex = parsed?.index ?? 0;
if (parsed?.content_block?.type === 'tool_use') {
if (!Array.isArray(toolCalls[choiceIndex])) {
toolCalls[choiceIndex] = [];
}
if (toolCalls[choiceIndex][toolCallIndex] === undefined) {
toolCalls[choiceIndex][toolCallIndex] = {};
}
const targetToolCall = toolCalls[choiceIndex][toolCallIndex];
ToolManager.#applyToolCallDelta(targetToolCall, parsed.content_block);
}
}
if (typeof parsed?.delta === 'object') {
const choiceIndex = 0;
const toolCallIndex = parsed?.index ?? 0;
const targetToolCall = toolCalls[choiceIndex]?.[toolCallIndex];
if (targetToolCall) {
if (parsed?.delta?.type === 'input_json_delta') {
const jsonDelta = parsed?.delta?.partial_json;
if (!targetToolCall[this.#INPUT_DELTA_KEY]) {
targetToolCall[this.#INPUT_DELTA_KEY] = '';
}
targetToolCall[this.#INPUT_DELTA_KEY] += jsonDelta;
}
}
}
if (parsed?.type === 'content_block_stop') {
const choiceIndex = 0;
const toolCallIndex = parsed?.index ?? 0;
const targetToolCall = toolCalls[choiceIndex]?.[toolCallIndex];
if (targetToolCall) {
const jsonDeltaString = targetToolCall[this.#INPUT_DELTA_KEY];
if (jsonDeltaString) {
try {
const jsonDelta = { input: JSON.parse(jsonDeltaString) };
delete targetToolCall[this.#INPUT_DELTA_KEY];
ToolManager.#applyToolCallDelta(targetToolCall, jsonDelta);
} catch (error) {
console.warn('[ToolManager] Failed to apply input JSON delta:', error);
}
}
}
}
if (Array.isArray(parsed?.candidates)) {
for (let choiceIndex = 0; choiceIndex < parsed.candidates.length; choiceIndex++) {
const candidate = parsed.candidates[choiceIndex];
if (Array.isArray(candidate?.content?.parts)) {
for (let partIndex = 0; partIndex < candidate.content.parts.length; partIndex++) {
const part = candidate.content.parts[partIndex];
if (part.functionCall) {
if (!Array.isArray(toolCalls[choiceIndex])) {
toolCalls[choiceIndex] = [];
}
const toolCallIndex = toolCalls[choiceIndex].length;
if (toolCalls[choiceIndex][toolCallIndex] === undefined) {
toolCalls[choiceIndex][toolCallIndex] = {};
}
const targetToolCall = toolCalls[choiceIndex][toolCallIndex];
if (part.thoughtSignature) {
targetToolCall.thoughtSignature = part.thoughtSignature;
}
ToolManager.#applyToolCallDelta(targetToolCall, part.functionCall);
}
}
}
}
}
}
/**
* Apply a tool call delta to a target object.
* @param {object} target The target object to apply the delta to
* @param {object} delta The delta object to apply
*/
static #applyToolCallDelta(target, delta) {
for (const key in delta) {
if (!Object.prototype.hasOwnProperty.call(delta, key)) continue;
if (key === '__proto__' || key === 'constructor') continue;
const deltaValue = delta[key];
const targetValue = target[key];
if (deltaValue === null || deltaValue === undefined) {
// Don't reset the value if it already exists
if (targetValue) {
continue;
}
target[key] = deltaValue;
continue;
}
if (typeof deltaValue === 'string') {
if (typeof targetValue === 'string') {
// Concatenate strings
target[key] = targetValue + deltaValue;
} else {
target[key] = deltaValue;
}
} else if (typeof deltaValue === 'object' && !Array.isArray(deltaValue)) {
if (typeof targetValue !== 'object' || targetValue === null || Array.isArray(targetValue)) {
target[key] = {};
}
// Recursively apply deltas to nested objects
ToolManager.#applyToolCallDelta(target[key], deltaValue);
} else {
// Assign other types directly
target[key] = deltaValue;
}
}
}
/**
* Checks if tool calling is supported for the current settings and generation type.
* @param {ChatCompletionSettings} settings Optional chat completion settings
* @param {string} model Optional model name
* @returns {boolean} Whether tool calling is supported for the given type
*/
static isToolCallingSupported(settings = null, model = null) {
settings = settings ?? oai_settings;
model = model ?? getChatCompletionModel(settings);
if (main_api !== 'openai' || !settings.function_calling) {
return false;
}
// Post-processing will forcefully remove past tool calls from the prompt, making them useless
const { NONE, MERGE_TOOLS, SEMI_TOOLS, STRICT_TOOLS } = custom_prompt_post_processing_types;
const allowedPromptPostProcessing = [NONE, MERGE_TOOLS, SEMI_TOOLS, STRICT_TOOLS];
if (!allowedPromptPostProcessing.includes(settings.custom_prompt_post_processing)) {
return false;
}
const currentModel = Array.isArray(model_list) ? model_list.find(m => m.id === model) : null;
if (currentModel) {
switch (settings.chat_completion_source) {
case chat_completion_sources.POLLINATIONS:
return currentModel.tools;
case chat_completion_sources.FIREWORKS:
return currentModel.supports_tools;
case chat_completion_sources.OPENROUTER:
return currentModel.supported_parameters?.includes('tools');
case chat_completion_sources.MISTRALAI:
return currentModel.capabilities?.function_calling;
case chat_completion_sources.AIMLAPI:
return currentModel.features?.includes('openai/chat-completion.function');
case chat_completion_sources.CHUTES:
return currentModel.supported_features?.includes('tools');
case chat_completion_sources.ELECTRONHUB:
return currentModel.metadata?.function_call;
case chat_completion_sources.WORKERS_AI:
return Array.isArray(currentModel.properties) && currentModel.properties.some(p => p.property_id === 'function_calling' && p.value === 'true');
}
}
const supportedSources = [
chat_completion_sources.OPENAI,
chat_completion_sources.CUSTOM,
chat_completion_sources.MISTRALAI,
chat_completion_sources.CLAUDE,
chat_completion_sources.OPENROUTER,
chat_completion_sources.AIMLAPI,
chat_completion_sources.GROQ,
chat_completion_sources.COHERE,
chat_completion_sources.DEEPSEEK,
chat_completion_sources.MAKERSUITE,
chat_completion_sources.VERTEXAI,
chat_completion_sources.AI21,
chat_completion_sources.XAI,
chat_completion_sources.POLLINATIONS,
chat_completion_sources.MOONSHOT,
chat_completion_sources.FIREWORKS,
chat_completion_sources.COMETAPI,
chat_completion_sources.CHUTES,
chat_completion_sources.ELECTRONHUB,
chat_completion_sources.AZURE_OPENAI,
chat_completion_sources.ZAI,
chat_completion_sources.SILICONFLOW,
chat_completion_sources.NANOGPT,
chat_completion_sources.WORKERS_AI,
chat_completion_sources.MINIMAX,
];
return supportedSources.includes(settings.chat_completion_source);
}
/**
* Checks if tool calls can be performed for the current settings and generation type.
* @param {string} type Generation type
* @param {ChatCompletionSettings} settings Optional chat completion settings
* @param {string} model Optional model name
* @returns {boolean} Whether tool calls can be performed for the given type
*/
static canPerformToolCalls(type, settings = null, model = null) {
settings = settings ?? oai_settings;
model = model ?? getChatCompletionModel(settings);
const noToolCallTypes = ['impersonate', 'quiet', 'continue'];
const isSupported = ToolManager.isToolCallingSupported(settings, model);
return isSupported && !noToolCallTypes.includes(type);
}
/**
* Utility function to get tool calls from the response data.
* @param {any} data Response data
* @returns {any[]} Tool calls from the response data
*/
static #getToolCallsFromData(data) {
const getRandomId = () => Math.random().toString(36).substring(2);
const isClaudeToolCall = c => Array.isArray(c) ? c.filter(x => x).every(isClaudeToolCall) : c?.input && c?.name && c?.id;
const isGoogleToolCall = c => Array.isArray(c) ? c.filter(x => x).every(isGoogleToolCall) : c?.name && c?.args;
const convertClaudeToolCall = c => ({ id: c.id, function: { name: c.name, arguments: c.input } });
const convertGoogleToolCall = (c, signature = null) => ({ id: getRandomId(), function: { name: c.name, arguments: c.args }, signature });
// Parsed tool calls from streaming data
if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) {
if (isClaudeToolCall(data[0])) {
return data[0].filter(x => x).map(convertClaudeToolCall);
}
if (isGoogleToolCall(data[0])) {
return data[0].filter(x => x).map((c) => convertGoogleToolCall(c, c.thoughtSignature));
}
if (typeof data[0]?.[0]?.tool_calls === 'object') {
return Array.isArray(data[0]?.[0]?.tool_calls) ? data[0][0].tool_calls : [data[0][0].tool_calls];
}
return data[0];
}
// Google AI Studio tool calls
if (Array.isArray(data?.responseContent?.parts)) {
return data.responseContent.parts.filter(p => p.functionCall).map(p => convertGoogleToolCall(p.functionCall, p.thoughtSignature));
}
// Parsed tool calls from non-streaming data
if (Array.isArray(data?.choices)) {
// Find a choice with 0-index
const choice = data.choices.find(choice => choice.index === 0);
if (choice && typeof choice.message === 'object' && Array.isArray(choice.message.tool_calls)) {
// Add OpenRouter signatures
if (Array.isArray(choice.message.reasoning_details)) {
for (const toolCall of choice.message.tool_calls) {
const reasoningDetail = choice.message.reasoning_details.find(rd => rd.id === toolCall.id);
if (reasoningDetail && reasoningDetail.type === 'reasoning.encrypted' && reasoningDetail.data) {
toolCall.signature = reasoningDetail.data;
}
}
}
return choice.message.tool_calls;
}
}
// Claude tool calls to OpenAI tool calls
if (Array.isArray(data?.content)) {
const content = data.content.filter(c => c.type === 'tool_use').map(convertClaudeToolCall);
if (content) {
return content;
}
}
// Cohere tool calls
if (typeof data?.message?.tool_calls === 'object') {
return Array.isArray(data?.message?.tool_calls) ? data.message.tool_calls : [data.message.tool_calls];
}
}
/**
* Checks if the response data contains tool calls.
* @param {object} data Response data
* @returns {boolean} Whether the response data contains tool calls
*/
static hasToolCalls(data) {
const toolCalls = ToolManager.#getToolCallsFromData(data);
return Array.isArray(toolCalls) && toolCalls.length > 0;
}
/**
* Check for function tool calls in the response data and invoke them.
* @param {any} data Reply data
* @returns {Promise<ToolInvocationResult>} Successful tool invocations
*/
static async invokeFunctionTools(data, { reasoningText = null } = {}) {
/** @type {ToolInvocationResult} */
const result = {
invocations: [],
errors: [],
stealthCalls: [],
};
const toolCalls = ToolManager.#getToolCallsFromData(data);
if (!Array.isArray(toolCalls)) {
return result;
}
for (const toolCall of toolCalls) {
if (!toolCall || !toolCall.function || typeof toolCall.function !== 'object') {
continue;
}
console.log('[ToolManager] Function tool call:', toolCall);
const id = toolCall.id;
const parameters = toolCall.function.arguments;
const name = toolCall.function.name;
const displayName = ToolManager.getDisplayName(name);
const isStealth = ToolManager.isStealthTool(name);
const message = await ToolManager.formatToolCallMessage(name, parameters);
const toast = message && toastr.info(message, 'Tool Calling', { timeOut: 0 });
const toolResult = await ToolManager.invokeFunctionTool(name, parameters);
toastr.clear(toast);
console.log('[ToolManager] Function tool result:', result);
// Handle tool errors — still create an invocation so the LLM sees the failure
if (toolResult instanceof Error) {
result.errors.push(toolResult);
if (isStealth) {
result.stealthCalls.push(name);
} else {
result.invocations.push({
id,
displayName,
name,
parameters: stringify(parameters),
result: toolResult.toString(),
error: true,
signature: toolCall.signature || null,
reasoning: reasoningText || null,
});
}
continue;
}
// Don't save stealth tool invocations
if (isStealth) {
result.stealthCalls.push(name);
continue;
}
const invocation = {
id,
displayName,
name,
parameters: stringify(parameters),
result: toolResult,
error: false,
signature: toolCall.signature || null,
reasoning: reasoningText || null,
};
result.invocations.push(invocation);
}
return result;
}
/**
* Groups tool names by count.
* @param {string[]} toolNames Tool names
* @returns {string} Grouped tool names
*/
static #groupToolNames(toolNames) {
const toolCounts = toolNames.reduce((acc, name) => {
acc[name] = (acc[name] || 0) + 1;
return acc;
}, {});
return Object.entries(toolCounts).map(([name, count]) => count > 1 ? `${name} (${count})` : name).join(', ');
}
/**
* Formats a message with tool invocations.
* @param {ToolInvocation[]} invocations Tool invocations.
* @returns {string} Formatted message with tool invocations.
*/
static #formatToolInvocationMessage(invocations) {
const data = structuredClone(invocations);
const detailsElement = document.createElement('details');
const summaryElement = document.createElement('summary');
const preElement = document.createElement('pre');
const codeElement = document.createElement('code');
codeElement.classList.add('language-json');
data.forEach(i => {
i.parameters = tryParse(i.parameters);
i.result = tryParse(i.result);
});
codeElement.textContent = JSON.stringify(data, null, 2);
const toolNames = data.map(i => i.displayName || i.name);
summaryElement.textContent = `Tool calls: ${this.#groupToolNames(toolNames)}`;
preElement.append(codeElement);
detailsElement.append(summaryElement, preElement);
return detailsElement.outerHTML;
}
/**
* Saves function tool invocations to the last user chat message extra metadata.
* @param {ToolInvocation[]} invocations Successful tool invocations
*/
static async saveFunctionToolInvocations(invocations) {
if (!Array.isArray(invocations) || invocations.length === 0) {
return;
}
const message = {
name: systemUserName,
force_avatar: system_avatar,
is_system: true,
is_user: false,
mes: ToolManager.#formatToolInvocationMessage(invocations),
extra: {
isSmallSys: true,
tool_invocations: invocations,
api: getGeneratingApi(),
model: getGeneratingModel(),
},
};
chat.push(message);
await eventSource.emit(event_types.TOOL_CALLS_PERFORMED, invocations);
addOneMessage(message);
await eventSource.emit(event_types.TOOL_CALLS_RENDERED, invocations);
await saveChatConditional();
}
/**
* Shows an error message for tool calls.
* @param {Error[]} errors Errors that occurred during tool invocation
* @returns {void}
*/
static showToolCallError(errors) {
toastr.error('An error occurred while invoking function tools. Click here for more details.', 'Tool Calling', {
onclick: () => Popup.show.text('Tool Calling Errors', DOMPurify.sanitize(errors.map(e => `${e.cause}: ${e.message}`).join('<br>'))),
timeOut: 5000,
});
}
static initToolSlashCommands() {
const toolsEnumProvider = () => ToolManager.tools.map(tool => {
const toolOpenAI = tool.toFunctionOpenAI();
return new SlashCommandEnumValue(toolOpenAI.function.name, toolOpenAI.function.description, enumTypes.enum, enumIcons.closure);
});
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'tools-list',
aliases: ['tool-list'],
helpString: 'Gets a list of all registered tools in the OpenAI function JSON format. Use the <code>return</code> argument to specify the return value type.',
returns: 'A list of all registered tools.',
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'return',
description: 'The way how you want the return value to be provided',
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: 'none',
enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
forceEnum: true,
}),
],
callback: async (args) => {
/** @type {any} */
const returnType = String(args?.return ?? 'popup-html').trim().toLowerCase();
const objectToStringFunc = (tools) => Array.isArray(tools) ? tools.map(x => x.toString()).join('\n\n') : tools.toString();
const tools = ToolManager.tools.map(tool => tool.toFunctionOpenAI());
return await slashCommandReturnHelper.doReturn(returnType ?? 'popup-html', tools ?? [], { objectToStringFunc });
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'tools-invoke',
aliases: ['tool-invoke'],
helpString: 'Invokes a registered tool by name. The <code>parameters</code> argument MUST be a JSON-serialized object.',
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'parameters',
description: 'The parameters to pass to the tool.',
typeList: [ARGUMENT_TYPE.DICTIONARY],
isRequired: true,
acceptsMultiple: false,
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The name of the tool to invoke.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
acceptsMultiple: false,
forceEnum: true,
enumProvider: toolsEnumProvider,
}),
],
callback: async (args, name) => {
const { parameters } = args;
const result = await ToolManager.invokeFunctionTool(String(name), parameters);
if (result instanceof Error) {
throw result;
}
return result;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'tools-register',
aliases: ['tool-register'],
helpString: `<div>Registers a new tool with the tool registry.</div>
<ul>
<li>The <code>parameters</code> argument MUST be a JSON-serialized object with a valid JSON schema.</li>
<li>The unnamed argument MUST be a closure that accepts the function parameters as local script variables.</li>
</ul>
<div>See <a target="_blank" href="https://json-schema.org/learn/">json-schema.org</a> and <a target="_blank" href="https://platform.openai.com/docs/guides/function-calling">OpenAI Function Calling</a> for more information.</div>
<div>Example:</div>
<pre><code>/let key=echoSchema
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The message to echo."
}
},
"required": [
"message"
]
}
||
/tools-register name=Echo description="Echoes a message. Call when the user is asking to repeat something" parameters={{var::echoSchema}} {: /echo {{var::arg.message}} :}</code></pre>`,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'name',
description: 'The name of the tool.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
acceptsMultiple: false,
}),
SlashCommandNamedArgument.fromProps({
name: 'description',
description: 'A description of what the tool does.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
acceptsMultiple: false,
}),
SlashCommandNamedArgument.fromProps({
name: 'parameters',
description: 'The parameters for the tool.',
typeList: [ARGUMENT_TYPE.DICTIONARY],
isRequired: true,
acceptsMultiple: false,
}),
SlashCommandNamedArgument.fromProps({
name: 'displayName',
description: 'The display name of the tool.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: false,
acceptsMultiple: false,
}),
SlashCommandNamedArgument.fromProps({
name: 'formatMessage',
description: 'The closure to be executed to format the tool call message. Must return a string.',
typeList: [ARGUMENT_TYPE.CLOSURE],
isRequired: true,
acceptsMultiple: false,
}),
SlashCommandNamedArgument.fromProps({
name: 'shouldRegister',
description: 'The closure to be executed to determine if the tool should be registered. Must return a boolean.',
typeList: [ARGUMENT_TYPE.CLOSURE],
isRequired: false,
acceptsMultiple: false,
}),
SlashCommandNamedArgument.fromProps({
name: 'stealth',
description: 'If true, a tool call result will not be shown in the chat and no follow-up generation will be performed.',
typeList: [ARGUMENT_TYPE.BOOLEAN],
isRequired: false,
acceptsMultiple: false,
defaultValue: String(false),
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The closure to be executed when the tool is invoked.',
typeList: [ARGUMENT_TYPE.CLOSURE],
isRequired: true,
acceptsMultiple: false,
}),
],
callback: async (args, action) => {
/**
* Converts a slash command closure to a function.
* @param {SlashCommandClosure} action Closure to convert to a function
* @param {function(any): any} convertResult Function to convert the result
* @returns {function} Function that executes the closure
*/
function closureToFunction(action, convertResult) {
return async (args) => {
const localClosure = action.getCopy();
localClosure.onProgress = () => { };
const scope = localClosure.scope;
if (typeof args === 'object' && args !== null) {
assignNestedVariables(scope, args, 'arg');
} else if (typeof args !== 'undefined') {
scope.letVariable('arg', args);
}
const result = await localClosure.execute();
return convertResult(result.pipe);
};
}
const { name, displayName, description, parameters, formatMessage, shouldRegister, stealth } = args;
if (!(action instanceof SlashCommandClosure)) {
throw new Error('The unnamed argument must be a closure.');
}
if (typeof name !== 'string' || !name) {
throw new Error('The "name" argument must be a non-empty string.');
}
if (typeof description !== 'string' || !description) {
throw new Error('The "description" argument must be a non-empty string.');
}
if (typeof parameters !== 'string' || !isJson(parameters)) {
throw new Error('The "parameters" argument must be a JSON-serialized object.');
}
if (displayName && typeof displayName !== 'string') {
throw new Error('The "displayName" argument must be a string.');
}
if (formatMessage && !(formatMessage instanceof SlashCommandClosure)) {
throw new Error('The "formatMessage" argument must be a closure.');
}
if (shouldRegister && !(shouldRegister instanceof SlashCommandClosure)) {
throw new Error('The "shouldRegister" argument must be a closure.');
}
const actionFunc = closureToFunction(action, x => x);
const formatMessageFunc = formatMessage instanceof SlashCommandClosure ? closureToFunction(formatMessage, x => String(x)) : null;
const shouldRegisterFunc = shouldRegister instanceof SlashCommandClosure ? closureToFunction(shouldRegister, x => isTrueBoolean(x)) : null;
ToolManager.registerFunctionTool({
name: String(name ?? ''),
displayName: String(displayName ?? ''),
description: String(description ?? ''),
parameters: JSON.parse(parameters ?? '{}'),
action: actionFunc,
formatMessage: formatMessageFunc,
shouldRegister: shouldRegisterFunc,
stealth: stealth && isTrueBoolean(String(stealth)),
});
return '';
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'tools-unregister',
aliases: ['tool-unregister'],
helpString: 'Unregisters a tool from the tool registry.',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The name of the tool to unregister.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
acceptsMultiple: false,
forceEnum: true,
enumProvider: toolsEnumProvider,
}),
],
callback: async (_, name) => {
if (typeof name !== 'string' || !name) {
throw new Error('The unnamed argument must be a non-empty string.');
}
ToolManager.unregisterFunctionTool(name);
return '';
},
}));
}
}