Merge pull request #3625 from SillyTavern/gemini-search Add backend-provided websearch connectors for OpenRouter and Gemini
Signed| @@ -1951,7 +1951,18 @@ | ||
| 1951 | 1951 | </span> |
| 1952 | 1952 | </div> |
| 1953 | 1953 | </div> |
| 1954 | 1954 | <div class="range-block" data-source="openai,cohere,mistralai,custom,claudemakersuite,openrouter,groq,deepseek"> |
| 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 | 1966 | <label for="openai_function_calling" class="checkbox_label flexWrap widthFreeExpand"> |
| 1956 | 1967 | <input id="openai_function_calling" type="checkbox" /> |
| 1957 | 1968 | <span data-i18n="Enable function calling">Enable function calling</span> |
| @@ -300,6 +300,7 @@ export const settingsToUpdate = { | ||
| 300 | 300 | function_calling: ['#openai_function_calling', 'function_calling', true], |
| 301 | 301 | show_thoughts: ['#openai_show_thoughts', 'show_thoughts', true], |
| 302 | 302 | reasoning_effort: ['#openai_reasoning_effort', 'reasoning_effort', false], |
| 303 | + enable_web_search: ['#openai_enable_web_search', 'enable_web_search', true], | |
| 303 | 304 | seed: ['#seed_openai', 'seed', false], |
| 304 | 305 | n: ['#n_openai', 'n', false], |
| 305 | 306 | bypass_status_check: ['#openai_bypass_status_check', 'bypass_status_check', true], |
| @@ -380,6 +381,7 @@ const default_settings = { | ||
| 380 | 381 | custom_prompt_post_processing: custom_prompt_post_processing_types.NONE, |
| 381 | 382 | show_thoughts: true, |
| 382 | 383 | reasoning_effort: 'medium', |
| 384 | + enable_web_search: false, | |
| 383 | 385 | seed: -1, |
| 384 | 386 | n: 1, |
| 385 | 387 | }; |
| @@ -459,6 +461,7 @@ const oai_settings = { | ||
| 459 | 461 | custom_prompt_post_processing: custom_prompt_post_processing_types.NONE, |
| 460 | 462 | show_thoughts: true, |
| 461 | 463 | reasoning_effort: 'medium', |
| 464 | + enable_web_search: false, | |
| 462 | 465 | seed: -1, |
| 463 | 466 | n: 1, |
| 464 | 467 | }; |
| @@ -2000,6 +2003,7 @@ async function sendOpenAIRequest(type, messages, signal) { | ||
| 2000 | 2003 | 'group_names': getGroupNames(), |
| 2001 | 2004 | 'include_reasoning': Boolean(oai_settings.show_thoughts), |
| 2002 | 2005 | 'reasoning_effort': String(oai_settings.reasoning_effort), |
| 2006 | + 'enable_web_search': Boolean(oai_settings.enable_web_search), | |
| 2003 | 2007 | }; |
| 2004 | 2008 | |
| 2005 | 2009 | if (!canMultiSwipe && ToolManager.canPerformToolCalls(type)) { |
| @@ -3222,6 +3226,7 @@ function loadOpenAISettings(data, settings) { | ||
| 3222 | 3226 | oai_settings.bypass_status_check = settings.bypass_status_check ?? default_settings.bypass_status_check; |
| 3223 | 3227 | oai_settings.show_thoughts = settings.show_thoughts ?? default_settings.show_thoughts; |
| 3224 | 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 | 3230 | oai_settings.seed = settings.seed ?? default_settings.seed; |
| 3226 | 3231 | oai_settings.n = settings.n ?? default_settings.n; |
| 3227 | 3232 | |
| @@ -3349,6 +3354,7 @@ function loadOpenAISettings(data, settings) { | ||
| 3349 | 3354 | $('#seed_openai').val(oai_settings.seed); |
| 3350 | 3355 | $('#n_openai').val(oai_settings.n); |
| 3351 | 3356 | $('#openai_show_thoughts').prop('checked', oai_settings.show_thoughts); |
| 3357 | + $('#openai_enable_web_search').prop('checked', oai_settings.enable_web_search); | |
| 3352 | 3358 | |
| 3353 | 3359 | $('#openai_reasoning_effort').val(oai_settings.reasoning_effort); |
| 3354 | 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 | 3619 | function_calling: settings.function_calling, |
| 3614 | 3620 | show_thoughts: settings.show_thoughts, |
| 3615 | 3621 | reasoning_effort: settings.reasoning_effort, |
| 3622 | + enable_web_search: settings.enable_web_search, | |
| 3616 | 3623 | seed: settings.seed, |
| 3617 | 3624 | n: settings.n, |
| 3618 | 3625 | }; |
| @@ -5572,6 +5579,11 @@ export function initOpenAI() { | ||
| 5572 | 5579 | saveSettingsDebounced(); |
| 5573 | 5580 | }); |
| 5574 | 5581 | |
| 5582 | + $('#openai_enable_web_search').on('input', function () { | |
| 5583 | + oai_settings.enable_web_search = !!$(this).prop('checked'); | |
| 5584 | + saveSettingsDebounced(); | |
| 5585 | + }); | |
| 5586 | + | |
| 5575 | 5587 | if (!CSS.supports('field-sizing', 'content')) { |
| 5576 | 5588 | $(document).on('input', '#openai_settings .autoSetHeight', function () { |
| 5577 | 5589 | resetScrollHeight($(this)); |
| @@ -137,9 +137,14 @@ async function* parseStreamData(json) { | ||
| 137 | 137 | else if (Array.isArray(json.candidates)) { |
| 138 | 138 | for (let i = 0; i < json.candidates.length; i++) { |
| 139 | 139 | const isNotPrimary = json.candidates?.[0]?.index > 0; |
| 140 | + const hasToolCalls = json?.candidates?.[0]?.content?.parts?.some(p => p?.functionCall); | |
| 140 | 141 | if (isNotPrimary || json.candidates.length === 0) { |
| 141 | 142 | return null; |
| 142 | 143 | } |
| 144 | + if (hasToolCalls) { | |
| 145 | + yield { data: json, chunk: '' }; | |
| 146 | + return; | |
| 147 | + } | |
| 143 | 148 | if (typeof json.candidates[0].content === 'object' && Array.isArray(json.candidates[i].content.parts)) { |
| 144 | 149 | for (let j = 0; j < json.candidates[i].content.parts.length; j++) { |
| 145 | 150 | if (typeof json.candidates[i].content.parts[j].text === 'string') { |
| @@ -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 | } |
| 510 | 530 | |
| 511 | 531 | /** |
| @@ -564,6 +584,7 @@ export class ToolManager { | ||
| 564 | 584 | chat_completion_sources.GROQ, |
| 565 | 585 | chat_completion_sources.COHERE, |
| 566 | 586 | chat_completion_sources.DEEPSEEK, |
| 587 | + chat_completion_sources.MAKERSUITE, | |
| 567 | 588 | ]; |
| 568 | 589 | return supportedSources.includes(oai_settings.chat_completion_source); |
| 569 | 590 | } |
| @@ -585,8 +606,11 @@ export class ToolManager { | ||
| 585 | 606 | * @returns {any[]} Tool calls from the response data |
| 586 | 607 | */ |
| 587 | 608 | static #getToolCallsFromData(data) { |
| 609 | + const getRandomId = () => Math.random().toString(36).substring(2); | |
| 588 | 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 | 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 } }); | |
| 590 | 614 | |
| 591 | 615 | // Parsed tool calls from streaming data |
| 592 | 616 | if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) { |
| @@ -594,6 +618,10 @@ export class ToolManager { | ||
| 594 | 618 | return data[0].filter(x => x).map(convertClaudeToolCall); |
| 595 | 619 | } |
| 596 | 620 | |
| 621 | + if (isGoogleToolCall(data[0])) { | |
| 622 | + return data[0].filter(x => x).map(convertGoogleToolCall); | |
| 623 | + } | |
| 624 | + | |
| 597 | 625 | if (typeof data[0]?.[0]?.tool_calls === 'object') { |
| 598 | 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 | 629 | return data[0]; |
| 602 | 630 | } |
| 603 | 631 | |
| 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 | 637 | // Parsed tool calls from non-streaming data |
| 605 | 638 | if (Array.isArray(data?.choices)) { |
| 606 | 639 | // Find a choice with 0-index |
| @@ -99,6 +99,21 @@ function getOpenRouterTransforms(request) { | ||
| 99 | 99 | } |
| 100 | 100 | |
| 101 | 101 | /** |
| 102 | + * Gets OpenRouter plugins based on the request. | |
| 103 | + * @param {import('express').Request} request | |
| 104 | + * @returns {any[]} OpenRouter plugins | |
| 105 | + */ | |
| 106 | +function 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 | 117 | * Sends a request to Claude API. |
| 103 | 118 | * @param {express.Request} request Express request |
| 104 | 119 | * @param {express.Response} response Express response |
| @@ -323,6 +338,7 @@ async function sendMakerSuiteRequest(request, response) { | ||
| 323 | 338 | |
| 324 | 339 | const model = String(request.body.model); |
| 325 | 340 | const stream = Boolean(request.body.stream); |
| 341 | + const enableWebSearch = Boolean(request.body.enable_web_search); | |
| 326 | 342 | const isThinking = model.includes('thinking'); |
| 327 | 343 | |
| 328 | 344 | const generationConfig = { |
| @@ -348,6 +364,7 @@ async function sendMakerSuiteRequest(request, response) { | ||
| 348 | 364 | model.startsWith('gemini-exp') |
| 349 | 365 | ) && request.body.use_makersuite_sysprompt; |
| 350 | 366 | |
| 367 | + const tools = []; | |
| 351 | 368 | const prompt = convertGooglePrompt(request.body.messages, model, should_use_system_prompt, getPromptNames(request)); |
| 352 | 369 | let safetySettings = GEMINI_SAFETY; |
| 353 | 370 | |
| @@ -361,6 +378,26 @@ async function sendMakerSuiteRequest(request, response) { | ||
| 361 | 378 | } |
| 362 | 379 | // Most of the other models allow for setting the threshold of filters, except for HARM_CATEGORY_CIVIC_INTEGRITY, to OFF. |
| 363 | 380 | |
| 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 | 401 | let body = { |
| 365 | 402 | contents: prompt.contents, |
| 366 | 403 | safetySettings: safetySettings, |
| @@ -371,6 +408,10 @@ async function sendMakerSuiteRequest(request, response) { | ||
| 371 | 408 | body.systemInstruction = prompt.system_instruction; |
| 372 | 409 | } |
| 373 | 410 | |
| 411 | + if (tools.length) { | |
| 412 | + body.tools = tools; | |
| 413 | + } | |
| 414 | + | |
| 374 | 415 | return body; |
| 375 | 416 | } |
| 376 | 417 | |
| @@ -426,10 +467,11 @@ async function sendMakerSuiteRequest(request, response) { | ||
| 426 | 467 | } |
| 427 | 468 | |
| 428 | 469 | const responseContent = candidates[0].content ?? candidates[0].output; |
| 470 | + const functionCall = (candidates?.[0]?.content?.parts ?? []).some(part => part.functionCall); | |
| 429 | 471 | console.warn('Google AI Studio response:', responseContent); |
| 430 | 472 | |
| 431 | 473 | const responseText = typeof responseContent === 'string' ? responseContent : responseContent?.parts?.filter(part => !part.thought)?.map(part => part.text)?.join('\n\n'); |
| 432 | 474 | if (!responseText && !functionCall) { |
| 433 | 475 | let message = 'Google AI Studio Candidate text empty'; |
| 434 | 476 | console.warn(message, generateResponseJson); |
| 435 | 477 | return response.send({ error: { message } }); |
| @@ -1014,6 +1056,7 @@ router.post('/generate', jsonParser, function (request, response) { | ||
| 1014 | 1056 | headers = { ...OPENROUTER_HEADERS }; |
| 1015 | 1057 | bodyParams = { |
| 1016 | 1058 | 'transforms': getOpenRouterTransforms(request), |
| 1059 | + 'plugins': getOpenRouterPlugins(request), | |
| 1017 | 1060 | 'include_reasoning': Boolean(request.body.include_reasoning), |
| 1018 | 1061 | }; |
| 1019 | 1062 | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import crypto from 'node:crypto'; |
| 2 | 2 | import { getConfigValue, tryParse } from './util.js'; |
| 3 | 3 | |
| 4 | 4 | const PROMPT_PLACEHOLDER = getConfigValue('promptPlaceholder', 'Let\'s get started.'); |
| 5 | 5 | |
| @@ -411,11 +411,12 @@ export function convertGooglePrompt(messages, model, useSysPrompt, names) { | ||
| 411 | 411 | } |
| 412 | 412 | |
| 413 | 413 | const system_instruction = { parts: { text: sys_prompt.trim() } }; |
| 414 | + const toolNameMap = {}; | |
| 414 | 415 | |
| 415 | 416 | const contents = []; |
| 416 | 417 | messages.forEach((message, index) => { |
| 417 | 418 | // fix the roles |
| 418 | 419 | if (message.role === 'system' || message.role === 'tool') { |
| 419 | 420 | message.role = 'user'; |
| 420 | 421 | } else if (message.role === 'assistant') { |
| 421 | 422 | message.role = 'model'; |
| @@ -423,7 +424,21 @@ export function convertGooglePrompt(messages, model, useSysPrompt, names) { | ||
| 423 | 424 | |
| 424 | 425 | // Convert the content to an array of parts |
| 425 | 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 | } |
| 428 | 443 | |
| 429 | 444 | // similar story as claude |
| @@ -455,6 +470,25 @@ export function convertGooglePrompt(messages, model, useSysPrompt, names) { | ||
| 455 | 470 | message.content.forEach((part) => { |
| 456 | 471 | if (part.type === 'text') { |
| 457 | 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 | 492 | } else if (part.type === 'image_url' && isMultimodal) { |
| 459 | 493 | const mimeType = part.image_url.url.split(';')[0].split(':')[1]; |
| 460 | 494 | const base64Data = part.image_url.url.split(',')[1]; |
| @@ -473,7 +507,7 @@ export function convertGooglePrompt(messages, model, useSysPrompt, names) { | ||
| 473 | 507 | if (part.text) { |
| 474 | 508 | contents[contents.length - 1].parts[0].text += '\n\n' + part.text; |
| 475 | 509 | } |
| 476 | 510 | if (part.inlineData || part.functionCall || part.functionResponse) { |
| 477 | 511 | contents[contents.length - 1].parts.push(part); |
| 478 | 512 | } |
| 479 | 513 | }); |