Added stream support to "custom-request"

ec474f557182f624856e9d5fe252be9373d152d0

bmen25124 <bmen25124@gmail.com>

3 files changed, +192 -71Ignore whitespace
public/scripts/custom-request.js+149 -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,20 @@ 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+ * @returns {StreamResponse}
68+ */
69+
5570// #endregion
5671
5772/**
5873 * Creates & sends a text completion request. Streaming is not supported.
5974 */
6075export class TextCompletionService {
6176 static TYPE = 'textgenerationwebui';
@@ -64,9 +79,10 @@ export class TextCompletionService {
6479 * @param {Record<string, any> & TextCompletionRequestBase & {prompt: string}} custom
6580 * @returns {TextCompletionPayload}
6681 */
6782 static createRequestData({ stream = false, prompt, max_tokens, model, api_type, api_server, temperature, min_p, ...props }) {
6883 const payload = {
6984 ...props,
85+ stream,
7086 prompt,
7187 max_tokens,
7288 max_new_tokens: max_tokens,
@@ -75,7 +91,6 @@ export class TextCompletionService {
7591 api_server: api_server ?? getTextGenServer(api_type),
7692 temperature,
7793 min_p,
78- stream: false,
7994 };
8095
8196 // Remove undefined values to avoid API errors
@@ -92,34 +107,81 @@ export class TextCompletionService {
92107 * Sends a text completion request to the specified server
93108 * @param {TextCompletionPayload} data Request data
94109 * @param {boolean?} extractData Extract message from the response. Default true
95- * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
110+ * @param {AbortSignal?} signal
111+ * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
96112 * @throws {Error}
97113 */
98114 static async sendRequest(data, extractData = true, signal = null) {
99- const response = await fetch(getGenerateUrl(this.TYPE), {
115+ if (!data.stream) {
116+ const response = await fetch(getGenerateUrl(this.TYPE), {
117+ method: 'POST',
118+ headers: getRequestHeaders(),
119+ cache: 'no-cache',
120+ body: JSON.stringify(data),
121+ signal: signal ?? new AbortController().signal,
122+ });
123+
124+ const json = await response.json();
125+ if (!response.ok || json.error) {
126+ throw json;
127+ }
128+
129+ if (!extractData) {
130+ return json;
131+ }
132+
133+ return {
134+ content: extractMessageFromData(json, this.TYPE),
135+ reasoning: extractReasoningFromData(json, {
136+ mainApi: this.TYPE,
137+ textGenType: data.api_type,
138+ ignoreShowThoughts: true,
139+ }),
140+ };
141+ }
142+
143+ const response = await fetch('/api/backends/text-completions/generate', {
100144 method: 'POST',
101145 headers: getRequestHeaders(),
102146 cache: 'no-cache',
103147 body: JSON.stringify(data),
104148 signal: signal ?? new AbortController().signal,
105149 });
106150
107- const json = await response.json();
151+ if (!response.ok) {
108- if (!response.ok || json.error) {
152+ const text = await response.text();
109- throw json;
153+ tryParseStreamingError(response, text, true);
110- }
111154
112- if (!extractData) {
155+ throw new Error(`Got response status ${response.status}`);
113- return json;
114156 }
115157
116- return {
158+ const eventStream = new EventSourceStream();
117- content: extractMessageFromData(json, this.TYPE),
159+ response.body.pipeThrough(eventStream);
118- reasoning: extractReasoningFromData(json, {
160+ const reader = eventStream.readable.getReader();
119- mainApi: this.TYPE,
161+ return async function* streamData() {
120- textGenType: data.api_type,
162+ let text = '';
121- ignoreShowThoughts: true,
163+ const swipes = [];
122- }),
164+ const state = { reasoning: '' };
165+ while (true) {
166+ const { done, value } = await reader.read();
167+ if (done) return;
168+ if (value.data === '[DONE]') return;
169+
170+ tryParseStreamingError(response, value.data, true);
171+
172+ let data = JSON.parse(value.data);
173+
174+ if (data?.choices?.[0]?.index > 0) {
175+ const swipeIndex = data.choices[0].index - 1;
176+ swipes[swipeIndex] = (swipes[swipeIndex] || '') + data.choices[0].text;
177+ } else {
178+ const newText = data?.choices?.[0]?.text || data?.content || '';
179+ text += newText;
180+ state.reasoning += data?.choices?.[0]?.reasoning ?? '';
181+ }
182+
183+ yield { text, swipes, state };
184+ }
123185 };
124186 }
125187
@@ -130,13 +192,15 @@ export class TextCompletionService {
130192 * @param {string?} [options.presetName] - Name of the preset to use for generation settings
131193 * @param {string?} [options.instructName] - Name of instruct preset for message formatting
132194 * @param {boolean} extractData - Whether to extract structured data from response
133- * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
195+ * @param {AbortSignal?} [signal]
196+ * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
134197 * @throws {Error}
135198 */
136199 static async processRequest(
137200 custom,
138201 options = {},
139202 extractData = true,
203+ signal = null,
140204 ) {
141205 const { presetName, instructName } = options;
142206 let requestData = { ...custom };
@@ -220,7 +284,7 @@ export class TextCompletionService {
220284 // @ts-ignore
221285 const data = this.createRequestData(requestData);
222286
223287 return await this.sendRequest(data, extractData, signal);
224288 }
225289
226290 /**
@@ -256,7 +320,7 @@ export class TextCompletionService {
256320}
257321
258322/**
259323 * Creates & sends a chat completion request. Streaming is not supported.
260324 */
261325export class ChatCompletionService {
262326 static TYPE = 'openai';
@@ -265,16 +329,16 @@ export class ChatCompletionService {
265329 * @param {ChatCompletionPayload} custom
266330 * @returns {ChatCompletionPayload}
267331 */
268332 static createRequestData({ stream = false, messages, model, chat_completion_source, max_tokens, temperature, custom_url, ...props }) {
269333 const payload = {
270334 ...props,
335+ stream,
271336 messages,
272337 model,
273338 chat_completion_source,
274339 max_tokens,
275340 temperature,
276341 custom_url,
277- stream: false,
278342 };
279343
280344 // Remove undefined values to avoid API errors
@@ -291,34 +355,74 @@ export class ChatCompletionService {
291355 * Sends a chat completion request
292356 * @param {ChatCompletionPayload} data Request data
293357 * @param {boolean?} extractData Extract message from the response. Default true
294- * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
358+ * @param {AbortSignal?} signal Abort signal
359+ * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
295360 * @throws {Error}
296361 */
297362 static async sendRequest(data, extractData = true, signal = null) {
298363 const response = await fetch('/api/backends/chat-completions/generate', {
299364 method: 'POST',
300365 headers: getRequestHeaders(),
301366 cache: 'no-cache',
302367 body: JSON.stringify(data),
303368 signal: signal ?? new AbortController().signal,
304369 });
305370
306- const json = await response.json();
371+ if (!data.stream) {
307- if (!response.ok || json.error) {
372+ const json = await response.json();
308- throw json;
373+ if (!response.ok || json.error) {
374+ throw json;
375+ }
376+
377+ if (!extractData) {
378+ return json;
379+ }
380+
381+ return {
382+ content: extractMessageFromData(json, this.TYPE),
383+ reasoning: extractReasoningFromData(json, {
384+ mainApi: this.TYPE,
385+ textGenType: data.chat_completion_source,
386+ ignoreShowThoughts: true,
387+ }),
388+ };
309389 }
310390
311391 if (!extractDataresponse.ok) {
312- return json;
392+ const text = await response.text();
393+ tryParseStreamingError(response, text, true);
394+
395+ throw new Error(`Got response status ${response.status}`);
313396 }
314397
315- return {
398+ const eventStream = new EventSourceStream();
316- content: extractMessageFromData(json, this.TYPE),
399+ response.body.pipeThrough(eventStream);
317- reasoning: extractReasoningFromData(json, {
400+ const reader = eventStream.readable.getReader();
318- mainApi: this.TYPE,
401+ return async function* streamData() {
319- textGenType: data.chat_completion_source,
402+ let text = '';
320- ignoreShowThoughts: true,
403+ const swipes = [];
321- }),
404+ const state = { reasoning: '', image: '' };
405+ while (true) {
406+ const { done, value } = await reader.read();
407+ if (done) return;
408+ const rawData = value.data;
409+ if (rawData === '[DONE]') return;
410+ tryParseStreamingError(response, rawData, true);
411+ const parsed = JSON.parse(rawData);
412+
413+ const reply = getStreamingReply(parsed, state, {
414+ chatCompletionSource: data.chat_completion_source,
415+ ignoreShowThoughts: true,
416+ });
417+ if (Array.isArray(parsed?.choices) && parsed?.choices?.[0]?.index > 0) {
418+ const swipeIndex = parsed.choices[0].index - 1;
419+ swipes[swipeIndex] = (swipes[swipeIndex] || '') + reply;
420+ } else {
421+ text += reply;
422+ }
423+
424+ yield { text, swipes: swipes, state };
425+ }
322426 };
323427 }
324428
@@ -327,11 +431,12 @@ export class ChatCompletionService {
327431 * @param {ChatCompletionPayload} custom
328432 * @param {Object} options - Configuration options
329433 * @param {string?} [options.presetName] - Name of the preset to use for generation settings
330434 * @param {boolean} [extractData=true] - Whether to extract structured data from response
331- * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
435+ * @param {AbortSignal?} [signal] - Abort signal
436+ * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
332437 * @throws {Error}
333438 */
334439 static async processRequest(custom, options, extractData = true, signal = null) {
335440 const { presetName } = options;
336441 let requestData = { ...custom };
337442
@@ -354,7 +459,7 @@ export class ChatCompletionService {
354459
355460 const data = this.createRequestData(requestData);
356461
357462 return await this.sendRequest(data, extractData, signal);
358463 }
359464
360465 /**
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+33 -21
@@ -1444,8 +1444,9 @@ 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 {boolean?} [supressToastr=false]
14471448 */
14481449export function tryParseStreamingError(response, decoded, supressToastr = false) {
14491450 try {
14501451 const data = JSON.parse(decoded);
14511452
@@ -1453,19 +1454,19 @@ function tryParseStreamingError(response, decoded) {
14531454 return;
14541455 }
14551456
14561457 checkQuotaError(data, supressToastr);
14571458 checkModerationError(data, supressToastr);
14581459
14591460 // these do not throw correctly (equiv to Error("[object Object]"))
14601461 // if trying to fix "[object Object]" displayed to users, start here
14611462
14621463 if (data.error) {
14631464 !supressToastr && toastr.error(data.error.message || response.statusText, 'Chat Completion API');
14641465 throw new Error(data);
14651466 }
14661467
14671468 if (data.message) {
14681469 !supressToastr && toastr.error(data.message, 'Chat Completion API');
14691470 throw new Error(data);
14701471 }
14711472 }
@@ -1477,16 +1478,17 @@ function tryParseStreamingError(response, decoded) {
14771478/**
14781479 * Checks if the response contains a quota error and displays a popup if it does.
14791480 * @param data
1481+ * @param {boolean?} [supressToastr=false]
14801482 * @returns {void}
14811483 * @throws {object} - response JSON
14821484 */
14831485function checkQuotaError(data, supressToastr = false) {
14841486 if (!data) {
14851487 return;
14861488 }
14871489
14881490 if (data.quota_error) {
14891491 !supressToastr && renderTemplateAsync('quotaError').then((html) => Popup.show.text('Quota Error', html));
14901492
14911493 // this does not throw correctly (equiv to Error("[object Object]"))
14921494 // if trying to fix "[object Object]" displayed to users, start here
@@ -1494,9 +1496,13 @@ function checkQuotaError(data) {
14941496 }
14951497}
14961498
1497-function checkModerationError(data) {
1499+/**
1500+ * @param {any} data
1501+ * @param {boolean?} [supressToastr=false]
1502+ */
1503+function checkModerationError(data, supressToastr = false) {
14981504 const moderationError = data?.error?.message?.includes('requires moderation');
14991505 if (moderationError && !supressToastr) {
15001506 const moderationReason = `Reasons: ${data?.error?.metadata?.reasons?.join(', ') ?? '(N/A)'}`;
15011507 const flaggedText = data?.error?.metadata?.flagged_input ?? '(N/A)';
15021508 toastr.info(flaggedText, moderationReason, { timeOut: 10000 });
@@ -2255,37 +2261,43 @@ async function sendOpenAIRequest(type, messages, signal) {
22552261 * Extracts the reply from the response data from a chat completions-like source
22562262 * @param {object} data Response data from the chat completions-like source
22572263 * @param {object} state Additional state to keep track of
2264+ * @param {object} options Additional options
2265+ * @param {string?} [options.chatCompletionSource] Chat completion source
2266+ * @param {boolean?} [options.ignoreShowThoughts] Ignore show thoughts
22582267 * @returns {string} The reply extracted from the response data
22592268 */
2260-function getStreamingReply(data, state) {
2269+export function getStreamingReply(data, state, { chatCompletionSource = null, ignoreShowThoughts = false } = {}) {
2261- if (oai_settings.chat_completion_source === chat_completion_sources.CLAUDE) {
2270+ const chat_completion_source = chatCompletionSource ?? oai_settings.chat_completion_source;
2262- if (oai_settings.show_thoughts) {
2271+ const show_thoughts = ignoreShowThoughts ? true : oai_settings.show_thoughts;
2272+
2273+ if (chat_completion_source === chat_completion_sources.CLAUDE) {
2274+ if (show_thoughts) {
22632275 state.reasoning += data?.delta?.thinking || '';
22642276 }
22652277 return data?.delta?.text || '';
22662278 } else if (oai_settings.chat_completion_source === chat_completion_sources.MAKERSUITE) {
22672279 const inlineData = data?.candidates?.[0]?.content?.parts?.find(x => x.inlineData)?.inlineData;
22682280 if (inlineData) {
22692281 state.image = `data:${inlineData.mimeType};base64,${inlineData.data}`;
22702282 }
22712283 if (oai_settings.show_thoughts) {
22722284 state.reasoning += (data?.candidates?.[0]?.content?.parts?.filter(x => x.thought)?.map(x => x.text)?.[0] || '');
22732285 }
22742286 return data?.candidates?.[0]?.content?.parts?.filter(x => !x.thought)?.map(x => x.text)?.[0] || '';
22752287 } else if (oai_settings.chat_completion_source === chat_completion_sources.COHERE) {
22762288 return data?.delta?.message?.content?.text || data?.delta?.message?.tool_plan || '';
22772289 } else if (oai_settings.chat_completion_source === chat_completion_sources.DEEPSEEK) {
22782290 if (oai_settings.show_thoughts) {
22792291 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content || '');
22802292 }
22812293 return data.choices?.[0]?.delta?.content || '';
22822294 } else if (oai_settings.chat_completion_source === chat_completion_sources.OPENROUTER) {
22832295 if (oai_settings.show_thoughts) {
22842296 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
22852297 }
22862298 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
22872299 } else if (oai_settings.chat_completion_source === chat_completion_sources.CUSTOM) {
22882300 if (oai_settings.show_thoughts) {
22892301 state.reasoning +=
22902302 data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??
22912303 data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning ??