Merge pull request #3742 from bmen25124/custom_request_stream Added stream support to "custom-request"

50334890a64b14949690e40ff215c56b5bd2e7da

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

Signed
3 files changed, +194 -71Ignore whitespace
public/scripts/custom-request.js+148 -44
@@ -3,10 +3,13 @@ import { extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../sc
33import { getTextGenServer } from './textgen-settings.js';
44import { extractReasoningFromData } from './reasoning.js';
55import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types } from './instruct-mode.js';
6+import { getStreamingReply, tryParseStreamingError } from './openai.js';
7+import EventSourceStream from './sse-stream.js';
68
79// #region Type Definitions
810/**
911 * @typedef {Object} TextCompletionRequestBase
12+ * @property {boolean?} [stream=false] - Whether to stream the response
1013 * @property {number} max_tokens - Maximum number of tokens to generate
1114 * @property {string} [model] - Optional model name
1215 * @property {string} api_type - Type of API to use
@@ -17,6 +20,7 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types
1720
1821/**
1922 * @typedef {Object} TextCompletionPayloadBase
23+ * @property {boolean?} [stream=false] - Whether to stream the response
2024 * @property {string} prompt - The text prompt for completion
2125 * @property {number} max_tokens - Maximum number of tokens to generate
2226 * @property {number} max_new_tokens - Alias for max_tokens
@@ -36,6 +40,7 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types
3640
3741/**
3842 * @typedef {Object} ChatCompletionPayloadBase
43+ * @property {boolean?} [stream=false] - Whether to stream the response
3944 * @property {ChatCompletionMessage[]} messages - Array of chat messages
4045 * @property {string} [model] - Optional model name to use for completion
4146 * @property {string} chat_completion_source - Source provider for chat completion
@@ -52,10 +57,19 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types
5257 * @property {string} reasoning - Extracted reasoning.
5358 */
5459
60+/**
61+ * @typedef {Object} StreamResponse
62+ * @property {string} text - Generated text.
63+ * @property {string[]} swipes - Generated swipes
64+ * @property {Object} state - Generated state
65+ * @property {string?} [state.reasoning] - Generated reasoning
66+ * @property {string?} [state.image] - Generated image
67+ */
68+
5569// #endregion
5670
5771/**
5872 * Creates & sends a text completion request. Streaming is not supported.
5973 */
6074export class TextCompletionService {
6175 static TYPE = 'textgenerationwebui';
@@ -64,9 +78,10 @@ export class TextCompletionService {
6478 * @param {Record<string, any> & TextCompletionRequestBase & {prompt: string}} custom
6579 * @returns {TextCompletionPayload}
6680 */
6781 static createRequestData({ stream = false, prompt, max_tokens, model, api_type, api_server, temperature, min_p, ...props }) {
6882 const payload = {
6983 ...props,
84+ stream,
7085 prompt,
7186 max_tokens,
7287 max_new_tokens: max_tokens,
@@ -75,7 +90,6 @@ export class TextCompletionService {
7590 api_server: api_server ?? getTextGenServer(api_type),
7691 temperature,
7792 min_p,
78- stream: false,
7993 };
8094
8195 // Remove undefined values to avoid API errors
@@ -92,34 +106,81 @@ export class TextCompletionService {
92106 * Sends a text completion request to the specified server
93107 * @param {TextCompletionPayload} data Request data
94108 * @param {boolean?} extractData Extract message from the response. Default true
95- * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
109+ * @param {AbortSignal?} signal
110+ * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
96111 * @throws {Error}
97112 */
98113 static async sendRequest(data, extractData = true, signal = null) {
99- const response = await fetch(getGenerateUrl(this.TYPE), {
114+ if (!data.stream) {
115+ const response = await fetch(getGenerateUrl(this.TYPE), {
116+ method: 'POST',
117+ headers: getRequestHeaders(),
118+ cache: 'no-cache',
119+ body: JSON.stringify(data),
120+ signal: signal ?? new AbortController().signal,
121+ });
122+
123+ const json = await response.json();
124+ if (!response.ok || json.error) {
125+ throw json;
126+ }
127+
128+ if (!extractData) {
129+ return json;
130+ }
131+
132+ return {
133+ content: extractMessageFromData(json, this.TYPE),
134+ reasoning: extractReasoningFromData(json, {
135+ mainApi: this.TYPE,
136+ textGenType: data.api_type,
137+ ignoreShowThoughts: true,
138+ }),
139+ };
140+ }
141+
142+ const response = await fetch('/api/backends/text-completions/generate', {
100143 method: 'POST',
101144 headers: getRequestHeaders(),
102145 cache: 'no-cache',
103146 body: JSON.stringify(data),
104147 signal: signal ?? new AbortController().signal,
105148 });
106149
107- const json = await response.json();
150+ if (!response.ok) {
108- if (!response.ok || json.error) {
151+ const text = await response.text();
109- throw json;
152+ tryParseStreamingError(response, text, { quiet: true });
110- }
111153
112- if (!extractData) {
154+ throw new Error(`Got response status ${response.status}`);
113- return json;
114155 }
115156
116- return {
157+ const eventStream = new EventSourceStream();
117- content: extractMessageFromData(json, this.TYPE),
158+ response.body.pipeThrough(eventStream);
118- reasoning: extractReasoningFromData(json, {
159+ const reader = eventStream.readable.getReader();
119- mainApi: this.TYPE,
160+ return async function* streamData() {
120- textGenType: data.api_type,
161+ let text = '';
121- ignoreShowThoughts: true,
162+ const swipes = [];
122- }),
163+ const state = { reasoning: '' };
164+ while (true) {
165+ const { done, value } = await reader.read();
166+ if (done) return;
167+ if (value.data === '[DONE]') return;
168+
169+ tryParseStreamingError(response, value.data, { quiet: true });
170+
171+ let data = JSON.parse(value.data);
172+
173+ if (data?.choices?.[0]?.index > 0) {
174+ const swipeIndex = data.choices[0].index - 1;
175+ swipes[swipeIndex] = (swipes[swipeIndex] || '') + data.choices[0].text;
176+ } else {
177+ const newText = data?.choices?.[0]?.text || data?.content || '';
178+ text += newText;
179+ state.reasoning += data?.choices?.[0]?.reasoning ?? '';
180+ }
181+
182+ yield { text, swipes, state };
183+ }
123184 };
124185 }
125186
@@ -130,13 +191,15 @@ export class TextCompletionService {
130191 * @param {string?} [options.presetName] - Name of the preset to use for generation settings
131192 * @param {string?} [options.instructName] - Name of instruct preset for message formatting
132193 * @param {boolean} extractData - Whether to extract structured data from response
133- * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
194+ * @param {AbortSignal?} [signal]
195+ * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
134196 * @throws {Error}
135197 */
136198 static async processRequest(
137199 custom,
138200 options = {},
139201 extractData = true,
202+ signal = null,
140203 ) {
141204 const { presetName, instructName } = options;
142205 let requestData = { ...custom };
@@ -220,7 +283,7 @@ export class TextCompletionService {
220283 // @ts-ignore
221284 const data = this.createRequestData(requestData);
222285
223286 return await this.sendRequest(data, extractData, signal);
224287 }
225288
226289 /**
@@ -256,7 +319,7 @@ export class TextCompletionService {
256319}
257320
258321/**
259322 * Creates & sends a chat completion request. Streaming is not supported.
260323 */
261324export class ChatCompletionService {
262325 static TYPE = 'openai';
@@ -265,16 +328,16 @@ export class ChatCompletionService {
265328 * @param {ChatCompletionPayload} custom
266329 * @returns {ChatCompletionPayload}
267330 */
268331 static createRequestData({ stream = false, messages, model, chat_completion_source, max_tokens, temperature, custom_url, ...props }) {
269332 const payload = {
270333 ...props,
334+ stream,
271335 messages,
272336 model,
273337 chat_completion_source,
274338 max_tokens,
275339 temperature,
276340 custom_url,
277- stream: false,
278341 };
279342
280343 // Remove undefined values to avoid API errors
@@ -291,34 +354,74 @@ export class ChatCompletionService {
291354 * Sends a chat completion request
292355 * @param {ChatCompletionPayload} data Request data
293356 * @param {boolean?} extractData Extract message from the response. Default true
294- * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
357+ * @param {AbortSignal?} signal Abort signal
358+ * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
295359 * @throws {Error}
296360 */
297361 static async sendRequest(data, extractData = true, signal = null) {
298362 const response = await fetch('/api/backends/chat-completions/generate', {
299363 method: 'POST',
300364 headers: getRequestHeaders(),
301365 cache: 'no-cache',
302366 body: JSON.stringify(data),
303367 signal: signal ?? new AbortController().signal,
304368 });
305369
306- const json = await response.json();
370+ if (!data.stream) {
307- if (!response.ok || json.error) {
371+ const json = await response.json();
308- throw json;
372+ if (!response.ok || json.error) {
373+ throw json;
374+ }
375+
376+ if (!extractData) {
377+ return json;
378+ }
379+
380+ return {
381+ content: extractMessageFromData(json, this.TYPE),
382+ reasoning: extractReasoningFromData(json, {
383+ mainApi: this.TYPE,
384+ textGenType: data.chat_completion_source,
385+ ignoreShowThoughts: true,
386+ }),
387+ };
309388 }
310389
311390 if (!extractDataresponse.ok) {
312- return json;
391+ const text = await response.text();
392+ tryParseStreamingError(response, text, { quiet: true });
393+
394+ throw new Error(`Got response status ${response.status}`);
313395 }
314396
315- return {
397+ const eventStream = new EventSourceStream();
316- content: extractMessageFromData(json, this.TYPE),
398+ response.body.pipeThrough(eventStream);
317- reasoning: extractReasoningFromData(json, {
399+ const reader = eventStream.readable.getReader();
318- mainApi: this.TYPE,
400+ return async function* streamData() {
319- textGenType: data.chat_completion_source,
401+ let text = '';
320- ignoreShowThoughts: true,
402+ const swipes = [];
321- }),
403+ const state = { reasoning: '', image: '' };
404+ while (true) {
405+ const { done, value } = await reader.read();
406+ if (done) return;
407+ const rawData = value.data;
408+ if (rawData === '[DONE]') return;
409+ tryParseStreamingError(response, rawData, { quiet: true });
410+ const parsed = JSON.parse(rawData);
411+
412+ const reply = getStreamingReply(parsed, state, {
413+ chatCompletionSource: data.chat_completion_source,
414+ overrideShowThoughts: true,
415+ });
416+ if (Array.isArray(parsed?.choices) && parsed?.choices?.[0]?.index > 0) {
417+ const swipeIndex = parsed.choices[0].index - 1;
418+ swipes[swipeIndex] = (swipes[swipeIndex] || '') + reply;
419+ } else {
420+ text += reply;
421+ }
422+
423+ yield { text, swipes: swipes, state };
424+ }
322425 };
323426 }
324427
@@ -327,11 +430,12 @@ export class ChatCompletionService {
327430 * @param {ChatCompletionPayload} custom
328431 * @param {Object} options - Configuration options
329432 * @param {string?} [options.presetName] - Name of the preset to use for generation settings
330433 * @param {boolean} [extractData=true] - Whether to extract structured data from response
331- * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
434+ * @param {AbortSignal?} [signal] - Abort signal
435+ * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
332436 * @throws {Error}
333437 */
334438 static async processRequest(custom, options, extractData = true, signal = null) {
335439 const { presetName } = options;
336440 let requestData = { ...custom };
337441
@@ -354,7 +458,7 @@ export class ChatCompletionService {
354458
355459 const data = this.createRequestData(requestData);
356460
357461 return await this.sendRequest(data, extractData, signal);
358462 }
359463
360464 /**
public/scripts/extensions/shared.js+10 -6
@@ -276,10 +276,12 @@ export async function getWebLlmContextSize() {
276276}
277277
278278/**
279279 * It uses the profiles to send a generate request to the API. Doesn't support streaming.
280280 */
281281export class ConnectionManagerRequestService {
282282 static defaultSendRequestParams = {
283+ stream: false,
284+ signal: null,
283285 extractData: true,
284286 includePreset: true,
285287 includeInstruct: true,
@@ -296,11 +298,11 @@ export class ConnectionManagerRequestService {
296298 * @param {string} profileId
297299 * @param {string | (import('../custom-request.js').ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt
298300 * @param {number} maxTokens
299301 * @param {{stream?: boolean, signal?: AbortSignal, extractData?: boolean, includePreset?: boolean, includeInstruct?: boolean}} custom - default values are true
300302 * @returns {Promise<import('../custom-request.js').ExtractedData | any(() => AsyncGenerator<import('../custom-request.js').StreamResponse>)>} ExtractedIf not streaming, returns extracted data; orif thestreaming, rawreturns responsea function that creates an AsyncGenerator
301303 */
302304 static async sendRequest(profileId, prompt, maxTokens, custom = this.defaultSendRequestParams) {
303305 const { stream, signal, extractData, includePreset, includeInstruct } = { ...this.defaultSendRequestParams, ...custom };
304306
305307 const context = SillyTavern.getContext();
306308 if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
@@ -319,6 +321,7 @@ export class ConnectionManagerRequestService {
319321
320322 const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }];
321323 return await context.ChatCompletionService.processRequest({
324+ stream,
322325 messages,
323326 max_tokens: maxTokens,
324327 model: profile.model,
@@ -326,7 +329,7 @@ export class ConnectionManagerRequestService {
326329 custom_url: profile['api-url'],
327330 }, {
328331 presetName: includePreset ? profile.preset : undefined,
329332 }, extractData, signal);
330333 }
331334 case 'textgenerationwebui': {
332335 if (!selectedApiMap.type) {
@@ -334,6 +337,7 @@ export class ConnectionManagerRequestService {
334337 }
335338
336339 return await context.TextCompletionService.processRequest({
340+ stream,
337341 prompt,
338342 max_tokens: maxTokens,
339343 model: profile.model,
@@ -342,7 +346,7 @@ export class ConnectionManagerRequestService {
342346 }, {
343347 instructName: includeInstruct ? profile.instruct : undefined,
344348 presetName: includePreset ? profile.preset : undefined,
345349 }, extractData, signal);
346350 }
347351 default: {
348352 throw new Error(`Unknown API type ${selectedApiMap.selected}`);
public/scripts/openai.js+36 -21
@@ -1444,8 +1444,10 @@ export async function prepareOpenAIMessages({
14441444 * Handles errors during streaming requests.
14451445 * @param {Response} response
14461446 * @param {string} decoded - response text or decoded stream data
1447+ * @param {object} [options]
1448+ * @param {boolean?} [options.quiet=false] Suppress toast messages
14471449 */
14481450export function tryParseStreamingError(response, decoded, { quiet = false } = {}) {
14491451 try {
14501452 const data = JSON.parse(decoded);
14511453
@@ -1453,19 +1455,19 @@ function tryParseStreamingError(response, decoded) {
14531455 return;
14541456 }
14551457
14561458 checkQuotaError(data, { quiet });
14571459 checkModerationError(data, { quiet });
14581460
14591461 // these do not throw correctly (equiv to Error("[object Object]"))
14601462 // if trying to fix "[object Object]" displayed to users, start here
14611463
14621464 if (data.error) {
14631465 !quiet && toastr.error(data.error.message || response.statusText, 'Chat Completion API');
14641466 throw new Error(data);
14651467 }
14661468
14671469 if (data.message) {
14681470 !quiet && toastr.error(data.message, 'Chat Completion API');
14691471 throw new Error(data);
14701472 }
14711473 }
@@ -1477,16 +1479,18 @@ function tryParseStreamingError(response, decoded) {
14771479/**
14781480 * Checks if the response contains a quota error and displays a popup if it does.
14791481 * @param data
1482+ * @param {object} [options]
1483+ * @param {boolean?} [options.quiet=false] Suppress toast messages
14801484 * @returns {void}
14811485 * @throws {object} - response JSON
14821486 */
14831487function checkQuotaError(data, { quiet = false } = {}) {
14841488 if (!data) {
14851489 return;
14861490 }
14871491
14881492 if (data.quota_error) {
14891493 !quiet && renderTemplateAsync('quotaError').then((html) => Popup.show.text('Quota Error', html));
14901494
14911495 // this does not throw correctly (equiv to Error("[object Object]"))
14921496 // if trying to fix "[object Object]" displayed to users, start here
@@ -1494,9 +1498,14 @@ function checkQuotaError(data) {
14941498 }
14951499}
14961500
1497-function checkModerationError(data) {
1501+/**
1502+ * @param {any} data
1503+ * @param {object} [options]
1504+ * @param {boolean?} [options.quiet=false] Suppress toast messages
1505+ */
1506+function checkModerationError(data, { quiet = false } = {}) {
14981507 const moderationError = data?.error?.message?.includes('requires moderation');
14991508 if (moderationError && !quiet) {
15001509 const moderationReason = `Reasons: ${data?.error?.metadata?.reasons?.join(', ') ?? '(N/A)'}`;
15011510 const flaggedText = data?.error?.metadata?.flagged_input ?? '(N/A)';
15021511 toastr.info(flaggedText, moderationReason, { timeOut: 10000 });
@@ -2255,37 +2264,43 @@ async function sendOpenAIRequest(type, messages, signal) {
22552264 * Extracts the reply from the response data from a chat completions-like source
22562265 * @param {object} data Response data from the chat completions-like source
22572266 * @param {object} state Additional state to keep track of
2267+ * @param {object} [options] Additional options
2268+ * @param {string?} [options.chatCompletionSource] Chat completion source
2269+ * @param {boolean?} [options.overrideShowThoughts] Override show thoughts
22582270 * @returns {string} The reply extracted from the response data
22592271 */
2260-function getStreamingReply(data, state) {
2272+export function getStreamingReply(data, state, { chatCompletionSource = null, overrideShowThoughts = null } = {}) {
2261- if (oai_settings.chat_completion_source === chat_completion_sources.CLAUDE) {
2273+ const chat_completion_source = chatCompletionSource ?? oai_settings.chat_completion_source;
2262- if (oai_settings.show_thoughts) {
2274+ const show_thoughts = overrideShowThoughts ?? oai_settings.show_thoughts;
2275+
2276+ if (chat_completion_source === chat_completion_sources.CLAUDE) {
2277+ if (show_thoughts) {
22632278 state.reasoning += data?.delta?.thinking || '';
22642279 }
22652280 return data?.delta?.text || '';
22662281 } else if (oai_settings.chat_completion_source === chat_completion_sources.MAKERSUITE) {
22672282 const inlineData = data?.candidates?.[0]?.content?.parts?.find(x => x.inlineData)?.inlineData;
22682283 if (inlineData) {
22692284 state.image = `data:${inlineData.mimeType};base64,${inlineData.data}`;
22702285 }
22712286 if (oai_settings.show_thoughts) {
22722287 state.reasoning += (data?.candidates?.[0]?.content?.parts?.filter(x => x.thought)?.map(x => x.text)?.[0] || '');
22732288 }
22742289 return data?.candidates?.[0]?.content?.parts?.filter(x => !x.thought)?.map(x => x.text)?.[0] || '';
22752290 } else if (oai_settings.chat_completion_source === chat_completion_sources.COHERE) {
22762291 return data?.delta?.message?.content?.text || data?.delta?.message?.tool_plan || '';
22772292 } else if (oai_settings.chat_completion_source === chat_completion_sources.DEEPSEEK) {
22782293 if (oai_settings.show_thoughts) {
22792294 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content || '');
22802295 }
22812296 return data.choices?.[0]?.delta?.content || '';
22822297 } else if (oai_settings.chat_completion_source === chat_completion_sources.OPENROUTER) {
22832298 if (oai_settings.show_thoughts) {
22842299 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
22852300 }
22862301 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
22872302 } else if (oai_settings.chat_completion_source === chat_completion_sources.CUSTOM) {
22882303 if (oai_settings.show_thoughts) {
22892304 state.reasoning +=
22902305 data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??
22912306 data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning ??