Merge pull request #3625 from SillyTavern/gemini-search Add backend-provided websearch connectors for OpenRouter and Gemini

bcb2096020a23731b16fa9201527aa50b83ab484

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
6 files changed, +144 -6Showing whitespace changes
public/index.html+12 -1
@@ -1951,7 +1951,18 @@
1951 </span>1951 </span>
1952 </div>1952 </div>
1953 </div>1953 </div>
1954 <div class="range-block" data-source="openai,cohere,mistralai,custom,claude,openrouter,groq,deepseek">1954 <div class="range-block" data-source="makersuite,openrouter">
1955 <label for="openai_enable_web_search" class="checkbox_label flexWrap widthFreeExpand">
1956 <input id="openai_enable_web_search" type="checkbox" />
1957 <span data-i18n="Enable web search">Enable web search</span>
1958 </label>
1959 <div class="flexBasis100p toggle-description justifyLeft">
1960 <span>
1961 Use search capabilities provided by the backend.
1962 </span>
1963 </div>
1964 </div>
1965 <div class="range-block" data-source="openai,cohere,mistralai,custom,claude,openrouter,groq,deepseek,makersuite">
1955 <label for="openai_function_calling" class="checkbox_label flexWrap widthFreeExpand">1966 <label for="openai_function_calling" class="checkbox_label flexWrap widthFreeExpand">
1956 <input id="openai_function_calling" type="checkbox" />1967 <input id="openai_function_calling" type="checkbox" />
1957 <span data-i18n="Enable function calling">Enable function calling</span>1968 <span data-i18n="Enable function calling">Enable function calling</span>
public/scripts/openai.js+12 -0
@@ -300,6 +300,7 @@ export const settingsToUpdate = {
300 function_calling: ['#openai_function_calling', 'function_calling', true],300 function_calling: ['#openai_function_calling', 'function_calling', true],
301 show_thoughts: ['#openai_show_thoughts', 'show_thoughts', true],301 show_thoughts: ['#openai_show_thoughts', 'show_thoughts', true],
302 reasoning_effort: ['#openai_reasoning_effort', 'reasoning_effort', false],302 reasoning_effort: ['#openai_reasoning_effort', 'reasoning_effort', false],
303 enable_web_search: ['#openai_enable_web_search', 'enable_web_search', true],
303 seed: ['#seed_openai', 'seed', false],304 seed: ['#seed_openai', 'seed', false],
304 n: ['#n_openai', 'n', false],305 n: ['#n_openai', 'n', false],
305 bypass_status_check: ['#openai_bypass_status_check', 'bypass_status_check', true],306 bypass_status_check: ['#openai_bypass_status_check', 'bypass_status_check', true],
@@ -380,6 +381,7 @@ const default_settings = {
380 custom_prompt_post_processing: custom_prompt_post_processing_types.NONE,381 custom_prompt_post_processing: custom_prompt_post_processing_types.NONE,
381 show_thoughts: true,382 show_thoughts: true,
382 reasoning_effort: 'medium',383 reasoning_effort: 'medium',
384 enable_web_search: false,
383 seed: -1,385 seed: -1,
384 n: 1,386 n: 1,
385};387};
@@ -459,6 +461,7 @@ const oai_settings = {
459 custom_prompt_post_processing: custom_prompt_post_processing_types.NONE,461 custom_prompt_post_processing: custom_prompt_post_processing_types.NONE,
460 show_thoughts: true,462 show_thoughts: true,
461 reasoning_effort: 'medium',463 reasoning_effort: 'medium',
464 enable_web_search: false,
462 seed: -1,465 seed: -1,
463 n: 1,466 n: 1,
464};467};
@@ -2000,6 +2003,7 @@ async function sendOpenAIRequest(type, messages, signal) {
2000 'group_names': getGroupNames(),2003 'group_names': getGroupNames(),
2001 'include_reasoning': Boolean(oai_settings.show_thoughts),2004 'include_reasoning': Boolean(oai_settings.show_thoughts),
2002 'reasoning_effort': String(oai_settings.reasoning_effort),2005 'reasoning_effort': String(oai_settings.reasoning_effort),
2006 'enable_web_search': Boolean(oai_settings.enable_web_search),
2003 };2007 };
20042008
2005 if (!canMultiSwipe && ToolManager.canPerformToolCalls(type)) {2009 if (!canMultiSwipe && ToolManager.canPerformToolCalls(type)) {
@@ -3222,6 +3226,7 @@ function loadOpenAISettings(data, settings) {
3222 oai_settings.bypass_status_check = settings.bypass_status_check ?? default_settings.bypass_status_check;3226 oai_settings.bypass_status_check = settings.bypass_status_check ?? default_settings.bypass_status_check;
3223 oai_settings.show_thoughts = settings.show_thoughts ?? default_settings.show_thoughts;3227 oai_settings.show_thoughts = settings.show_thoughts ?? default_settings.show_thoughts;
3224 oai_settings.reasoning_effort = settings.reasoning_effort ?? default_settings.reasoning_effort;3228 oai_settings.reasoning_effort = settings.reasoning_effort ?? default_settings.reasoning_effort;
3229 oai_settings.enable_web_search = settings.enable_web_search ?? default_settings.enable_web_search;
3225 oai_settings.seed = settings.seed ?? default_settings.seed;3230 oai_settings.seed = settings.seed ?? default_settings.seed;
3226 oai_settings.n = settings.n ?? default_settings.n;3231 oai_settings.n = settings.n ?? default_settings.n;
32273232
@@ -3349,6 +3354,7 @@ function loadOpenAISettings(data, settings) {
3349 $('#seed_openai').val(oai_settings.seed);3354 $('#seed_openai').val(oai_settings.seed);
3350 $('#n_openai').val(oai_settings.n);3355 $('#n_openai').val(oai_settings.n);
3351 $('#openai_show_thoughts').prop('checked', oai_settings.show_thoughts);3356 $('#openai_show_thoughts').prop('checked', oai_settings.show_thoughts);
3357 $('#openai_enable_web_search').prop('checked', oai_settings.enable_web_search);
33523358
3353 $('#openai_reasoning_effort').val(oai_settings.reasoning_effort);3359 $('#openai_reasoning_effort').val(oai_settings.reasoning_effort);
3354 $(`#openai_reasoning_effort option[value="${oai_settings.reasoning_effort}"]`).prop('selected', true);3360 $(`#openai_reasoning_effort option[value="${oai_settings.reasoning_effort}"]`).prop('selected', true);
@@ -3613,6 +3619,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
3613 function_calling: settings.function_calling,3619 function_calling: settings.function_calling,
3614 show_thoughts: settings.show_thoughts,3620 show_thoughts: settings.show_thoughts,
3615 reasoning_effort: settings.reasoning_effort,3621 reasoning_effort: settings.reasoning_effort,
3622 enable_web_search: settings.enable_web_search,
3616 seed: settings.seed,3623 seed: settings.seed,
3617 n: settings.n,3624 n: settings.n,
3618 };3625 };
@@ -5572,6 +5579,11 @@ export function initOpenAI() {
5572 saveSettingsDebounced();5579 saveSettingsDebounced();
5573 });5580 });
55745581
5582 $('#openai_enable_web_search').on('input', function () {
5583 oai_settings.enable_web_search = !!$(this).prop('checked');
5584 saveSettingsDebounced();
5585 });
5586
5575 if (!CSS.supports('field-sizing', 'content')) {5587 if (!CSS.supports('field-sizing', 'content')) {
5576 $(document).on('input', '#openai_settings .autoSetHeight', function () {5588 $(document).on('input', '#openai_settings .autoSetHeight', function () {
5577 resetScrollHeight($(this));5589 resetScrollHeight($(this));
public/scripts/sse-stream.js+5 -0
@@ -137,9 +137,14 @@ async function* parseStreamData(json) {
137 else if (Array.isArray(json.candidates)) {137 else if (Array.isArray(json.candidates)) {
138 for (let i = 0; i < json.candidates.length; i++) {138 for (let i = 0; i < json.candidates.length; i++) {
139 const isNotPrimary = json.candidates?.[0]?.index > 0;139 const isNotPrimary = json.candidates?.[0]?.index > 0;
140 const hasToolCalls = json?.candidates?.[0]?.content?.parts?.some(p => p?.functionCall);
140 if (isNotPrimary || json.candidates.length === 0) {141 if (isNotPrimary || json.candidates.length === 0) {
141 return null;142 return null;
142 }143 }
144 if (hasToolCalls) {
145 yield { data: json, chunk: '' };
146 return;
147 }
143 if (typeof json.candidates[0].content === 'object' && Array.isArray(json.candidates[i].content.parts)) {148 if (typeof json.candidates[0].content === 'object' && Array.isArray(json.candidates[i].content.parts)) {
144 for (let j = 0; j < json.candidates[i].content.parts.length; j++) {149 for (let j = 0; j < json.candidates[i].content.parts.length; j++) {
145 if (typeof json.candidates[i].content.parts[j].text === 'string') {150 if (typeof json.candidates[i].content.parts[j].text === 'string') {
public/scripts/tool-calling.js+33 -0
@@ -506,6 +506,26 @@ export class ToolManager {
506 }506 }
507 }507 }
508 }508 }
509 if (Array.isArray(parsed?.candidates)) {
510 for (let choiceIndex = 0; choiceIndex < parsed.candidates.length; choiceIndex++) {
511 const candidate = parsed.candidates[choiceIndex];
512 if (Array.isArray(candidate?.content?.parts)) {
513 for (let toolCallIndex = 0; toolCallIndex < candidate.content.parts.length; toolCallIndex++) {
514 const part = candidate.content.parts[toolCallIndex];
515 if (part.functionCall) {
516 if (!Array.isArray(toolCalls[choiceIndex])) {
517 toolCalls[choiceIndex] = [];
518 }
519 if (toolCalls[choiceIndex][toolCallIndex] === undefined) {
520 toolCalls[choiceIndex][toolCallIndex] = {};
521 }
522 const targetToolCall = toolCalls[choiceIndex][toolCallIndex];
523 ToolManager.#applyToolCallDelta(targetToolCall, part.functionCall);
524 }
525 }
526 }
527 }
528 }
509 }529 }
510530
511 /**531 /**
@@ -564,6 +584,7 @@ export class ToolManager {
564 chat_completion_sources.GROQ,584 chat_completion_sources.GROQ,
565 chat_completion_sources.COHERE,585 chat_completion_sources.COHERE,
566 chat_completion_sources.DEEPSEEK,586 chat_completion_sources.DEEPSEEK,
587 chat_completion_sources.MAKERSUITE,
567 ];588 ];
568 return supportedSources.includes(oai_settings.chat_completion_source);589 return supportedSources.includes(oai_settings.chat_completion_source);
569 }590 }
@@ -585,8 +606,11 @@ export class ToolManager {
585 * @returns {any[]} Tool calls from the response data606 * @returns {any[]} Tool calls from the response data
586 */607 */
587 static #getToolCallsFromData(data) {608 static #getToolCallsFromData(data) {
609 const getRandomId = () => Math.random().toString(36).substring(2);
588 const isClaudeToolCall = c => Array.isArray(c) ? c.filter(x => x).every(isClaudeToolCall) : c?.input && c?.name && c?.id;610 const isClaudeToolCall = c => Array.isArray(c) ? c.filter(x => x).every(isClaudeToolCall) : c?.input && c?.name && c?.id;
611 const isGoogleToolCall = c => Array.isArray(c) ? c.filter(x => x).every(isGoogleToolCall) : c?.name && c?.args;
589 const convertClaudeToolCall = c => ({ id: c.id, function: { name: c.name, arguments: c.input } });612 const convertClaudeToolCall = c => ({ id: c.id, function: { name: c.name, arguments: c.input } });
613 const convertGoogleToolCall = (c) => ({ id: getRandomId(), function: { name: c.name, arguments: c.args } });
590614
591 // Parsed tool calls from streaming data615 // Parsed tool calls from streaming data
592 if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) {616 if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) {
@@ -594,6 +618,10 @@ export class ToolManager {
594 return data[0].filter(x => x).map(convertClaudeToolCall);618 return data[0].filter(x => x).map(convertClaudeToolCall);
595 }619 }
596620
621 if (isGoogleToolCall(data[0])) {
622 return data[0].filter(x => x).map(convertGoogleToolCall);
623 }
624
597 if (typeof data[0]?.[0]?.tool_calls === 'object') {625 if (typeof data[0]?.[0]?.tool_calls === 'object') {
598 return Array.isArray(data[0]?.[0]?.tool_calls) ? data[0][0].tool_calls : [data[0][0].tool_calls];626 return Array.isArray(data[0]?.[0]?.tool_calls) ? data[0][0].tool_calls : [data[0][0].tool_calls];
599 }627 }
@@ -601,6 +629,11 @@ export class ToolManager {
601 return data[0];629 return data[0];
602 }630 }
603631
632 // Google AI Studio tool calls
633 if (Array.isArray(data?.responseContent?.parts)) {
634 return data.responseContent.parts.filter(p => p.functionCall).map(p => convertGoogleToolCall(p.functionCall));
635 }
636
604 // Parsed tool calls from non-streaming data637 // Parsed tool calls from non-streaming data
605 if (Array.isArray(data?.choices)) {638 if (Array.isArray(data?.choices)) {
606 // Find a choice with 0-index639 // Find a choice with 0-index
src/endpoints/backends/chat-completions.js+44 -1
@@ -99,6 +99,21 @@ function getOpenRouterTransforms(request) {
99}99}
100100
101/**101/**
102 * Gets OpenRouter plugins based on the request.
103 * @param {import('express').Request} request
104 * @returns {any[]} OpenRouter plugins
105 */
106function getOpenRouterPlugins(request) {
107 const plugins = [];
108
109 if (request.body.enable_web_search) {
110 plugins.push({ 'id': 'web' });
111 }
112
113 return plugins;
114}
115
116/**
102 * Sends a request to Claude API.117 * Sends a request to Claude API.
103 * @param {express.Request} request Express request118 * @param {express.Request} request Express request
104 * @param {express.Response} response Express response119 * @param {express.Response} response Express response
@@ -323,6 +338,7 @@ async function sendMakerSuiteRequest(request, response) {
323338
324 const model = String(request.body.model);339 const model = String(request.body.model);
325 const stream = Boolean(request.body.stream);340 const stream = Boolean(request.body.stream);
341 const enableWebSearch = Boolean(request.body.enable_web_search);
326 const isThinking = model.includes('thinking');342 const isThinking = model.includes('thinking');
327343
328 const generationConfig = {344 const generationConfig = {
@@ -348,6 +364,7 @@ async function sendMakerSuiteRequest(request, response) {
348 model.startsWith('gemini-exp')364 model.startsWith('gemini-exp')
349 ) && request.body.use_makersuite_sysprompt;365 ) && request.body.use_makersuite_sysprompt;
350366
367 const tools = [];
351 const prompt = convertGooglePrompt(request.body.messages, model, should_use_system_prompt, getPromptNames(request));368 const prompt = convertGooglePrompt(request.body.messages, model, should_use_system_prompt, getPromptNames(request));
352 let safetySettings = GEMINI_SAFETY;369 let safetySettings = GEMINI_SAFETY;
353370
@@ -361,6 +378,26 @@ async function sendMakerSuiteRequest(request, response) {
361 }378 }
362 // Most of the other models allow for setting the threshold of filters, except for HARM_CATEGORY_CIVIC_INTEGRITY, to OFF.379 // Most of the other models allow for setting the threshold of filters, except for HARM_CATEGORY_CIVIC_INTEGRITY, to OFF.
363380
381 if (enableWebSearch) {
382 const searchTool = model.includes('1.5') || model.includes('1.0')
383 ? ({ google_search_retrieval: {} })
384 : ({ google_search: {} });
385 tools.push(searchTool);
386 }
387
388 if (Array.isArray(request.body.tools) && request.body.tools.length > 0) {
389 const functionDeclarations = [];
390 for (const tool of request.body.tools) {
391 if (tool.type === 'function') {
392 if (tool.function.parameters?.$schema) {
393 delete tool.function.parameters.$schema;
394 }
395 functionDeclarations.push(tool.function);
396 }
397 }
398 tools.push({ function_declarations: functionDeclarations });
399 }
400
364 let body = {401 let body = {
365 contents: prompt.contents,402 contents: prompt.contents,
366 safetySettings: safetySettings,403 safetySettings: safetySettings,
@@ -371,6 +408,10 @@ async function sendMakerSuiteRequest(request, response) {
371 body.systemInstruction = prompt.system_instruction;408 body.systemInstruction = prompt.system_instruction;
372 }409 }
373410
411 if (tools.length) {
412 body.tools = tools;
413 }
414
374 return body;415 return body;
375 }416 }
376417
@@ -426,10 +467,11 @@ async function sendMakerSuiteRequest(request, response) {
426 }467 }
427468
428 const responseContent = candidates[0].content ?? candidates[0].output;469 const responseContent = candidates[0].content ?? candidates[0].output;
470 const functionCall = (candidates?.[0]?.content?.parts ?? []).some(part => part.functionCall);
429 console.warn('Google AI Studio response:', responseContent);471 console.warn('Google AI Studio response:', responseContent);
430472
431 const responseText = typeof responseContent === 'string' ? responseContent : responseContent?.parts?.filter(part => !part.thought)?.map(part => part.text)?.join('\n\n');473 const responseText = typeof responseContent === 'string' ? responseContent : responseContent?.parts?.filter(part => !part.thought)?.map(part => part.text)?.join('\n\n');
432 if (!responseText) {474 if (!responseText && !functionCall) {
433 let message = 'Google AI Studio Candidate text empty';475 let message = 'Google AI Studio Candidate text empty';
434 console.warn(message, generateResponseJson);476 console.warn(message, generateResponseJson);
435 return response.send({ error: { message } });477 return response.send({ error: { message } });
@@ -1014,6 +1056,7 @@ router.post('/generate', jsonParser, function (request, response) {
1014 headers = { ...OPENROUTER_HEADERS };1056 headers = { ...OPENROUTER_HEADERS };
1015 bodyParams = {1057 bodyParams = {
1016 'transforms': getOpenRouterTransforms(request),1058 'transforms': getOpenRouterTransforms(request),
1059 'plugins': getOpenRouterPlugins(request),
1017 'include_reasoning': Boolean(request.body.include_reasoning),1060 'include_reasoning': Boolean(request.body.include_reasoning),
1018 };1061 };
10191062
src/prompt-converters.js+38 -4
@@ -1,5 +1,5 @@
1import crypto from 'node:crypto';1import crypto from 'node:crypto';
2import { getConfigValue } from './util.js';2import { getConfigValue, tryParse } from './util.js';
33
4const PROMPT_PLACEHOLDER = getConfigValue('promptPlaceholder', 'Let\'s get started.');4const PROMPT_PLACEHOLDER = getConfigValue('promptPlaceholder', 'Let\'s get started.');
55
@@ -411,11 +411,12 @@ export function convertGooglePrompt(messages, model, useSysPrompt, names) {
411 }411 }
412412
413 const system_instruction = { parts: { text: sys_prompt.trim() } };413 const system_instruction = { parts: { text: sys_prompt.trim() } };
414 const toolNameMap = {};
414415
415 const contents = [];416 const contents = [];
416 messages.forEach((message, index) => {417 messages.forEach((message, index) => {
417 // fix the roles418 // fix the roles
418 if (message.role === 'system') {419 if (message.role === 'system' || message.role === 'tool') {
419 message.role = 'user';420 message.role = 'user';
420 } else if (message.role === 'assistant') {421 } else if (message.role === 'assistant') {
421 message.role = 'model';422 message.role = 'model';
@@ -423,7 +424,21 @@ export function convertGooglePrompt(messages, model, useSysPrompt, names) {
423424
424 // Convert the content to an array of parts425 // Convert the content to an array of parts
425 if (!Array.isArray(message.content)) {426 if (!Array.isArray(message.content)) {
426 message.content = [{ type: 'text', text: String(message.content ?? '') }];427 const content = (() => {
428 const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
429 const hasToolCallId = typeof message.tool_call_id === 'string' && message.tool_call_id.length > 0;
430
431 if (hasToolCalls) {
432 return { type: 'tool_calls', tool_calls: message.tool_calls };
433 }
434
435 if (hasToolCallId) {
436 return { type: 'tool_call_id', tool_call_id: message.tool_call_id, content: String(message.content ?? '') };
437 }
438
439 return { type: 'text', text: String(message.content ?? '') };
440 })();
441 message.content = [content];
427 }442 }
428443
429 // similar story as claude444 // similar story as claude
@@ -455,6 +470,25 @@ export function convertGooglePrompt(messages, model, useSysPrompt, names) {
455 message.content.forEach((part) => {470 message.content.forEach((part) => {
456 if (part.type === 'text') {471 if (part.type === 'text') {
457 parts.push({ text: part.text });472 parts.push({ text: part.text });
473 } else if (part.type === 'tool_call_id') {
474 const name = toolNameMap[part.tool_call_id] ?? 'unknown';
475 parts.push({
476 functionResponse: {
477 name: name,
478 response: { name: name, content: part.content },
479 },
480 });
481 } else if (part.type === 'tool_calls') {
482 part.tool_calls.forEach((toolCall) => {
483 parts.push({
484 functionCall: {
485 name: toolCall.function.name,
486 args: tryParse(toolCall.function.arguments) ?? toolCall.function.arguments,
487 },
488 });
489
490 toolNameMap[toolCall.id] = toolCall.function.name;
491 });
458 } else if (part.type === 'image_url' && isMultimodal) {492 } else if (part.type === 'image_url' && isMultimodal) {
459 const mimeType = part.image_url.url.split(';')[0].split(':')[1];493 const mimeType = part.image_url.url.split(';')[0].split(':')[1];
460 const base64Data = part.image_url.url.split(',')[1];494 const base64Data = part.image_url.url.split(',')[1];
@@ -473,7 +507,7 @@ export function convertGooglePrompt(messages, model, useSysPrompt, names) {
473 if (part.text) {507 if (part.text) {
474 contents[contents.length - 1].parts[0].text += '\n\n' + part.text;508 contents[contents.length - 1].parts[0].text += '\n\n' + part.text;
475 }509 }
476 if (part.inlineData) {510 if (part.inlineData || part.functionCall || part.functionResponse) {
477 contents[contents.length - 1].parts.push(part);511 contents[contents.length - 1].parts.push(part);
478 }512 }
479 });513 });