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 | * Background generation based on the provided prompt. | 2332 | * Background generation based on the provided prompt. |
| 2333 | * @param {string} quiet_prompt Instruction prompt for the AI | 2333 | * @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) mode | 2334 | * @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 prompt | 2335 | * @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 prompt | 2336 | * @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 | * @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 | export async function generateQuietPrompt(quiet_prompt, quietToLoud, skipWIAN, quietImage = null, quietName = null, responseLength = null, force_chid = null) { | 2343 | export 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 message | 3133 | * @returns {Promise<string>} Generated message |
| 3131 | */ | 3134 | */ |
| 3132 | export async function generateRaw(prompt, api, instructOverride, quietToLoud, systemPrompt, responseLength, trimNames = true, prefill = '') { | 3135 | export 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 | } |
| 3208 | 3211 | ||
| 3212 | if (options?.jsonSchema) { | ||
| 3213 | return extractJsonFromData(data, { mainApi: api }); | ||
| 3214 | } | ||
| 3215 | |||
| 3209 | // format result, exclude user prompt bias | 3216 | // 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 | } |
| 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 | * 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 type | 3371 | * @param {string} type Generation type |
| 3344 | * @param {GenerateOptions} options Generation options | 3372 | * @param {GenerateOptions} options Generation options |
| 3345 | * @param {boolean} dryRun Whether to actually generate a message or just assemble the prompt | 3373 | * @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 | */ |
| 3349 | export async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, depth = 0 } = {}, dryRun = false) { | 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 | 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 | } |
| 4494 | 4521 | ||
| @@ -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 | } |
| 4522 | 4549 | ||
| 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 | } |
| 5098 | 5131 | ||
| 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 type | 5139 | * @param {string} type Generation type |
| 5102 | * @param {object} data Generation data | 5140 | * @param {object} data Generation data |
| 5141 | * @param {AdditionalRequestOptions} [options] Additional options for the generation request | ||
| 5103 | * @returns {Promise<object>} Response data from the API | 5142 | * @returns {Promise<object>} Response data from the API |
| 5104 | * @throws {Error|object} | 5143 | * @throws {Error|object} |
| 5105 | */ | 5144 | */ |
| 5106 | export async function sendGenerationRequest(type, data) { | 5145 | export 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 | } |
| 5110 | 5149 | ||
| 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 type | 5171 | * @param {string} type Generation type |
| 5133 | * @param {object} data Generation data | 5172 | * @param {object} data Generation data |
| 5173 | * @param {AdditionalRequestOptions} [options] Additional options for the generation request | ||
| 5134 | * @returns {Promise<any>} Streaming generator | 5174 | * @returns {Promise<any>} Streaming generator |
| 5135 | */ | 5175 | */ |
| 5136 | export async function sendStreamingRequest(type, data) { | 5176 | export 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 | } |
| 5140 | 5180 | ||
| 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 | } |
| 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 | * Extracts multiswipe swipes from the response data. | 5377 | * Extracts multiswipe swipes from the response data. |
| 5286 | * @param {Object} data Response data | 5378 | * @param {Object} data Response data |
| 5287 | * @param {string} type Type of generation | 5379 | * @param {string} type Type of generation |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { getPresetManager } from './preset-manager.js'; | 1 | import { getPresetManager } from './preset-manager.js'; |
| 2 | import { extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../script.js'; | 2 | import { extractJsonFromData, extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../script.js'; |
| 3 | import { getTextGenServer } from './textgen-settings.js'; | 3 | import { getTextGenServer } from './textgen-settings.js'; |
| 4 | import { extractReasoningFromData } from './reasoning.js'; | 4 | import { extractReasoningFromData } from './reasoning.js'; |
| 5 | import { formatInstructModeChat, formatInstructModePrompt, getInstructStoppingSequences, names_behavior_types } from './instruct-mode.js'; | 5 | import { 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 | } |
| 469 | 469 | ||
| 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 | } |
| 479 | 484 | ||
| 480 | if (!response.ok) { | 485 | if (!response.ok) { |
| @@ -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} messages | 2188 | * @param {Array} messages |
| 2189 | * @param {AbortSignal?} signal | 2189 | * @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 | */ |
| 2193 | 2194 | ||
| 2194 | async function sendOpenAIRequest(type, messages, signal) { | 2195 | async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } = {}) { |
| 2195 | // Provide default abort signal | 2196 | // 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 | } |
| 2465 | 2466 | ||
| 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); |
| 2467 | 2472 | ||
| 2468 | const generate_url = '/api/backends/chat-completions/generate'; | 2473 | const generate_url = '/api/backends/chat-completions/generate'; |
| @@ -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'; |
| 56 | import { | 57 | import { |
| 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, |
| @@ -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'; |
| 22 | import { | 23 | import { |
| 23 | convertClaudeMessages, | 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 | 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'); |
| 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 | 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 | }; |
| 376 | 391 | ||
| 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 | } |
| 617 | 632 | ||
| 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 | } |
| 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 | 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 | } |
| 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 | console.debug('Cohere request:', requestBody); | 850 | console.debug('Cohere request:', requestBody); |
| 805 | 851 | ||
| 806 | const config = { | 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 | const postProcessType = String(request.body.model).endsWith('-reasoner') | 943 | const postProcessType = String(request.body.model).endsWith('-reasoner') |
| 886 | ? PROMPT_PROCESSING_TYPE.STRICT_TOOLS | 944 | ? 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 | } |
| 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 | const processedMessages = request.body.messages = convertXAIMessages(request.body.messages, getPromptNames(request)); | 1062 | const processedMessages = request.body.messages = convertXAIMessages(request.body.messages, getPromptNames(request)); |
| 994 | 1063 | ||
| 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 | } |
| 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 | 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 | } |
| 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 | 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 | } |
| 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 | 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 | } |
| 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 | 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, |
| @@ -17,7 +17,7 @@ import mime from 'mime-types'; | |||
| 17 | import { default as simpleGit } from 'simple-git'; | 17 | import { default as simpleGit } from 'simple-git'; |
| 18 | import chalk from 'chalk'; | 18 | import chalk from 'chalk'; |
| 19 | import bytes from 'bytes'; | 19 | import bytes from 'bytes'; |
| 20 | import { LOG_LEVELS } from './constants.js'; | 20 | import { LOG_LEVELS, CHAT_COMPLETION_SOURCES } from './constants.js'; |
| 21 | import { serverDirectory } from './server-directory.js'; | 21 | import { serverDirectory } from './server-directory.js'; |
| 22 | 22 | ||
| 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 | */ | ||
| 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 | } | ||