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>
Signed| @@ -2330,29 +2330,31 @@ export function getStoppingStrings(isImpersonate, isContinue) { | ||
| 2330 | 2330 | |
| 2331 | 2331 | /** |
| 2332 | 2332 | * Background generation based on the provided prompt. |
| 2333 | 2333 | * @param {string} quiet_promptquietPrompt Instruction prompt for the AI |
| 2334 | 2334 | * @param {boolean} [quietToLoud] Whether the message should be sent in a foreground (loud) or background (quiet) mode |
| 2335 | 2335 | * @param {boolean} [skipWIAN] whetherWhether to skip addition of World Info and Author's Note into the prompt |
| 2336 | 2336 | * @param {string} [quietImage] Image to use for the quiet prompt |
| 2337 | 2337 | * @param {string} [quietName] Name to use for the quiet prompt (defaults to "System:") |
| 2338 | 2338 | * @param {number} [responseLength] Maximum response length. If unset, the global default value is used. |
| 2339 | 2339 | * @param {number} force_chid[forceChId] Character ID to use for this generation run. Works in groups only. |
| 2340 | - * @returns | |
| 2340 | + * @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 | */ |
| 2342 | 2343 | export async function generateQuietPrompt(quiet_promptquietPrompt, quietToLoud = false, skipWIAN = false, quietImage = null, quietName = null, responseLength = null, force_chidforceChId = null, { jsonSchema } = {}) { |
| 2343 | 2344 | console.log('got into genQuietPrompt'); |
| 2344 | 2345 | const responseLengthCustomized = typeof responseLength === 'number' && responseLength > 0; |
| 2345 | 2346 | let eventHook = () => { }; |
| 2346 | 2347 | try { |
| 2347 | 2348 | /** @type {GenerateOptions} */ |
| 2348 | 2349 | const options = { |
| 2349 | 2350 | quiet_prompt: quietPrompt, |
| 2350 | 2351 | quietToLoud, |
| 2351 | 2352 | skipWIAN: skipWIAN, |
| 2352 | 2353 | force_name2: true, |
| 2353 | 2354 | quietImage: quietImage, |
| 2354 | 2355 | quietName: quietName, |
| 2355 | 2356 | force_chid: force_chidforceChId, |
| 2357 | + jsonSchema: jsonSchema, | |
| 2356 | 2358 | }; |
| 2357 | 2359 | if (responseLengthCustomized) { |
| 2358 | 2360 | TempResponseLength.save(main_api, responseLength); |
| @@ -3127,9 +3129,10 @@ export function createRawPrompt(prompt, api, instructOverride, quietToLoud, syst | ||
| 3127 | 3129 | * @param {number} [responseLength] Maximum response length. If unset, the global default value is used. |
| 3128 | 3130 | * @param {boolean} [trimNames] Whether to allow trimming "{{user}}:" and "{{char}}:" from the response. |
| 3129 | 3131 | * @param {string} [prefill] An optional prefill for the prompt. |
| 3132 | + * @param {AdditionalRequestOptions} [options] Additional options for generation | |
| 3130 | 3133 | * @returns {Promise<string>} Generated message |
| 3131 | 3134 | */ |
| 3132 | 3135 | export async function generateRaw(prompt, api, instructOverride, quietToLoud, systemPrompt, responseLength, trimNames = true, prefill = '', options = {}) { |
| 3133 | 3136 | if (!api) { |
| 3134 | 3137 | api = main_api; |
| 3135 | 3138 | } |
| @@ -3181,7 +3184,7 @@ export async function generateRaw(prompt, api, instructOverride, quietToLoud, sy | ||
| 3181 | 3184 | if (api === 'koboldhorde') { |
| 3182 | 3185 | data = await generateHorde(prompt.toString(), generateData, abortController.signal, false); |
| 3183 | 3186 | } else if (api === 'openai') { |
| 3184 | 3187 | data = await sendOpenAIRequest('quiet', generateData, abortController.signal, options); |
| 3185 | 3188 | } else { |
| 3186 | 3189 | const generateUrl = getGenerateUrl(api); |
| 3187 | 3190 | const response = await fetch(generateUrl, { |
| @@ -3206,6 +3209,10 @@ export async function generateRaw(prompt, api, instructOverride, quietToLoud, sy | ||
| 3206 | 3209 | throw new Error(data.response); |
| 3207 | 3210 | } |
| 3208 | 3211 | |
| 3212 | + if (options?.jsonSchema) { | |
| 3213 | + return extractJsonFromData(data, { mainApi: api }); | |
| 3214 | + } | |
| 3215 | + | |
| 3209 | 3216 | // format result, exclude user prompt bias |
| 3210 | 3217 | const message = cleanUpMessage({ |
| 3211 | 3218 | getMessage: extractMessageFromData(data), |
| @@ -3338,15 +3345,35 @@ function removeLastMessage() { | ||
| 3338 | 3345 | } |
| 3339 | 3346 | |
| 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 | 3369 | * MARK:Generate() |
| 3342 | 3370 | * Runs a generation using the current chat context. |
| 3343 | 3371 | * @param {string} type Generation type |
| 3344 | 3372 | * @param {GenerateOptions} options Generation options |
| 3345 | 3373 | * @param {boolean} dryRun Whether to actually generate a message or just assemble the prompt |
| 3346 | 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 | */ |
| 3349 | 3376 | export async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, jsonSchema = null, depth = 0 } = {}, dryRun = false) { |
| 3350 | 3377 | console.log('Generate entered'); |
| 3351 | 3378 | setGenerationProgress(0); |
| 3352 | 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 | 4517 | } else { |
| 4491 | 4518 | return await sendGenerationRequest(type, generate_data, { jsonSchema }); |
| 4492 | 4519 | } |
| 4493 | 4520 | } |
| 4494 | 4521 | |
| @@ -4520,6 +4547,12 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | ||
| 4520 | 4547 | throw new Error(data?.response); |
| 4521 | 4548 | } |
| 4522 | 4549 | |
| 4550 | + if (jsonSchema) { | |
| 4551 | + unblockGeneration(type); | |
| 4552 | + generatedPromptCache = ''; | |
| 4553 | + return extractJsonFromData(data); | |
| 4554 | + } | |
| 4555 | + | |
| 4523 | 4556 | //const getData = await response.json(); |
| 4524 | 4557 | let getMessage = extractMessageFromData(data); |
| 4525 | 4558 | let title = extractTitleFromData(data); |
| @@ -5097,15 +5130,21 @@ function setInContextMessages(msgInContextCount, type) { | ||
| 5097 | 5130 | } |
| 5098 | 5131 | |
| 5099 | 5132 | /** |
| 5133 | + * @typedef {object} AdditionalRequestOptions | |
| 5134 | + * @property {JsonSchema} [jsonSchema] | |
| 5135 | + */ | |
| 5136 | + | |
| 5137 | +/** | |
| 5100 | 5138 | * Sends a non-streaming request to the API. |
| 5101 | 5139 | * @param {string} type Generation type |
| 5102 | 5140 | * @param {object} data Generation data |
| 5141 | + * @param {AdditionalRequestOptions} [options] Additional options for the generation request | |
| 5103 | 5142 | * @returns {Promise<object>} Response data from the API |
| 5104 | 5143 | * @throws {Error|object} |
| 5105 | 5144 | */ |
| 5106 | 5145 | export async function sendGenerationRequest(type, data, options = {}) { |
| 5107 | 5146 | if (main_api === 'openai') { |
| 5108 | 5147 | return await sendOpenAIRequest(type, data.prompt, abortController.signal, options); |
| 5109 | 5148 | } |
| 5110 | 5149 | |
| 5111 | 5150 | if (main_api === 'koboldhorde') { |
| @@ -5131,16 +5170,17 @@ export async function sendGenerationRequest(type, data) { | ||
| 5131 | 5170 | * Sends a streaming request to the API. |
| 5132 | 5171 | * @param {string} type Generation type |
| 5133 | 5172 | * @param {object} data Generation data |
| 5173 | + * @param {AdditionalRequestOptions} [options] Additional options for the generation request | |
| 5134 | 5174 | * @returns {Promise<any>} Streaming generator |
| 5135 | 5175 | */ |
| 5136 | 5176 | export async function sendStreamingRequest(type, data, options = {}) { |
| 5137 | 5177 | if (abortController?.signal?.aborted) { |
| 5138 | 5178 | throw new Error('Generation was aborted.'); |
| 5139 | 5179 | } |
| 5140 | 5180 | |
| 5141 | 5181 | switch (main_api) { |
| 5142 | 5182 | case 'openai': |
| 5143 | 5183 | return await sendOpenAIRequest(type, data.prompt, streamingProcessor.abortController.signal, options); |
| 5144 | 5184 | case 'textgenerationwebui': |
| 5145 | 5185 | return await generateTextGenWithStreaming(data, streamingProcessor.abortController.signal); |
| 5146 | 5186 | case 'novel': |
| @@ -5282,6 +5322,58 @@ export function extractMessageFromData(data, activeApi = null) { | ||
| 5282 | 5322 | } |
| 5283 | 5323 | |
| 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 | + */ | |
| 5329 | +export 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 | 5377 | * Extracts multiswipe swipes from the response data. |
| 5286 | 5378 | * @param {Object} data Response data |
| 5287 | 5379 | * @param {string} type Type of generation |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { getPresetManager } from './preset-manager.js'; |
| 2 | 2 | import { extractJsonFromData, extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../script.js'; |
| 3 | 3 | import { getTextGenServer } from './textgen-settings.js'; |
| 4 | 4 | import { extractReasoningFromData } from './reasoning.js'; |
| 5 | 5 | import { formatInstructModeChat, formatInstructModePrompt, getInstructStoppingSequences, names_behavior_types } from './instruct-mode.js'; |
| @@ -467,7 +467,7 @@ export class ChatCompletionService { | ||
| 467 | 467 | return json; |
| 468 | 468 | } |
| 469 | 469 | |
| 470 | 470 | returnconst result = { |
| 471 | 471 | content: extractMessageFromData(json, this.TYPE), |
| 472 | 472 | reasoning: extractReasoningFromData(json, { |
| 473 | 473 | mainApi: this.TYPE, |
| @@ -475,6 +475,11 @@ export class ChatCompletionService { | ||
| 475 | 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 | } |
| 479 | 484 | |
| 480 | 485 | if (!response.ok) { |
| @@ -2187,11 +2187,12 @@ function getReasoningEffort() { | ||
| 2187 | 2187 | * @param {string} type (impersonate, quiet, continue, etc) |
| 2188 | 2188 | * @param {Array} messages |
| 2189 | 2189 | * @param {AbortSignal?} signal |
| 2190 | + * @param {import('../script.js').AdditionalRequestOptions} options | |
| 2190 | 2191 | * @returns {Promise<unknown>} |
| 2191 | 2192 | * @throws {Error} |
| 2192 | 2193 | */ |
| 2193 | 2194 | |
| 2194 | 2195 | async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } = {}) { |
| 2195 | 2196 | // Provide default abort signal |
| 2196 | 2197 | if (!signal) { |
| 2197 | 2198 | signal = new AbortController().signal; |
| @@ -2463,6 +2464,10 @@ async function sendOpenAIRequest(type, messages, signal) { | ||
| 2463 | 2464 | } |
| 2464 | 2465 | } |
| 2465 | 2466 | |
| 2467 | + if (jsonSchema) { | |
| 2468 | + generate_data.json_schema = jsonSchema; | |
| 2469 | + } | |
| 2470 | + | |
| 2466 | 2471 | await eventSource.emit(event_types.CHAT_COMPLETION_SETTINGS_READY, generate_data); |
| 2467 | 2472 | |
| 2468 | 2473 | const generate_url = '/api/backends/chat-completions/generate'; |
| @@ -52,6 +52,7 @@ import { | ||
| 52 | 52 | getCharacterCardFields, |
| 53 | 53 | swipe_right, |
| 54 | 54 | swipe_left, |
| 55 | + generateRaw, | |
| 55 | 56 | } from '../script.js'; |
| 56 | 57 | import { |
| 57 | 58 | extension_settings, |
| @@ -170,6 +171,7 @@ export function getContext() { | ||
| 170 | 171 | ModuleWorkerWrapper, |
| 171 | 172 | getTokenizerModel, |
| 172 | 173 | generateQuietPrompt, |
| 174 | + generateRaw, | |
| 173 | 175 | writeExtensionField, |
| 174 | 176 | getThumbnailUrl, |
| 175 | 177 | selectCharacterById, |
| @@ -18,6 +18,7 @@ import { | ||
| 18 | 18 | excludeKeysByYaml, |
| 19 | 19 | color, |
| 20 | 20 | trimTrailingSlash, |
| 21 | + flattenSchema, | |
| 21 | 22 | } from '../../util.js'; |
| 22 | 23 | import { |
| 23 | 24 | convertClaudeMessages, |
| @@ -173,6 +174,17 @@ async function sendClaudeRequest(request, response) { | ||
| 173 | 174 | } |
| 174 | 175 | } |
| 175 | 176 | |
| 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 | 188 | if (useWebSearch) { |
| 177 | 189 | const webSearchTool = [{ |
| 178 | 190 | 'type': 'web_search_20250305', |
| @@ -363,6 +375,9 @@ async function sendMakerSuiteRequest(request, response) { | ||
| 363 | 375 | const isGemma = model.includes('gemma'); |
| 364 | 376 | const isLearnLM = model.includes('learnlm'); |
| 365 | 377 | |
| 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 | 381 | const generationConfig = { |
| 367 | 382 | stopSequences: request.body.stop, |
| 368 | 383 | candidateCount: 1, |
| @@ -370,8 +385,8 @@ async function sendMakerSuiteRequest(request, response) { | ||
| 370 | 385 | temperature: request.body.temperature, |
| 371 | 386 | topP: request.body.top_p, |
| 372 | 387 | topK: request.body.top_k || undefined, |
| 373 | 388 | responseMimeType: request.body.responseMimeType, |
| 374 | 389 | responseSchema: request.body.responseSchema, |
| 375 | 390 | }; |
| 376 | 391 | |
| 377 | 392 | function getGeminiBody() { |
| @@ -615,12 +630,23 @@ async function sendAI21Request(request, response) { | ||
| 615 | 630 | return response.status(400).send({ error: true }); |
| 616 | 631 | } |
| 617 | 632 | |
| 633 | + const bodyParams = {}; | |
| 618 | 634 | const controller = new AbortController(); |
| 619 | - console.debug(request.body.messages); | |
| 620 | 635 | request.socket.removeAllListeners('close'); |
| 621 | 636 | request.socket.on('close', function () { |
| 622 | 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 | 650 | const convertedPrompt = convertAI21Messages(request.body.messages, getPromptNames(request)); |
| 625 | 651 | const body = { |
| 626 | 652 | messages: convertedPrompt, |
| @@ -631,6 +657,7 @@ async function sendAI21Request(request, response) { | ||
| 631 | 657 | stop: request.body.stop, |
| 632 | 658 | stream: request.body.stream, |
| 633 | 659 | tools: request.body.tools, |
| 660 | + ...bodyParams, | |
| 634 | 661 | }; |
| 635 | 662 | const options = { |
| 636 | 663 | method: 'POST', |
| @@ -711,6 +738,18 @@ async function sendMistralAIRequest(request, response) { | ||
| 711 | 738 | requestBody['tool_choice'] = request.body.tool_choice; |
| 712 | 739 | } |
| 713 | 740 | |
| 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 | 753 | const config = { |
| 715 | 754 | method: 'POST', |
| 716 | 755 | headers: { |
| @@ -801,6 +840,13 @@ async function sendCohereRequest(request, response) { | ||
| 801 | 840 | requestBody.safety_mode = 'OFF'; |
| 802 | 841 | } |
| 803 | 842 | |
| 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 | 850 | console.debug('Cohere request:', requestBody); |
| 805 | 851 | |
| 806 | 852 | const config = { |
| @@ -882,6 +928,18 @@ async function sendDeepSeekRequest(request, response) { | ||
| 882 | 928 | }); |
| 883 | 929 | } |
| 884 | 930 | |
| 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 | 943 | const postProcessType = String(request.body.model).endsWith('-reasoner') |
| 886 | 944 | ? PROMPT_PROCESSING_TYPE.STRICT_TOOLS |
| 887 | 945 | : PROMPT_PROCESSING_TYPE.SEMI_TOOLS; |
| @@ -990,6 +1048,17 @@ async function sendXaiRequest(request, response) { | ||
| 990 | 1048 | }; |
| 991 | 1049 | } |
| 992 | 1050 | |
| 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 | 1062 | const processedMessages = request.body.messages = convertXAIMessages(request.body.messages, getPromptNames(request)); |
| 994 | 1063 | |
| 995 | 1064 | const requestBody = { |
| @@ -1085,6 +1154,18 @@ async function sendAimlapiRequest(request, response) { | ||
| 1085 | 1154 | bodyParams['reasoning_effort'] = request.body.reasoning_effort; |
| 1086 | 1155 | } |
| 1087 | 1156 | |
| 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 | 1169 | const requestBody = { |
| 1089 | 1170 | 'messages': request.body.messages, |
| 1090 | 1171 | 'model': request.body.model, |
| @@ -1405,6 +1486,10 @@ router.post('/generate', function (request, response) { | ||
| 1405 | 1486 | getPromptNames(request)); |
| 1406 | 1487 | } |
| 1407 | 1488 | |
| 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 | 1493 | switch (request.body.chat_completion_source) { |
| 1409 | 1494 | case CHAT_COMPLETION_SOURCES.CLAUDE: return sendClaudeRequest(request, response); |
| 1410 | 1495 | case CHAT_COMPLETION_SOURCES.SCALE: return sendScaleRequest(request, response); |
| @@ -1480,6 +1565,17 @@ router.post('/generate', function (request, response) { | ||
| 1480 | 1565 | bodyParams['reasoning'] = { effort: request.body.reasoning_effort }; |
| 1481 | 1566 | } |
| 1482 | 1567 | |
| 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 | 1579 | const cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1, 'number'); |
| 1484 | 1580 | const isClaude3or4 = /anthropic\/claude-(3|opus-4|sonnet-4)/.test(request.body.model); |
| 1485 | 1581 | const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m'; |
| @@ -1516,11 +1612,30 @@ router.post('/generate', function (request, response) { | ||
| 1516 | 1612 | reasoning_effort: request.body.reasoning_effort, |
| 1517 | 1613 | }; |
| 1518 | 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 | 1623 | } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.GROQ) { |
| 1520 | 1624 | apiUrl = API_GROQ; |
| 1521 | 1625 | apiKey = readSecret(request.user.directories, SECRET_KEYS.GROQ); |
| 1522 | 1626 | headers = {}; |
| 1523 | 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 | 1639 | } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.NANOGPT) { |
| 1525 | 1640 | apiUrl = API_NANOGPT; |
| 1526 | 1641 | apiKey = readSecret(request.user.directories, SECRET_KEYS.NANOGPT); |
| @@ -1543,6 +1658,17 @@ router.post('/generate', function (request, response) { | ||
| 1543 | 1658 | referrer: 'sillytavern', |
| 1544 | 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 | 1672 | } else { |
| 1547 | 1673 | console.warn('This chat completion source is not supported yet.'); |
| 1548 | 1674 | return response.status(400).send({ error: true }); |
| @@ -1581,6 +1707,17 @@ router.post('/generate', function (request, response) { | ||
| 1581 | 1707 | bodyParams['tool_choice'] = request.body.tool_choice; |
| 1582 | 1708 | } |
| 1583 | 1709 | |
| 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 | 1721 | const requestBody = { |
| 1585 | 1722 | 'messages': isTextCompletion === false ? request.body.messages : undefined, |
| 1586 | 1723 | 'prompt': isTextCompletion === true ? textPrompt : undefined, |
| @@ -17,7 +17,7 @@ import mime from 'mime-types'; | ||
| 17 | 17 | import { default as simpleGit } from 'simple-git'; |
| 18 | 18 | import chalk from 'chalk'; |
| 19 | 19 | import bytes from 'bytes'; |
| 20 | 20 | import { LOG_LEVELS, CHAT_COMPLETION_SOURCES } from './constants.js'; |
| 21 | 21 | import { serverDirectory } from './server-directory.js'; |
| 22 | 22 | |
| 23 | 23 | /** |
| @@ -1213,3 +1213,65 @@ export function getRequestURL(request) { | ||
| 1213 | 1213 | } |
| 1214 | 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 | + */ | |
| 1223 | +export 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 | +} | |