Added structured output for common APIs (#4272) * Added structured output for common APIs * eslint * Added frontend impl * Type name change * Unprefix json_schema, apply review suggestions * Add schema to generateQuietPrompt, add comments * Prettify diff * Extract JSON from Claude response * Add structured gen for Mistral * Hack to support schema for DeepSeek * Hack JSON schema for AI21 * Add Groq structured gen * Add JSON mode for pollinations * Add JSON schema for perplexity * Add JSON schema for AIML * Using extractJsonFromData in custom-request, added google rules for flattenSchema * Fix response parsing * Fix Google * Fixed json parse * Expose generateRaw to getContext --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

cd176039ef0fef85c74bb3315abc535aca912fc8

bmen25124 <bmen25124@gmail.com>

Signed
6 files changed, +329 -26Ignore whitespace
public/script.js+111 -19
@@ -2330,29 +2330,31 @@ export function getStoppingStrings(isImpersonate, isContinue) {
23302330
2331/**2331/**
2332 * Background generation based on the provided prompt.2332 * Background generation based on the provided prompt.
2333 * @param {string} quiet_prompt Instruction prompt for the AI2333 * @param {string} quietPrompt Instruction prompt for the AI
2334 * @param {boolean} quietToLoud Whether the message should be sent in a foreground (loud) or background (quiet) mode2334 * @param {boolean} [quietToLoud] Whether the message should be sent in a foreground (loud) or background (quiet) mode
2335 * @param {boolean} skipWIAN whether to skip addition of World Info and Author's Note into the prompt2335 * @param {boolean} [skipWIAN] Whether to skip addition of World Info and Author's Note into the prompt
2336 * @param {string} quietImage Image to use for the quiet prompt2336 * @param {string} [quietImage] Image to use for the quiet prompt
2337 * @param {string} quietName Name to use for the quiet prompt (defaults to "System:")2337 * @param {string} [quietName] Name to use for the quiet prompt (defaults to "System:")
2338 * @param {number} [responseLength] Maximum response length. If unset, the global default value is used.2338 * @param {number} [responseLength] Maximum response length. If unset, the global default value is used.
2339 * @param {number} force_chid Character ID to use for this generation run. Works in groups only.2339 * @param {number} [forceChId] Character ID to use for this generation run. Works in groups only.
2340 * @returns2340 * @param {AdditionalRequestOptions} [options={}] Additional generation request options.
2341 * @returns {Promise<string>} Generated text. If using structured output, will contain a serialized JSON object.
2341 */2342 */
2342export async function generateQuietPrompt(quiet_prompt, quietToLoud, skipWIAN, quietImage = null, quietName = null, responseLength = null, force_chid = null) {2343export async function generateQuietPrompt(quietPrompt, quietToLoud = false, skipWIAN = false, quietImage = null, quietName = null, responseLength = null, forceChId = null, { jsonSchema } = {}) {
2343 console.log('got into genQuietPrompt');2344 console.log('got into genQuietPrompt');
2344 const responseLengthCustomized = typeof responseLength === 'number' && responseLength > 0;2345 const responseLengthCustomized = typeof responseLength === 'number' && responseLength > 0;
2345 let eventHook = () => { };2346 let eventHook = () => { };
2346 try {2347 try {
2347 /** @type {GenerateOptions} */2348 /** @type {GenerateOptions} */
2348 const options = {2349 const options = {
2349 quiet_prompt,2350 quiet_prompt: quietPrompt,
2350 quietToLoud,2351 quietToLoud,
2351 skipWIAN: skipWIAN,2352 skipWIAN: skipWIAN,
2352 force_name2: true,2353 force_name2: true,
2353 quietImage: quietImage,2354 quietImage: quietImage,
2354 quietName: quietName,2355 quietName: quietName,
2355 force_chid: force_chid,2356 force_chid: forceChId,
2357 jsonSchema: jsonSchema,
2356 };2358 };
2357 if (responseLengthCustomized) {2359 if (responseLengthCustomized) {
2358 TempResponseLength.save(main_api, responseLength);2360 TempResponseLength.save(main_api, responseLength);
@@ -3127,9 +3129,10 @@ export function createRawPrompt(prompt, api, instructOverride, quietToLoud, syst
3127 * @param {number} [responseLength] Maximum response length. If unset, the global default value is used.3129 * @param {number} [responseLength] Maximum response length. If unset, the global default value is used.
3128 * @param {boolean} [trimNames] Whether to allow trimming "{{user}}:" and "{{char}}:" from the response.3130 * @param {boolean} [trimNames] Whether to allow trimming "{{user}}:" and "{{char}}:" from the response.
3129 * @param {string} [prefill] An optional prefill for the prompt.3131 * @param {string} [prefill] An optional prefill for the prompt.
3132 * @param {AdditionalRequestOptions} [options] Additional options for generation
3130 * @returns {Promise<string>} Generated message3133 * @returns {Promise<string>} Generated message
3131 */3134 */
3132export async function generateRaw(prompt, api, instructOverride, quietToLoud, systemPrompt, responseLength, trimNames = true, prefill = '') {3135export async function generateRaw(prompt, api, instructOverride, quietToLoud, systemPrompt, responseLength, trimNames = true, prefill = '', options = {}) {
3133 if (!api) {3136 if (!api) {
3134 api = main_api;3137 api = main_api;
3135 }3138 }
@@ -3181,7 +3184,7 @@ export async function generateRaw(prompt, api, instructOverride, quietToLoud, sy
3181 if (api === 'koboldhorde') {3184 if (api === 'koboldhorde') {
3182 data = await generateHorde(prompt.toString(), generateData, abortController.signal, false);3185 data = await generateHorde(prompt.toString(), generateData, abortController.signal, false);
3183 } else if (api === 'openai') {3186 } else if (api === 'openai') {
3184 data = await sendOpenAIRequest('quiet', generateData, abortController.signal);3187 data = await sendOpenAIRequest('quiet', generateData, abortController.signal, options);
3185 } else {3188 } else {
3186 const generateUrl = getGenerateUrl(api);3189 const generateUrl = getGenerateUrl(api);
3187 const response = await fetch(generateUrl, {3190 const response = await fetch(generateUrl, {
@@ -3206,6 +3209,10 @@ export async function generateRaw(prompt, api, instructOverride, quietToLoud, sy
3206 throw new Error(data.response);3209 throw new Error(data.response);
3207 }3210 }
32083211
3212 if (options?.jsonSchema) {
3213 return extractJsonFromData(data, { mainApi: api });
3214 }
3215
3209 // format result, exclude user prompt bias3216 // format result, exclude user prompt bias
3210 const message = cleanUpMessage({3217 const message = cleanUpMessage({
3211 getMessage: extractMessageFromData(data),3218 getMessage: extractMessageFromData(data),
@@ -3338,15 +3345,35 @@ function removeLastMessage() {
3338}3345}
33393346
3340/**3347/**
3348 * @typedef {object} JsonSchema
3349 * @property {string} name Name of the schema.
3350 * @property {object} value JSON schema value.
3351 * @property {string} [description] Description of the schema.
3352 * @property {boolean} [strict] If true, the schema will be used in strict mode, meaning that only the fields defined in the schema will be allowed.
3353 *
3354 * @typedef {object} GenerateOptions
3355 * @property {boolean} [automatic_trigger] If the generation was triggered automatically (e.g. group auto mode).
3356 * @property {boolean} [force_name2] If a char name should be forced to add to the prompt's last line (Text Completion, non-Instruct only).
3357 * @property {string} [quiet_prompt] A system instruction to use for the quiet prompt.
3358 * @property {boolean} [quietToLoud] Whether the system instruction should be sent in background (quiet) or a foreground (loud) mode.
3359 * @property {boolean} [skipWIAN] Skip adding World Info and Author's Note to the prompt.
3360 * @property {number} [force_chid] Force character ID to use for the generation. Only works in groups.
3361 * @property {AbortSignal} [signal] Abort signal to cancel the generation. If not provided, will create a new AbortController.
3362 * @property {string} [quietImage] Image URL to use for the quiet prompt (defaults to empty string)
3363 * @property {string} [quietName] Name to use for the quiet prompt (defaults to "System:")
3364 * @property {number} [depth] Recursion depth for the generation. Used to prevent infinite loops in tool calls.
3365 * @property {JsonSchema} [jsonSchema] JSON schema to use for the structured generation. Usually requires a special instruction.
3366 */
3367
3368/**
3341 * MARK:Generate()3369 * MARK:Generate()
3342 * Runs a generation using the current chat context.3370 * Runs a generation using the current chat context.
3343 * @param {string} type Generation type3371 * @param {string} type Generation type
3344 * @param {GenerateOptions} options Generation options3372 * @param {GenerateOptions} options Generation options
3345 * @param {boolean} dryRun Whether to actually generate a message or just assemble the prompt3373 * @param {boolean} dryRun Whether to actually generate a message or just assemble the prompt
3346 * @returns {Promise<any>} Returns a promise that resolves when the text is done generating.3374 * @returns {Promise<any>} Returns a promise that resolves when the text is done generating.
3347 * @typedef {{automatic_trigger?: boolean, force_name2?: boolean, quiet_prompt?: string, quietToLoud?: boolean, skipWIAN?: boolean, force_chid?: number, signal?: AbortSignal, quietImage?: string, quietName?: string, depth?: number }} GenerateOptions
3348 */3375 */
3349export async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, depth = 0 } = {}, dryRun = false) {3376export async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, jsonSchema = null, depth = 0 } = {}, dryRun = false) {
3350 console.log('Generate entered');3377 console.log('Generate entered');
3351 setGenerationProgress(0);3378 setGenerationProgress(0);
3352 generation_started = new Date();3379 generation_started = new Date();
@@ -4488,7 +4515,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
4488 });4515 });
4489 }4516 }
4490 } else {4517 } else {
4491 return await sendGenerationRequest(type, generate_data);4518 return await sendGenerationRequest(type, generate_data, { jsonSchema });
4492 }4519 }
4493 }4520 }
44944521
@@ -4520,6 +4547,12 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
4520 throw new Error(data?.response);4547 throw new Error(data?.response);
4521 }4548 }
45224549
4550 if (jsonSchema) {
4551 unblockGeneration(type);
4552 generatedPromptCache = '';
4553 return extractJsonFromData(data);
4554 }
4555
4523 //const getData = await response.json();4556 //const getData = await response.json();
4524 let getMessage = extractMessageFromData(data);4557 let getMessage = extractMessageFromData(data);
4525 let title = extractTitleFromData(data);4558 let title = extractTitleFromData(data);
@@ -5097,15 +5130,21 @@ function setInContextMessages(msgInContextCount, type) {
5097}5130}
50985131
5099/**5132/**
5133 * @typedef {object} AdditionalRequestOptions
5134 * @property {JsonSchema} [jsonSchema]
5135 */
5136
5137/**
5100 * Sends a non-streaming request to the API.5138 * Sends a non-streaming request to the API.
5101 * @param {string} type Generation type5139 * @param {string} type Generation type
5102 * @param {object} data Generation data5140 * @param {object} data Generation data
5141 * @param {AdditionalRequestOptions} [options] Additional options for the generation request
5103 * @returns {Promise<object>} Response data from the API5142 * @returns {Promise<object>} Response data from the API
5104 * @throws {Error|object}5143 * @throws {Error|object}
5105 */5144 */
5106export async function sendGenerationRequest(type, data) {5145export async function sendGenerationRequest(type, data, options = {}) {
5107 if (main_api === 'openai') {5146 if (main_api === 'openai') {
5108 return await sendOpenAIRequest(type, data.prompt, abortController.signal);5147 return await sendOpenAIRequest(type, data.prompt, abortController.signal, options);
5109 }5148 }
51105149
5111 if (main_api === 'koboldhorde') {5150 if (main_api === 'koboldhorde') {
@@ -5131,16 +5170,17 @@ export async function sendGenerationRequest(type, data) {
5131 * Sends a streaming request to the API.5170 * Sends a streaming request to the API.
5132 * @param {string} type Generation type5171 * @param {string} type Generation type
5133 * @param {object} data Generation data5172 * @param {object} data Generation data
5173 * @param {AdditionalRequestOptions} [options] Additional options for the generation request
5134 * @returns {Promise<any>} Streaming generator5174 * @returns {Promise<any>} Streaming generator
5135 */5175 */
5136export async function sendStreamingRequest(type, data) {5176export async function sendStreamingRequest(type, data, options = {}) {
5137 if (abortController?.signal?.aborted) {5177 if (abortController?.signal?.aborted) {
5138 throw new Error('Generation was aborted.');5178 throw new Error('Generation was aborted.');
5139 }5179 }
51405180
5141 switch (main_api) {5181 switch (main_api) {
5142 case 'openai':5182 case 'openai':
5143 return await sendOpenAIRequest(type, data.prompt, streamingProcessor.abortController.signal);5183 return await sendOpenAIRequest(type, data.prompt, streamingProcessor.abortController.signal, options);
5144 case 'textgenerationwebui':5184 case 'textgenerationwebui':
5145 return await generateTextGenWithStreaming(data, streamingProcessor.abortController.signal);5185 return await generateTextGenWithStreaming(data, streamingProcessor.abortController.signal);
5146 case 'novel':5186 case 'novel':
@@ -5282,6 +5322,58 @@ export function extractMessageFromData(data, activeApi = null) {
5282}5322}
52835323
5284/**5324/**
5325 * Extracts JSON from the response data.
5326 * @param {object} data Response data
5327 * @returns {string} Extracted JSON string from the response data
5328 */
5329export function extractJsonFromData(data, { mainApi = null, chatCompletionSource = null } = {}) {
5330 mainApi = mainApi ?? main_api;
5331 chatCompletionSource = chatCompletionSource ?? oai_settings.chat_completion_source;
5332
5333 const tryParse = (/** @type {string} */ value) => {
5334 try {
5335 return JSON.parse(value);
5336 } catch (e) {
5337 console.debug('Failed to parse content as JSON.', e);
5338 }
5339 };
5340
5341 let result = {};
5342
5343 switch (mainApi) {
5344 case 'openai': {
5345 const text = extractMessageFromData(data, mainApi);
5346 switch (chatCompletionSource) {
5347 case chat_completion_sources.CLAUDE:
5348 result = data?.content?.find(x => x.type === 'tool_use')?.input;
5349 break;
5350 case chat_completion_sources.PERPLEXITY:
5351 result = tryParse(removeReasoningFromString(text));
5352 break;
5353 case chat_completion_sources.VERTEXAI:
5354 case chat_completion_sources.MAKERSUITE:
5355 case chat_completion_sources.DEEPSEEK:
5356 case chat_completion_sources.AI21:
5357 case chat_completion_sources.GROQ:
5358 case chat_completion_sources.POLLINATIONS:
5359 case chat_completion_sources.AIMLAPI:
5360 case chat_completion_sources.OPENAI:
5361 case chat_completion_sources.OPENROUTER:
5362 case chat_completion_sources.MISTRALAI:
5363 case chat_completion_sources.CUSTOM:
5364 case chat_completion_sources.COHERE:
5365 case chat_completion_sources.XAI:
5366 default:
5367 result = tryParse(text);
5368 break;
5369 }
5370 } break;
5371 }
5372
5373 return JSON.stringify(result ?? {});
5374}
5375
5376/**
5285 * Extracts multiswipe swipes from the response data.5377 * Extracts multiswipe swipes from the response data.
5286 * @param {Object} data Response data5378 * @param {Object} data Response data
5287 * @param {string} type Type of generation5379 * @param {string} type Type of generation
public/scripts/custom-request.js+7 -2
@@ -1,5 +1,5 @@
1import { getPresetManager } from './preset-manager.js';1import { getPresetManager } from './preset-manager.js';
2import { extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../script.js';2import { extractJsonFromData, extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../script.js';
3import { getTextGenServer } from './textgen-settings.js';3import { getTextGenServer } from './textgen-settings.js';
4import { extractReasoningFromData } from './reasoning.js';4import { extractReasoningFromData } from './reasoning.js';
5import { formatInstructModeChat, formatInstructModePrompt, getInstructStoppingSequences, names_behavior_types } from './instruct-mode.js';5import { formatInstructModeChat, formatInstructModePrompt, getInstructStoppingSequences, names_behavior_types } from './instruct-mode.js';
@@ -467,7 +467,7 @@ export class ChatCompletionService {
467 return json;467 return json;
468 }468 }
469469
470 return {470 const result = {
471 content: extractMessageFromData(json, this.TYPE),471 content: extractMessageFromData(json, this.TYPE),
472 reasoning: extractReasoningFromData(json, {472 reasoning: extractReasoningFromData(json, {
473 mainApi: this.TYPE,473 mainApi: this.TYPE,
@@ -475,6 +475,11 @@ export class ChatCompletionService {
475 ignoreShowThoughts: true,475 ignoreShowThoughts: true,
476 }),476 }),
477 };477 };
478 // Try parse JSON
479 if (data.json_schema) {
480 result.content = JSON.parse(extractJsonFromData(json, { mainApi: this.TYPE, chatCompletionSource: data.chat_completion_source }));
481 }
482 return result;
478 }483 }
479484
480 if (!response.ok) {485 if (!response.ok) {
public/scripts/openai.js+6 -1
@@ -2187,11 +2187,12 @@ function getReasoningEffort() {
2187 * @param {string} type (impersonate, quiet, continue, etc)2187 * @param {string} type (impersonate, quiet, continue, etc)
2188 * @param {Array} messages2188 * @param {Array} messages
2189 * @param {AbortSignal?} signal2189 * @param {AbortSignal?} signal
2190 * @param {import('../script.js').AdditionalRequestOptions} options
2190 * @returns {Promise<unknown>}2191 * @returns {Promise<unknown>}
2191 * @throws {Error}2192 * @throws {Error}
2192 */2193 */
21932194
2194async function sendOpenAIRequest(type, messages, signal) {2195async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } = {}) {
2195 // Provide default abort signal2196 // Provide default abort signal
2196 if (!signal) {2197 if (!signal) {
2197 signal = new AbortController().signal;2198 signal = new AbortController().signal;
@@ -2463,6 +2464,10 @@ async function sendOpenAIRequest(type, messages, signal) {
2463 }2464 }
2464 }2465 }
24652466
2467 if (jsonSchema) {
2468 generate_data.json_schema = jsonSchema;
2469 }
2470
2466 await eventSource.emit(event_types.CHAT_COMPLETION_SETTINGS_READY, generate_data);2471 await eventSource.emit(event_types.CHAT_COMPLETION_SETTINGS_READY, generate_data);
24672472
2468 const generate_url = '/api/backends/chat-completions/generate';2473 const generate_url = '/api/backends/chat-completions/generate';
public/scripts/st-context.js+2 -0
@@ -52,6 +52,7 @@ import {
52 getCharacterCardFields,52 getCharacterCardFields,
53 swipe_right,53 swipe_right,
54 swipe_left,54 swipe_left,
55 generateRaw,
55} from '../script.js';56} from '../script.js';
56import {57import {
57 extension_settings,58 extension_settings,
@@ -170,6 +171,7 @@ export function getContext() {
170 ModuleWorkerWrapper,171 ModuleWorkerWrapper,
171 getTokenizerModel,172 getTokenizerModel,
172 generateQuietPrompt,173 generateQuietPrompt,
174 generateRaw,
173 writeExtensionField,175 writeExtensionField,
174 getThumbnailUrl,176 getThumbnailUrl,
175 selectCharacterById,177 selectCharacterById,
src/endpoints/backends/chat-completions.js+140 -3
@@ -18,6 +18,7 @@ import {
18 excludeKeysByYaml,18 excludeKeysByYaml,
19 color,19 color,
20 trimTrailingSlash,20 trimTrailingSlash,
21 flattenSchema,
21} from '../../util.js';22} from '../../util.js';
22import {23import {
23 convertClaudeMessages,24 convertClaudeMessages,
@@ -173,6 +174,17 @@ async function sendClaudeRequest(request, response) {
173 }174 }
174 }175 }
175176
177 // Structured output is a forced tool
178 if (request.body.json_schema) {
179 const jsonTool = {
180 name: request.body.json_schema.name,
181 description: request.body.json_schema.description || 'Well-formed JSON object',
182 input_schema: request.body.json_schema.value,
183 };
184 requestBody.tools = [...(requestBody.tools || []), jsonTool];
185 requestBody.tool_choice = { type: 'tool', name: request.body.json_schema.name };
186 }
187
176 if (useWebSearch) {188 if (useWebSearch) {
177 const webSearchTool = [{189 const webSearchTool = [{
178 'type': 'web_search_20250305',190 'type': 'web_search_20250305',
@@ -363,6 +375,9 @@ async function sendMakerSuiteRequest(request, response) {
363 const isGemma = model.includes('gemma');375 const isGemma = model.includes('gemma');
364 const isLearnLM = model.includes('learnlm');376 const isLearnLM = model.includes('learnlm');
365377
378 const responseMimeType = request.body.responseMimeType ?? (request.body.json_schema ? 'application/json' : undefined);
379 const responseSchema = request.body.responseSchema ?? (request.body.json_schema ? request.body.json_schema.value : undefined);
380
366 const generationConfig = {381 const generationConfig = {
367 stopSequences: request.body.stop,382 stopSequences: request.body.stop,
368 candidateCount: 1,383 candidateCount: 1,
@@ -370,8 +385,8 @@ async function sendMakerSuiteRequest(request, response) {
370 temperature: request.body.temperature,385 temperature: request.body.temperature,
371 topP: request.body.top_p,386 topP: request.body.top_p,
372 topK: request.body.top_k || undefined,387 topK: request.body.top_k || undefined,
373 responseMimeType: request.body.responseMimeType,388 responseMimeType: responseMimeType,
374 responseSchema: request.body.responseSchema,389 responseSchema: responseSchema,
375 };390 };
376391
377 function getGeminiBody() {392 function getGeminiBody() {
@@ -615,12 +630,23 @@ async function sendAI21Request(request, response) {
615 return response.status(400).send({ error: true });630 return response.status(400).send({ error: true });
616 }631 }
617632
633 const bodyParams = {};
618 const controller = new AbortController();634 const controller = new AbortController();
619 console.debug(request.body.messages);
620 request.socket.removeAllListeners('close');635 request.socket.removeAllListeners('close');
621 request.socket.on('close', function () {636 request.socket.on('close', function () {
622 controller.abort();637 controller.abort();
623 });638 });
639 // Hack to support JSON schema
640 if (request.body.json_schema) {
641 bodyParams.response_format = {
642 type: 'json_object',
643 };
644 const message = {
645 role: 'user',
646 content: `JSON schema for the response:\n${JSON.stringify(request.body.json_schema.value, null, 4)}`,
647 };
648 request.body.messages.push(message);
649 }
624 const convertedPrompt = convertAI21Messages(request.body.messages, getPromptNames(request));650 const convertedPrompt = convertAI21Messages(request.body.messages, getPromptNames(request));
625 const body = {651 const body = {
626 messages: convertedPrompt,652 messages: convertedPrompt,
@@ -631,6 +657,7 @@ async function sendAI21Request(request, response) {
631 stop: request.body.stop,657 stop: request.body.stop,
632 stream: request.body.stream,658 stream: request.body.stream,
633 tools: request.body.tools,659 tools: request.body.tools,
660 ...bodyParams,
634 };661 };
635 const options = {662 const options = {
636 method: 'POST',663 method: 'POST',
@@ -711,6 +738,18 @@ async function sendMistralAIRequest(request, response) {
711 requestBody['tool_choice'] = request.body.tool_choice;738 requestBody['tool_choice'] = request.body.tool_choice;
712 }739 }
713740
741 if (request.body.json_schema) {
742 requestBody['response_format'] = {
743 type: 'json_schema',
744 json_schema: {
745 name: request.body.json_schema.name,
746 description: request.body.json_schema.description,
747 schema: request.body.json_schema.value,
748 strict: request.body.json_schema.strict ?? true,
749 },
750 };
751 }
752
714 const config = {753 const config = {
715 method: 'POST',754 method: 'POST',
716 headers: {755 headers: {
@@ -801,6 +840,13 @@ async function sendCohereRequest(request, response) {
801 requestBody.safety_mode = 'OFF';840 requestBody.safety_mode = 'OFF';
802 }841 }
803842
843 if (request.body.json_schema) {
844 requestBody.response_format = {
845 type: 'json_schema',
846 schema: request.body.json_schema.value,
847 };
848 }
849
804 console.debug('Cohere request:', requestBody);850 console.debug('Cohere request:', requestBody);
805851
806 const config = {852 const config = {
@@ -882,6 +928,18 @@ async function sendDeepSeekRequest(request, response) {
882 });928 });
883 }929 }
884930
931 // Hack to support JSON schema
932 if (request.body.json_schema) {
933 bodyParams.response_format = {
934 type: 'json_object',
935 };
936 const message = {
937 role: 'user',
938 content: `JSON schema for the response:\n${JSON.stringify(request.body.json_schema.value, null, 4)}`,
939 };
940 request.body.messages.push(message);
941 }
942
885 const postProcessType = String(request.body.model).endsWith('-reasoner')943 const postProcessType = String(request.body.model).endsWith('-reasoner')
886 ? PROMPT_PROCESSING_TYPE.STRICT_TOOLS944 ? PROMPT_PROCESSING_TYPE.STRICT_TOOLS
887 : PROMPT_PROCESSING_TYPE.SEMI_TOOLS;945 : PROMPT_PROCESSING_TYPE.SEMI_TOOLS;
@@ -990,6 +1048,17 @@ async function sendXaiRequest(request, response) {
990 };1048 };
991 }1049 }
9921050
1051 if (request.body.json_schema) {
1052 bodyParams['response_format'] = {
1053 type: 'json_schema',
1054 json_schema: {
1055 name: request.body.json_schema.name,
1056 strict: request.body.json_schema.strict ?? true,
1057 schema: request.body.json_schema.value,
1058 },
1059 };
1060 }
1061
993 const processedMessages = request.body.messages = convertXAIMessages(request.body.messages, getPromptNames(request));1062 const processedMessages = request.body.messages = convertXAIMessages(request.body.messages, getPromptNames(request));
9941063
995 const requestBody = {1064 const requestBody = {
@@ -1085,6 +1154,18 @@ async function sendAimlapiRequest(request, response) {
1085 bodyParams['reasoning_effort'] = request.body.reasoning_effort;1154 bodyParams['reasoning_effort'] = request.body.reasoning_effort;
1086 }1155 }
10871156
1157 if (request.body.json_schema) {
1158 bodyParams['response_format'] = {
1159 type: 'json_schema',
1160 json_schema: {
1161 name: request.body.json_schema.name,
1162 description: request.body.json_schema.description,
1163 schema: request.body.json_schema.value,
1164 strict: request.body.json_schema.strict ?? true,
1165 },
1166 };
1167 }
1168
1088 const requestBody = {1169 const requestBody = {
1089 'messages': request.body.messages,1170 'messages': request.body.messages,
1090 'model': request.body.model,1171 'model': request.body.model,
@@ -1405,6 +1486,10 @@ router.post('/generate', function (request, response) {
1405 getPromptNames(request));1486 getPromptNames(request));
1406 }1487 }
14071488
1489 if (request.body.json_schema?.value) {
1490 request.body.json_schema.value = flattenSchema(request.body.json_schema.value, request.body.chat_completion_source);
1491 }
1492
1408 switch (request.body.chat_completion_source) {1493 switch (request.body.chat_completion_source) {
1409 case CHAT_COMPLETION_SOURCES.CLAUDE: return sendClaudeRequest(request, response);1494 case CHAT_COMPLETION_SOURCES.CLAUDE: return sendClaudeRequest(request, response);
1410 case CHAT_COMPLETION_SOURCES.SCALE: return sendScaleRequest(request, response);1495 case CHAT_COMPLETION_SOURCES.SCALE: return sendScaleRequest(request, response);
@@ -1480,6 +1565,17 @@ router.post('/generate', function (request, response) {
1480 bodyParams['reasoning'] = { effort: request.body.reasoning_effort };1565 bodyParams['reasoning'] = { effort: request.body.reasoning_effort };
1481 }1566 }
14821567
1568 if (request.body.json_schema) {
1569 bodyParams['response_format'] = {
1570 type: 'json_schema',
1571 json_schema: {
1572 name: request.body.json_schema.name,
1573 strict: request.body.json_schema.strict ?? true,
1574 schema: request.body.json_schema.value,
1575 },
1576 };
1577 }
1578
1483 const cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1, 'number');1579 const cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1, 'number');
1484 const isClaude3or4 = /anthropic\/claude-(3|opus-4|sonnet-4)/.test(request.body.model);1580 const isClaude3or4 = /anthropic\/claude-(3|opus-4|sonnet-4)/.test(request.body.model);
1485 const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m';1581 const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m';
@@ -1516,11 +1612,30 @@ router.post('/generate', function (request, response) {
1516 reasoning_effort: request.body.reasoning_effort,1612 reasoning_effort: request.body.reasoning_effort,
1517 };1613 };
1518 request.body.messages = postProcessPrompt(request.body.messages, PROMPT_PROCESSING_TYPE.STRICT, getPromptNames(request));1614 request.body.messages = postProcessPrompt(request.body.messages, PROMPT_PROCESSING_TYPE.STRICT, getPromptNames(request));
1615 if (request.body.json_schema) {
1616 bodyParams['response_format'] = {
1617 type: 'json_schema',
1618 json_schema: {
1619 schema: request.body.json_schema.value,
1620 },
1621 };
1622 }
1519 } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.GROQ) {1623 } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.GROQ) {
1520 apiUrl = API_GROQ;1624 apiUrl = API_GROQ;
1521 apiKey = readSecret(request.user.directories, SECRET_KEYS.GROQ);1625 apiKey = readSecret(request.user.directories, SECRET_KEYS.GROQ);
1522 headers = {};1626 headers = {};
1523 bodyParams = {};1627 bodyParams = {};
1628 if (request.body.json_schema) {
1629 bodyParams['response_format'] = {
1630 type: 'json_schema',
1631 json_schema: {
1632 name: request.body.json_schema.name,
1633 description: request.body.json_schema.description,
1634 schema: request.body.json_schema.value,
1635 strict: request.body.json_schema.strict ?? true,
1636 },
1637 };
1638 }
1524 } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.NANOGPT) {1639 } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.NANOGPT) {
1525 apiUrl = API_NANOGPT;1640 apiUrl = API_NANOGPT;
1526 apiKey = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);1641 apiKey = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);
@@ -1543,6 +1658,17 @@ router.post('/generate', function (request, response) {
1543 referrer: 'sillytavern',1658 referrer: 'sillytavern',
1544 seed: request.body.seed ?? Math.floor(Math.random() * 99999999),1659 seed: request.body.seed ?? Math.floor(Math.random() * 99999999),
1545 };1660 };
1661 // Hack to support JSON schema
1662 if (request.body.json_schema) {
1663 bodyParams['response_format'] = {
1664 type: 'json_object',
1665 };
1666 const message = {
1667 role: 'user',
1668 content: `JSON schema for the response:\n${JSON.stringify(request.body.json_schema.value, null, 4)}`,
1669 };
1670 request.body.messages.push(message);
1671 }
1546 } else {1672 } else {
1547 console.warn('This chat completion source is not supported yet.');1673 console.warn('This chat completion source is not supported yet.');
1548 return response.status(400).send({ error: true });1674 return response.status(400).send({ error: true });
@@ -1581,6 +1707,17 @@ router.post('/generate', function (request, response) {
1581 bodyParams['tool_choice'] = request.body.tool_choice;1707 bodyParams['tool_choice'] = request.body.tool_choice;
1582 }1708 }
15831709
1710 if (request.body.json_schema && !bodyParams['response_format']) {
1711 bodyParams['response_format'] = {
1712 type: 'json_schema',
1713 json_schema: {
1714 name: request.body.json_schema.name,
1715 strict: request.body.json_schema.strict ?? true,
1716 schema: request.body.json_schema.value,
1717 },
1718 };
1719 }
1720
1584 const requestBody = {1721 const requestBody = {
1585 'messages': isTextCompletion === false ? request.body.messages : undefined,1722 'messages': isTextCompletion === false ? request.body.messages : undefined,
1586 'prompt': isTextCompletion === true ? textPrompt : undefined,1723 'prompt': isTextCompletion === true ? textPrompt : undefined,
src/util.js+63 -1
@@ -17,7 +17,7 @@ import mime from 'mime-types';
17import { default as simpleGit } from 'simple-git';17import { default as simpleGit } from 'simple-git';
18import chalk from 'chalk';18import chalk from 'chalk';
19import bytes from 'bytes';19import bytes from 'bytes';
20import { LOG_LEVELS } from './constants.js';20import { LOG_LEVELS, CHAT_COMPLETION_SOURCES } from './constants.js';
21import { serverDirectory } from './server-directory.js';21import { serverDirectory } from './server-directory.js';
2222
23/**23/**
@@ -1213,3 +1213,65 @@ export function getRequestURL(request) {
1213 }1213 }
1214 throw new TypeError('Invalid request type');1214 throw new TypeError('Invalid request type');
1215}1215}
1216
1217/**
1218 * Flattens a JSON schema by inlining all definitions and setting additionalProperties to false.
1219 * @param {object} schema The JSON schema to flatten.
1220 * @param {string} api The API source, used to determine how to handle certain properties.
1221 * @returns {object} The flattened schema.
1222 */
1223export function flattenSchema(schema, api) {
1224 if (!schema || typeof schema !== 'object') {
1225 return schema;
1226 }
1227
1228 // Deep clone to avoid modifying the original object.
1229 const schemaCopy = structuredClone(schema);
1230
1231 const definitions = schemaCopy.$defs || {};
1232 delete schemaCopy.$defs;
1233
1234 function replaceRefs(obj) {
1235 if (obj === null || typeof obj !== 'object') {
1236 return obj;
1237 }
1238
1239 if (Array.isArray(obj)) {
1240 for (let i = 0; i < obj.length; i++) {
1241 obj[i] = replaceRefs(obj[i]);
1242 }
1243 return obj;
1244 }
1245
1246 if (obj.$ref && typeof obj.$ref === 'string' && obj.$ref.startsWith('#/$defs/')) {
1247 const defName = obj.$ref.split('/').pop();
1248 if (definitions[defName]) {
1249 return replaceRefs(structuredClone(definitions[defName]));
1250 }
1251 }
1252
1253 if (api === CHAT_COMPLETION_SOURCES.MAKERSUITE || api === CHAT_COMPLETION_SOURCES.VERTEXAI) {
1254 delete obj.default;
1255 delete obj.additionalProperties;
1256 } else if ('properties' in obj) {
1257 if (obj.additionalProperties === undefined || obj.additionalProperties === true) {
1258 obj.additionalProperties = false;
1259 }
1260 }
1261
1262 for (const key in obj) {
1263 if (Object.prototype.hasOwnProperty.call(obj, key)) {
1264 obj[key] = replaceRefs(obj[key]);
1265 }
1266 }
1267 return obj;
1268 }
1269
1270 const flattenedSchema = replaceRefs(schemaCopy);
1271
1272 if (flattenedSchema.$schema) {
1273 delete flattenedSchema.$schema;
1274 }
1275
1276 return flattenedSchema;
1277}