char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Stores split refactor (#27240)521a64cd0

char/aliceai-t5-moe
48.3 KiB1665 linesraw
1/**
2 * ChatService - Stateless chat completion and streaming API layer
3 *
4 * Wraps the /chat/completions and /stream endpoints: request building, SSE
5 * parsing, streaming callbacks, resume/probe logic and pre-encode KV-cache
6 * warming. No reactive state; consumed by chatStore and its managers.
7 */
8
9import { getAudioInputFormat } from '../utils/audio-format';
10import { capImageDataURLSize } from '../utils/cap-img-size';
11import {
12	API_CHAT,
13	API_SLOTS,
14	API_STREAM,
15	CONTROL_ACTION,
16	HEADERS,
17	LEGACY_AGENTIC_REGEX,
18	REASONING_EFFORT_TOKENS,
19	SETTINGS_KEYS,
20	SSE_DATA_PREFIX,
21	SSE_DONE_MARKER,
22	SSE_LINE_SEPARATOR,
23	STREAM_QUERY_PARAMS,
24	STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX,
25	STREAM_VISIBILITY_KICK_MS
26} from '$lib/constants';
27import {
28	AttachmentLabel,
29	AttachmentType,
30	ContentPartType,
31	MessageRole,
32	ReasoningFormat,
33	StreamConnectionState
34} from '$lib/enums';
35import { modelsStore } from '$lib/stores/models/index.svelte';
36import { settingsStore } from '$lib/stores/settings/index.svelte';
37import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types';
38import type {
39	ApiChatCompletionToolCall,
40	ApiChatMessageContentPart,
41	ApiChatMessageData,
42	ApiStreamSession
43} from '$lib/types/api';
44import { isAbortError } from '$lib/utils/abort';
45import { ApiError } from '$lib/utils/api-fetch';
46import { getAuthHeaders, getJsonHeaders } from '$lib/utils/api-headers';
47import { formatAttachmentText } from '$lib/utils/formatters';
48import { streamIdentity } from '$lib/utils/stream-identity';
49
50interface ResumableStreamState {
51	bytesReceived: number;
52	updatedAt: number;
53
54	// model frozen at POST time, lets a reload rebuild the exact conv::model identity the
55	// server keyed the session under. null when the POST carried no explicit model
56	model?: string | null;
57}
58
59function streamStorageKey(conversationId: string): string {
60	return STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX + conversationId;
61}
62
63export class ChatService {
64	// Per-chunk localStorage writes are throttled to at most one per
65	// conversation per interval (saveStreamStateThrottled). The resume offset
66	// only needs to be roughly current: on resume the server retransmits from
67	// a line boundary and the client discards its partial line. Guaranteed
68	// immediate writes happen at stream start, at resume boundaries and when
69	// the page goes hidden or away (pagehide/visibilitychange), so a reload
70	// always finds a usable offset.
71	private static readonly STREAM_STATE_SAVE_INTERVAL_MS = 500;
72
73	private static streamStateSaveTrackers = new Map<
74		string,
75		{ lastSavedAt: number; model: string | null; pendingBytes: number | null }
76	>();
77
78	/**
79	 * Checks whether all server slots are currently idle (not processing any requests).
80	 * Queries the /slots endpoint (requires --slots flag on the server).
81	 * Returns true if all slots are idle, false if any is processing.
82	 * If the endpoint is unavailable or errors out, returns true (best-effort fallback).
83	 *
84	 * @param signal - Optional AbortSignal to cancel the request if needed
85	 * @param model - Optional model name to check slots for (required in ROUTER mode)
86	 * @returns {Promise<boolean>} Promise that resolves to true if all slots are idle, false if any is processing
87	 */
88	static async areAllSlotsIdle(model?: string | null, signal?: AbortSignal): Promise<boolean> {
89		try {
90			const url = model ? `${API_SLOTS.LIST}?model=${encodeURIComponent(model)}` : API_SLOTS.LIST;
91			const res = await fetch(url, { signal });
92
93			if (!res.ok) return true;
94
95			const slots: { is_processing: boolean }[] = await res.json();
96
97			return slots.every((s) => !s.is_processing);
98		} catch {
99			return true;
100		}
101	}
102
103	/**
104	 * Cancels the server-side replay buffer for a conversation, freeing its slot.
105	 */
106	static async cancelServerStream(conversationId: string, model?: string | null): Promise<void> {
107		if (!conversationId) return;
108
109		try {
110			const id = streamIdentity(conversationId, model);
111
112			await fetch(ChatService.buildStreamUrl(id), {
113				headers: getAuthHeaders(),
114				method: 'DELETE'
115			});
116		} catch (e) {
117			console.warn('cancelServerStream failed:', e);
118		}
119	}
120
121	static clearStreamState(conversationId: string): void {
122		if (!conversationId) return;
123
124		ChatService.streamStateSaveTrackers.delete(conversationId);
125
126		try {
127			localStorage.removeItem(streamStorageKey(conversationId));
128		} catch {
129			// nothing to do
130		}
131	}
132
133	/**
134	 * Converts a database message with attachments to API chat message format.
135	 * Processes various attachment types (images, text files, PDFs) and formats them
136	 * as content parts suitable for the chat completion API.
137	 */
138	static async convertDbMessageToApiChatMessageData(
139		message: DatabaseMessage & { extra?: DatabaseMessageExtra[] }
140	): Promise<ApiChatMessageData> {
141		// Handle tool result messages (role: 'tool')
142		if (message.role === MessageRole.TOOL && message.toolCallId) {
143			return {
144				content: message.content,
145				role: MessageRole.TOOL,
146				tool_call_id: message.toolCallId
147			};
148		}
149
150		// Parse tool calls for assistant messages
151		let toolCalls: ApiChatCompletionToolCall[] | undefined;
152
153		if (message.toolCalls) {
154			try {
155				toolCalls = JSON.parse(message.toolCalls);
156			} catch {
157				// Ignore parse errors for malformed tool calls
158			}
159		}
160
161		if (!message.extra || message.extra.length === 0) {
162			const result: ApiChatMessageData = {
163				content: message.content,
164				role: message.role as MessageRole
165			};
166
167			if (message.reasoningContent) {
168				result.reasoning_content = message.reasoningContent;
169			}
170
171			if (toolCalls && toolCalls.length > 0) {
172				result.tool_calls = toolCalls;
173			}
174
175			return result;
176		}
177
178		const contentParts: ApiChatMessageContentPart[] = [];
179		const textFiles = message.extra.filter(
180			(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
181				extra.type === AttachmentType.TEXT
182		);
183
184		for (const textFile of textFiles) {
185			contentParts.push({
186				text: formatAttachmentText(AttachmentLabel.FILE, textFile.name, textFile.content),
187				type: ContentPartType.TEXT
188			});
189		}
190
191		// Handle legacy 'context' type from the old UI (pasted content)
192		const legacyContextFiles = message.extra.filter(
193			(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext =>
194				extra.type === AttachmentType.LEGACY_CONTEXT
195		);
196
197		for (const legacyContextFile of legacyContextFiles) {
198			contentParts.push({
199				text: formatAttachmentText(
200					AttachmentLabel.FILE,
201					legacyContextFile.name,
202					legacyContextFile.content
203				),
204				type: ContentPartType.TEXT
205			});
206		}
207
208		const imageFiles = message.extra.filter(
209			(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
210				extra.type === AttachmentType.IMAGE
211		);
212
213		for (const image of imageFiles) {
214			const maxImageResolution = settingsStore.getConfig(SETTINGS_KEYS.MAX_IMAGE_RESOLUTION);
215			// Caps the resolution and bakes the jpeg exif orientation in one pass,
216			// untouched images pass through as is
217			const base64Url = await capImageDataURLSize(image.base64Url, maxImageResolution);
218
219			contentParts.push({
220				image_url: { url: base64Url },
221				type: ContentPartType.IMAGE_URL
222			});
223		}
224
225		const audioFiles = message.extra.filter(
226			(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile =>
227				extra.type === AttachmentType.AUDIO
228		);
229
230		for (const audio of audioFiles) {
231			contentParts.push({
232				input_audio: {
233					data: audio.base64Data,
234					format: getAudioInputFormat(audio.mimeType)
235				},
236				type: ContentPartType.INPUT_AUDIO
237			});
238		}
239
240		if (message.content) {
241			contentParts.push({
242				text: message.content,
243				type: ContentPartType.TEXT
244			});
245		}
246
247		const videoFiles = message.extra.filter(
248			(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraVideoFile =>
249				extra.type === AttachmentType.VIDEO
250		);
251
252		for (const video of videoFiles) {
253			contentParts.push({
254				input_video: {
255					data: video.base64Data,
256					format: video.mimeType.includes('mp4')
257						? 'mp4'
258						: video.mimeType.includes('ogg')
259							? 'ogg'
260							: 'auto'
261				},
262				type: ContentPartType.INPUT_VIDEO
263			});
264		}
265
266		const pdfFiles = message.extra.filter(
267			(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
268				extra.type === AttachmentType.PDF
269		);
270
271		for (const pdfFile of pdfFiles) {
272			if (pdfFile.processedAsImages && pdfFile.images) {
273				for (let i = 0; i < pdfFile.images.length; i++) {
274					contentParts.push({
275						image_url: { url: pdfFile.images[i] },
276						type: ContentPartType.IMAGE_URL
277					});
278				}
279			} else {
280				contentParts.push({
281					text: formatAttachmentText(AttachmentLabel.PDF_FILE, pdfFile.name, pdfFile.content),
282					type: ContentPartType.TEXT
283				});
284			}
285		}
286
287		const mcpPrompts = message.extra.filter(
288			(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpPrompt =>
289				extra.type === AttachmentType.MCP_PROMPT
290		);
291
292		for (const mcpPrompt of mcpPrompts) {
293			contentParts.push({
294				text: formatAttachmentText(
295					AttachmentLabel.MCP_PROMPT,
296					mcpPrompt.name,
297					mcpPrompt.content,
298					mcpPrompt.serverName
299				),
300				type: ContentPartType.TEXT
301			});
302		}
303
304		const mcpResources = message.extra.filter(
305			(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpResource =>
306				extra.type === AttachmentType.MCP_RESOURCE
307		);
308
309		for (const mcpResource of mcpResources) {
310			contentParts.push({
311				text: formatAttachmentText(
312					AttachmentLabel.MCP_RESOURCE,
313					mcpResource.name,
314					mcpResource.content,
315					mcpResource.serverName
316				),
317				type: ContentPartType.TEXT
318			});
319		}
320
321		const result: ApiChatMessageData = {
322			content: contentParts,
323			role: message.role as MessageRole
324		};
325
326		if (message.reasoningContent) {
327			result.reasoning_content = message.reasoningContent;
328		}
329
330		if (toolCalls && toolCalls.length > 0) {
331			result.tool_calls = toolCalls;
332		}
333
334		return result;
335	}
336
337	/**
338	 * Fetch the full replay of a server-side stream from byte 0. Returns the raw Response so the
339	 * caller can pipe it through the SSE parser like a fresh stream.
340	 */
341	static async fetchStreamReplay(streamId: string): Promise<Response> {
342		const resp = await fetch(ChatService.buildStreamUrl(streamId, 0), {
343			headers: getAuthHeaders()
344		});
345
346		if (!resp.ok) {
347			throw new ApiError(`Stream replay failed with HTTP ${resp.status}`, resp.status);
348		}
349
350		return resp;
351	}
352
353	// write a throttled-but-not-yet-persisted offset immediately; used at
354	// resume boundaries and on pagehide/visibilitychange so the persisted
355	// offset is the freshest one when it matters
356	static flushStreamState(conversationId: string): void {
357		const tracker = ChatService.streamStateSaveTrackers.get(conversationId);
358
359		if (!tracker || tracker.pendingBytes === null) return;
360
361		const { model, pendingBytes } = tracker;
362
363		tracker.lastSavedAt = Date.now();
364		tracker.pendingBytes = null;
365
366		ChatService.writeStreamState(conversationId, pendingBytes, model);
367	}
368
369	/**
370	 * Sends a streaming chat completion request for generating a chat title.
371	 * Delegates to `sendMessage` for fetch, SSE parsing, and error handling.
372	 *
373	 * @param message - The single message to send (a user message containing the title generation prompt)
374	 * @param model - Optional model name to use (required in ROUTER mode)
375	 * @param signal - Optional AbortSignal to cancel the request
376	 * @returns {Promise<string>} The aggregated title text, or empty string if request failed
377	 * @static
378	 */
379	static async generateTitle(
380		message: ApiChatMessageData,
381		model?: string | null,
382		signal?: AbortSignal
383	): Promise<string> {
384		let titleResponse = '';
385
386		try {
387			await ChatService.sendMessage(
388				[message],
389				{
390					custom: { chat_template_kwargs: { enable_thinking: false } },
391					model: model || undefined,
392					onChunk: (chunk: string) => {
393						titleResponse += chunk;
394					},
395					stream: true
396				},
397				undefined,
398				signal
399			);
400		} catch {
401			return '';
402		}
403
404		return titleResponse;
405	}
406
407	static getStreamState(conversationId: string): ResumableStreamState | null {
408		if (!conversationId) return null;
409
410		try {
411			const raw = localStorage.getItem(streamStorageKey(conversationId));
412
413			if (!raw) return null;
414
415			const parsed = JSON.parse(raw) as ResumableStreamState;
416
417			if (!parsed || typeof parsed.bytesReceived !== 'number') return null;
418
419			return parsed;
420		} catch {
421			return null;
422		}
423	}
424
425	/**
426	 * Handles streaming response from the chat completion API.
427	 */
428	static async handleStreamResponse(
429		response: Response,
430		onChunk?: (chunk: string) => void,
431		onComplete?: (
432			response: string,
433			reasoningContent?: string,
434			timings?: ChatMessageTimings,
435			toolCalls?: string
436		) => void,
437		onError?: (error: Error) => void,
438		onReasoningChunk?: (chunk: string) => void,
439		onToolCallChunk?: (chunk: string) => void,
440		onModel?: (model: string) => void,
441		onCompletionId?: (id: string) => void,
442		onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
443		conversationId?: string,
444		abortSignal?: AbortSignal,
445		onConnectionState?: (state: StreamConnectionState) => void,
446		streamModel?: string | null
447	): Promise<void> {
448		let reader = response.body?.getReader();
449
450		if (!reader) {
451			throw new Error('No response body');
452		}
453
454		// bytesParsed is the absolute server side buffer offset of the next byte to parse
455		// segmentStartOffset is the absolute offset where the current reader started, reset on resume
456		// segmentBytesRead is wire bytes read by the current reader
457		let bytesParsed = 0;
458		let segmentStartOffset = 0;
459		let segmentBytesRead = 0;
460		let lastByteAt = Date.now();
461		// each resume must produce at least one byte to be retried again
462		// if a resume returns 200 but yields nothing, we abandon
463		// since the session has a bounded size, the total number of retries is bounded by construction
464		let madeProgress = true;
465
466		const encoder = new TextEncoder();
467
468		if (conversationId) {
469			ChatService.saveStreamState(conversationId, 0, streamModel);
470		}
471
472		onConnectionState?.(StreamConnectionState.STREAMING);
473
474		let decoder = new TextDecoder();
475		let aggregatedContent = '';
476		let fullReasoningContent = '';
477		let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
478		let lastTimings: ChatMessageTimings | undefined;
479		let streamFinished = false;
480		let modelEmitted = false;
481		let idEmitted = false;
482		let toolCallIndexOffset = 0;
483		let hasOpenToolCallBatch = false;
484
485		const finalizeOpenToolCallBatch = () => {
486			if (!hasOpenToolCallBatch) {
487				return;
488			}
489
490			toolCallIndexOffset = aggregatedToolCalls.length;
491			hasOpenToolCallBatch = false;
492		};
493		const processToolCallDelta = (toolCalls?: ApiChatCompletionToolCallDelta[]) => {
494			if (!toolCalls || toolCalls.length === 0) {
495				return;
496			}
497
498			aggregatedToolCalls = ChatService.mergeToolCallDeltas(
499				aggregatedToolCalls,
500				toolCalls,
501				toolCallIndexOffset
502			);
503
504			if (aggregatedToolCalls.length === 0) {
505				return;
506			}
507
508			hasOpenToolCallBatch = true;
509
510			const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
511
512			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
513				console.log('[ChatService] Aggregated tool calls:', serializedToolCalls);
514			}
515
516			if (!serializedToolCalls) {
517				return;
518			}
519
520			if (!abortSignal?.aborted) {
521				onToolCallChunk?.(serializedToolCalls);
522			}
523		};
524		const onVisibilityChange = () => {
525			if (typeof document === 'undefined') return;
526
527			if (document.visibilityState === 'hidden') {
528				// the tab is going to the background and the OS may throttle or
529				// drop the socket shortly; persist the freshest resume offset now
530				if (conversationId) ChatService.flushStreamState(conversationId);
531
532				return;
533			}
534
535			if (streamFinished) return;
536
537			if (!conversationId) return;
538
539			// the bytes have been quiet for too long, the OS likely killed the socket
540			// kicking the reader unblocks reader.read with done=true so the outer loop can resume
541			if (Date.now() - lastByteAt > STREAM_VISIBILITY_KICK_MS) {
542				reader!.cancel().catch(() => {});
543			}
544		};
545		const onPageHide = () => {
546			// a reload or navigation is about to happen; make sure the resume
547			// offset that getStreamState() will read is not a stale throttled one
548			if (conversationId) ChatService.flushStreamState(conversationId);
549		};
550
551		if (typeof document !== 'undefined') {
552			document.addEventListener('visibilitychange', onVisibilityChange);
553			window.addEventListener('pagehide', onPageHide);
554		}
555
556		try {
557			let chunk = '';
558
559			// outer loop drives the resume cycle, swaps reader on premature end of stream
560			while (true) {
561				while (true) {
562					if (abortSignal?.aborted) break;
563
564					let done: boolean;
565					let value: Uint8Array | undefined;
566
567					try {
568						const r = await reader.read();
569
570						done = r.done;
571						value = r.value;
572					} catch (readErr) {
573						// reader.read() rejects with TypeError when the underlying connection drops
574						// instead of just resolving with done=true. treat it like done so the outer
575						// loop swaps reader via the resume path
576						if (isAbortError(readErr)) {
577							throw readErr;
578						}
579
580						console.warn('reader.read() rejected, treating as premature end:', readErr);
581						done = true;
582						value = undefined;
583					}
584
585					if (done) break;
586
587					if (abortSignal?.aborted) break;
588
589					if (value && value.byteLength > 0) {
590						segmentBytesRead += value.byteLength;
591						lastByteAt = Date.now();
592
593						if (!madeProgress) {
594							madeProgress = true;
595							onConnectionState?.(StreamConnectionState.STREAMING);
596						}
597					}
598
599					chunk += decoder.decode(value, { stream: true });
600					const lines = chunk.split(SSE_LINE_SEPARATOR);
601
602					chunk = lines.pop() || '';
603
604					// the persisted offset must point right after the last fully parsed line,
605					// the trailing `chunk` is partial bytes still waiting for a newline
606					if (conversationId) {
607						const tailBytes = encoder.encode(chunk).byteLength;
608
609						bytesParsed = segmentStartOffset + segmentBytesRead - tailBytes;
610						ChatService.saveStreamStateThrottled(conversationId, bytesParsed, streamModel);
611					}
612
613					for (const line of lines) {
614						if (abortSignal?.aborted) break;
615
616						if (line.startsWith(SSE_DATA_PREFIX)) {
617							const data = line.slice(SSE_DATA_PREFIX.length).trim();
618
619							if (data === SSE_DONE_MARKER) {
620								streamFinished = true;
621
622								continue;
623							}
624
625							try {
626								const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
627								const choice = parsed.choices?.[0];
628								const content = choice?.delta?.content;
629								const reasoningContent = choice?.delta?.reasoning_content;
630								const toolCalls = choice?.delta?.tool_calls;
631								const timings = parsed.timings;
632								const promptProgress = parsed.prompt_progress;
633								const chunkModel = ChatService.extractModelName(parsed);
634
635								if (chunkModel && !modelEmitted) {
636									modelEmitted = true;
637									onModel?.(chunkModel);
638								}
639
640								if (parsed.id && !idEmitted) {
641									idEmitted = true;
642									onCompletionId?.(parsed.id);
643								}
644
645								if (promptProgress) {
646									ChatService.notifyTimings(undefined, promptProgress, onTimings);
647								}
648
649								if (timings) {
650									ChatService.notifyTimings(timings, promptProgress, onTimings);
651									lastTimings = timings;
652								}
653
654								if (content) {
655									finalizeOpenToolCallBatch();
656									aggregatedContent += content;
657
658									if (!abortSignal?.aborted) {
659										onChunk?.(content);
660									}
661								}
662
663								if (reasoningContent) {
664									finalizeOpenToolCallBatch();
665									fullReasoningContent += reasoningContent;
666
667									if (!abortSignal?.aborted) {
668										onReasoningChunk?.(reasoningContent);
669									}
670								}
671
672								processToolCallDelta(toolCalls);
673							} catch (e) {
674								console.error('Error parsing JSON chunk:', e);
675							}
676						}
677					}
678
679					if (abortSignal?.aborted) break;
680
681					if (streamFinished) break;
682				}
683
684				// inner reader done, decide whether to try a resume
685				if (abortSignal?.aborted) break;
686
687				if (streamFinished) break;
688
689				if (!conversationId) break;
690
691				if (!madeProgress) {
692					onConnectionState?.(StreamConnectionState.LOST);
693					onError?.(new Error('Stream resume produced no new bytes, giving up'));
694
695					break;
696				}
697
698				onConnectionState?.(StreamConnectionState.RESUMING);
699				madeProgress = false;
700
701				// the server resends starting at bytesParsed, discard any partial line we held, it
702				// will be retransmitted from a clean line boundary. reuse the frozen model, not the
703				// live dropdown
704				// resumeStream reads the offset from localStorage, so persist the
705				// freshest bytesParsed before asking the server to replay from it
706				ChatService.flushStreamState(conversationId);
707				const resumeResp = await ChatService.resumeStream(
708					conversationId,
709					abortSignal,
710					streamModel
711				).catch(() => null);
712
713				// an abort landing during the resume request is intentional, not a lost connection
714				if (abortSignal?.aborted) break;
715
716				if (!resumeResp || resumeResp.status !== 200) {
717					onConnectionState?.(StreamConnectionState.LOST);
718					onError?.(new Error('Stream connection lost and could not be resumed'));
719
720					break;
721				}
722
723				const newReader = resumeResp.body?.getReader();
724
725				if (!newReader) break;
726
727				try {
728					reader.releaseLock();
729				} catch {
730					/* ignore */
731				}
732				reader = newReader;
733				decoder = new TextDecoder();
734				chunk = '';
735				segmentStartOffset = bytesParsed;
736				segmentBytesRead = 0;
737				lastByteAt = Date.now();
738			}
739
740			if (abortSignal?.aborted) return;
741
742			if (streamFinished) {
743				finalizeOpenToolCallBatch();
744
745				if (conversationId) {
746					ChatService.clearStreamState(conversationId);
747				}
748
749				const finalToolCalls =
750					aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined;
751
752				onComplete?.(
753					aggregatedContent,
754					fullReasoningContent || undefined,
755					lastTimings,
756					finalToolCalls
757				);
758			}
759		} catch (error) {
760			const err = error instanceof Error ? error : new Error('Stream error');
761
762			onError?.(err);
763
764			throw err;
765		} finally {
766			if (typeof document !== 'undefined') {
767				document.removeEventListener('visibilitychange', onVisibilityChange);
768				window.removeEventListener('pagehide', onPageHide);
769			}
770
771			try {
772				reader.releaseLock();
773			} catch {
774				/* ignore */
775			}
776		}
777	}
778
779	/**
780	 * Look up server-side stream sessions for the given conversation ids. Ids carry the frozen
781	 * conv::model identity when a model was bound at POST time.
782	 */
783	static async lookupStreamSessions(conversationIds: string[]): Promise<ApiStreamSession[]> {
784		const resp = await fetch(API_STREAM.LOOKUP, {
785			body: JSON.stringify({ conversation_ids: conversationIds }),
786			headers: getJsonHeaders(),
787			method: 'POST'
788		});
789
790		if (!resp.ok) {
791			throw new ApiError(`Stream lookup failed with HTTP ${resp.status}`, resp.status);
792		}
793
794		const body = (await resp.json()) as unknown;
795
796		if (!Array.isArray(body)) {
797			throw new Error('Stream lookup returned a non-array response');
798		}
799
800		return body as ApiStreamSession[];
801	}
802
803	/**
804	 * Normalizes an array of messages (database or already-API-shaped) into
805	 * API chat message data, converting DB messages and dropping empty system
806	 * messages. Shared by sendMessage, preEncode and the agentic flow.
807	 */
808	static async normalizeMessagesForApi(
809		messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[]
810	): Promise<ApiChatMessageData[]> {
811		return (
812			await Promise.all(
813				messages.map((msg) => {
814					if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
815						return ChatService.convertDbMessageToApiChatMessageData(
816							msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
817						);
818					}
819
820					return msg as ApiChatMessageData;
821				})
822			)
823		).filter((msg: { role: ChatRole; content: string | ApiChatMessageContentPart[] }) => {
824			// Filter out empty system messages
825			if (msg.role === MessageRole.SYSTEM) {
826				const content = typeof msg.content === 'string' ? msg.content : '';
827
828				return content.trim().length > 0;
829			}
830
831			return true;
832		});
833	}
834
835	/**
836	 * Fire-and-forget request to pre-encode the conversation in the server's KV cache.
837	 * Re-submits the full conversation with n_predict=0 so the server processes the prompt
838	 * without generating tokens, warming the cache for the next turn.
839	 */
840	static async preEncode(
841		messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
842		model?: string | null,
843		excludeReasoning?: boolean,
844		signal?: AbortSignal
845	): Promise<void> {
846		const normalizedMessages: ApiChatMessageData[] =
847			await ChatService.normalizeMessagesForApi(messages);
848		const requestBody: Record<string, unknown> = {
849			messages: normalizedMessages.map((msg: ApiChatMessageData) => {
850				const mapped: Record<string, unknown> = {
851					content: excludeReasoning ? ChatService.stripReasoningContent(msg.content) : msg.content,
852					role: msg.role,
853					tool_call_id: msg.tool_call_id,
854					tool_calls: msg.tool_calls
855				};
856
857				if (!excludeReasoning && msg.reasoning_content) {
858					mapped.reasoning_content = msg.reasoning_content;
859				}
860
861				return mapped;
862			}),
863			n_predict: 0,
864			stream: false
865		};
866
867		if (model) {
868			requestBody.model = model;
869		}
870
871		try {
872			await fetch(API_CHAT.COMPLETIONS, {
873				body: JSON.stringify(requestBody),
874				headers: getJsonHeaders(),
875				method: 'POST',
876				signal
877			});
878		} catch (error) {
879			if (!isAbortError(error)) {
880				console.warn('[ChatService] Pre-encode request failed:', error);
881			}
882		}
883	}
884
885	// probe the resume route status without consuming the stream: the SSE route has no HEAD,
886	// so issue the GET and abort it right after the status line. 0 on network error
887	static async probeResumeStatus(streamId: string): Promise<number> {
888		if (!streamId) return 0;
889
890		const ac = new AbortController();
891
892		try {
893			const resp = await fetch(ChatService.buildStreamUrl(streamId, 0), {
894				headers: getAuthHeaders(),
895				signal: ac.signal
896			});
897
898			ac.abort();
899
900			return resp.status;
901		} catch {
902			return 0;
903		}
904	}
905
906	static async resumeStream(
907		conversationId: string,
908		signal?: AbortSignal,
909		model?: string | null
910	): Promise<Response | null> {
911		if (!conversationId) return null;
912
913		const state = ChatService.getStreamState(conversationId);
914		const from = state?.bytesReceived ?? 0;
915		const id = streamIdentity(conversationId, model);
916		const url = ChatService.buildStreamUrl(id, from);
917
918		return await fetch(url, { headers: getAuthHeaders(), method: 'GET', signal });
919	}
920
921	/**
922	 * Rebuild the stream identity for a resume. The model persisted at POST time wins, including a
923	 * stored null which means the POST carried no explicit model so the identity stays the bare conv
924	 * id. Only fall back to the caller supplied current model when nothing was persisted.
925	 */
926	static resumeStreamIdentity(
927		conversationId: string,
928		state: ResumableStreamState | null,
929		fallbackModel: string | null
930	): string {
931		const model = state && state.model !== undefined ? state.model : fallbackModel;
932
933		return streamIdentity(conversationId, model);
934	}
935
936	// persist the running byte count and the frozen model for a conversation, a later visit
937	// resumes the SSE replay at the right offset under the same conv::model
938	// identity. Writes immediately; the per-chunk read loop uses the throttled
939	// variant instead.
940	static saveStreamState(
941		conversationId: string,
942		bytesReceived: number,
943		model?: string | null
944	): void {
945		if (!conversationId) return;
946
947		ChatService.writeStreamState(conversationId, bytesReceived, model);
948		// record the write so a throttled save landing inside the interval
949		// holds its value pending instead of re-writing
950		ChatService.streamStateSaveTrackers.set(conversationId, {
951			lastSavedAt: Date.now(),
952			model: model ?? null,
953			pendingBytes: null
954		});
955	}
956
957	// throttled variant for the per-chunk read loop: writes at most once per
958	// conversation per STREAM_STATE_SAVE_INTERVAL_MS, holding the latest value
959	// pending until the interval elapses or flushStreamState() forces it out
960	static saveStreamStateThrottled(
961		conversationId: string,
962		bytesReceived: number,
963		model?: string | null
964	): void {
965		if (!conversationId) return;
966
967		const tracker = ChatService.streamStateSaveTrackers.get(conversationId) ?? {
968			lastSavedAt: 0,
969			model: null,
970			pendingBytes: null
971		};
972
973		tracker.model = model ?? null;
974
975		if (Date.now() - tracker.lastSavedAt >= ChatService.STREAM_STATE_SAVE_INTERVAL_MS) {
976			tracker.lastSavedAt = Date.now();
977			tracker.pendingBytes = null;
978			ChatService.writeStreamState(conversationId, bytesReceived, model);
979		} else {
980			tracker.pendingBytes = bytesReceived;
981		}
982
983		ChatService.streamStateSaveTrackers.set(conversationId, tracker);
984	}
985
986	/**
987	 * Pick the running session to splice into when discoverActiveStream lists candidates for a
988	 * conversation. Finalized sessions are not candidates: their final content was already written
989	 * to the DB by the original onComplete handler, so attaching to them would replay a buffer that
990	 * may not match what the DB holds. A continue session's buffer holds only the appended deltas,
991	 * not the pre continue prefix, so replaying it as a fresh generation would erase the original.
992	 *
993	 * Among running sessions we tie break on the most recent started_at, which covers the case of
994	 * multiple inferences left running on the same conversation.
995	 */
996	static selectActiveStream(
997		sessions: ApiStreamSession[] | null | undefined
998	): ApiStreamSession | null {
999		if (!Array.isArray(sessions) || sessions.length === 0) {
1000			return null;
1001		}
1002
1003		const running = sessions.filter((s) => !s.is_done);
1004
1005		if (running.length === 0) {
1006			return null;
1007		}
1008
1009		return running.reduce((best, cur) => (cur.started_at > best.started_at ? cur : best));
1010	}
1011
1012	/**
1013	 * Sends a chat completion request to the llama-server.
1014	 * Supports both streaming and non-streaming responses with comprehensive parameter configuration.
1015	 * Automatically converts database messages with attachments to the appropriate API format.
1016	 *
1017	 * @param messages - Array of chat messages to send to the API (supports both ApiChatMessageData and DatabaseMessage with attachments)
1018	 * @param options - Configuration options for the chat completion request. See `SettingsChatServiceOptions` type for details.
1019	 * @returns {Promise<string | void>} that resolves to the complete response string (non-streaming) or void (streaming)
1020	 * @throws {Error} if the request fails or is aborted
1021	 */
1022	static async sendMessage(
1023		messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
1024		options: SettingsChatServiceOptions = {},
1025		conversationId?: string,
1026		signal?: AbortSignal
1027	): Promise<string | void> {
1028		const {
1029			backend_sampling,
1030			continueFinalMessage,
1031			custom,
1032			// Config options
1033			disableReasoningParsing,
1034			dry_allowed_length,
1035			dry_base,
1036			dry_multiplier,
1037			dry_penalty_last_n,
1038			dynatemp_exponent,
1039			// Sampling parameters
1040			dynatemp_range,
1041			enableThinking,
1042			excludeReasoningFromContext,
1043			frequency_penalty,
1044			max_tokens,
1045			min_p,
1046			onChunk,
1047			onComplete,
1048			onCompletionId,
1049			onConnectionState,
1050			onError,
1051			onModel,
1052			onReasoningChunk,
1053			onTimings,
1054			onToolCallChunk,
1055			presence_penalty,
1056			reasoningEffort,
1057			// Penalty parameters
1058			repeat_last_n,
1059			repeat_penalty,
1060			// Other parameters
1061			samplers,
1062			stream,
1063			// Generation parameters
1064			temperature,
1065			timings_per_token,
1066			// Tools for function calling
1067			tools,
1068			top_k,
1069			top_p,
1070			typ_p,
1071			xtc_probability,
1072			xtc_threshold
1073		} = options;
1074		const normalizedMessages: ApiChatMessageData[] =
1075			await ChatService.normalizeMessagesForApi(messages);
1076
1077		// Filter out image attachments if the model doesn't support vision
1078		if (options.model && !modelsStore.props.modelSupportsVision(options.model)) {
1079			normalizedMessages.forEach((msg) => {
1080				if (Array.isArray(msg.content)) {
1081					msg.content = msg.content.filter((part: ApiChatMessageContentPart) => {
1082						if (part.type === ContentPartType.IMAGE_URL) {
1083							console.info(
1084								`[ChatService] Skipping image attachment in message history (model "${options.model}" does not support vision)`
1085							);
1086
1087							return false;
1088						}
1089
1090						return true;
1091					});
1092
1093					// If only text remains and it's a single part, simplify to string
1094					if (
1095						msg.content.length === 1 &&
1096						msg.content[0].type === ContentPartType.TEXT &&
1097						typeof msg.content[0].text === 'string'
1098					) {
1099						msg.content = msg.content[0].text;
1100					}
1101				}
1102			});
1103		}
1104
1105		const requestBody: ApiChatCompletionRequest = {
1106			messages: normalizedMessages.map((msg: ApiChatMessageData) => {
1107				const mapped: ApiChatCompletionRequest['messages'][0] = {
1108					content: msg.content,
1109					role: msg.role,
1110					tool_call_id: msg.tool_call_id,
1111					tool_calls: msg.tool_calls
1112				};
1113
1114				// Include reasoning_content from the dedicated field
1115				if (!excludeReasoningFromContext && msg.reasoning_content) {
1116					mapped.reasoning_content = msg.reasoning_content;
1117				}
1118
1119				return mapped;
1120			}),
1121			return_progress: stream ? true : undefined,
1122			sse_ping_interval: stream ? 1 : undefined,
1123			stream,
1124			tools: tools && tools.length > 0 ? tools : undefined
1125		};
1126
1127		// Include model in request if provided (required in ROUTER mode)
1128		if (options.model) {
1129			requestBody.model = options.model;
1130		}
1131
1132		requestBody.reasoning_format = disableReasoningParsing
1133			? ReasoningFormat.NONE
1134			: ReasoningFormat.AUTO;
1135
1136		const reasoningBudgetTokens =
1137			enableThinking && reasoningEffort ? (REASONING_EFFORT_TOKENS[reasoningEffort] ?? -1) : -1;
1138
1139		// an explicit user choice injects the kwarg, otherwise it is omitted so
1140		// the server default applies (--reasoning flag or chat template)
1141		if (enableThinking !== undefined) {
1142			requestBody.chat_template_kwargs = {
1143				...(requestBody.chat_template_kwargs ?? {}),
1144				enable_thinking: enableThinking
1145			};
1146		}
1147
1148		if (reasoningBudgetTokens >= 0) {
1149			requestBody.thinking_budget_tokens = reasoningBudgetTokens;
1150		}
1151
1152		// arms the budget sampler so reasoning can be ended at runtime via the control endpoint
1153		requestBody.reasoning_control = true;
1154
1155		if (continueFinalMessage) {
1156			requestBody.continue_final_message = true;
1157			requestBody.add_generation_prompt = false;
1158		}
1159
1160		if (temperature !== undefined) requestBody.temperature = temperature;
1161
1162		if (max_tokens !== undefined) {
1163			// Set max_tokens to -1 (infinite) when explicitly configured as 0 or null
1164			requestBody.max_tokens = max_tokens !== null && max_tokens !== 0 ? max_tokens : -1;
1165		}
1166
1167		if (dynatemp_range !== undefined) requestBody.dynatemp_range = dynatemp_range;
1168
1169		if (dynatemp_exponent !== undefined) requestBody.dynatemp_exponent = dynatemp_exponent;
1170
1171		if (top_k !== undefined) requestBody.top_k = top_k;
1172
1173		if (top_p !== undefined) requestBody.top_p = top_p;
1174
1175		if (min_p !== undefined) requestBody.min_p = min_p;
1176
1177		if (xtc_probability !== undefined) requestBody.xtc_probability = xtc_probability;
1178
1179		if (xtc_threshold !== undefined) requestBody.xtc_threshold = xtc_threshold;
1180
1181		if (typ_p !== undefined) requestBody.typ_p = typ_p;
1182
1183		if (repeat_last_n !== undefined) requestBody.repeat_last_n = repeat_last_n;
1184
1185		if (repeat_penalty !== undefined) requestBody.repeat_penalty = repeat_penalty;
1186
1187		if (presence_penalty !== undefined) requestBody.presence_penalty = presence_penalty;
1188
1189		if (frequency_penalty !== undefined) requestBody.frequency_penalty = frequency_penalty;
1190
1191		if (dry_multiplier !== undefined) requestBody.dry_multiplier = dry_multiplier;
1192
1193		if (dry_base !== undefined) requestBody.dry_base = dry_base;
1194
1195		if (dry_allowed_length !== undefined) requestBody.dry_allowed_length = dry_allowed_length;
1196
1197		if (dry_penalty_last_n !== undefined) requestBody.dry_penalty_last_n = dry_penalty_last_n;
1198
1199		if (samplers !== undefined) {
1200			requestBody.samplers =
1201				typeof samplers === 'string'
1202					? samplers.split(';').filter((s: string) => s.trim())
1203					: samplers;
1204		}
1205
1206		if (backend_sampling !== undefined) requestBody.backend_sampling = backend_sampling;
1207
1208		if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token;
1209
1210		if (custom) {
1211			try {
1212				const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom;
1213
1214				Object.assign(requestBody, customParams);
1215			} catch (error) {
1216				console.warn('Failed to parse custom parameters:', error);
1217			}
1218		}
1219
1220		try {
1221			const headers: Record<string, string> = { ...getJsonHeaders() };
1222
1223			// tag streaming requests with the conversation id, this single header is the opt in for the
1224			// server side replay buffer and powers discoverActiveStream on tab reopen. with an explicit
1225			// model the ::model suffix keeps the per model session distinct
1226			if (stream && conversationId) {
1227				headers[HEADERS.X_CONVERSATION_ID_HEADER] = streamIdentity(conversationId, options.model);
1228				// persist the pending stream before the fetch: a reload during the model load or
1229				// the prompt processing must still find its way back to the session once it exists
1230				ChatService.saveStreamState(conversationId, 0, options.model ?? null);
1231			}
1232
1233			const response = await fetch(API_CHAT.COMPLETIONS, {
1234				body: JSON.stringify(requestBody),
1235				headers,
1236				method: 'POST',
1237				signal
1238			});
1239
1240			if (!response.ok) {
1241				// a rejected request (including one cancelled by a stop during the model load)
1242				// leaves nothing to resume
1243				if (conversationId) {
1244					ChatService.clearStreamState(conversationId);
1245				}
1246
1247				const error = await ChatService.parseErrorResponse(response);
1248
1249				if (onError) {
1250					onError(error);
1251				}
1252
1253				throw error;
1254			}
1255
1256			if (stream) {
1257				await ChatService.handleStreamResponse(
1258					response,
1259					onChunk,
1260					onComplete,
1261					onError,
1262					onReasoningChunk,
1263					onToolCallChunk,
1264					onModel,
1265					onCompletionId,
1266					onTimings,
1267					conversationId,
1268					signal,
1269					onConnectionState,
1270					options.model
1271				);
1272
1273				return;
1274			} else {
1275				return ChatService.handleNonStreamResponse(
1276					response,
1277					onComplete,
1278					onError,
1279					onToolCallChunk,
1280					onModel
1281				);
1282			}
1283		} catch (error) {
1284			if (isAbortError(error)) {
1285				console.log('Chat completion request was aborted');
1286
1287				return;
1288			}
1289
1290			let userFriendlyError: Error;
1291
1292			if (error instanceof Error) {
1293				if (error.name === 'TypeError' && error.message.includes('fetch')) {
1294					userFriendlyError = new Error(
1295						'Unable to connect to server - please check if the server is running'
1296					);
1297					userFriendlyError.name = 'NetworkError';
1298				} else if (error.message.includes('ECONNREFUSED')) {
1299					userFriendlyError = new Error('Connection refused - server may be offline');
1300					userFriendlyError.name = 'NetworkError';
1301				} else if (error.message.includes('ETIMEDOUT')) {
1302					userFriendlyError = new Error('Request timed out - the server took too long to respond');
1303					userFriendlyError.name = 'TimeoutError';
1304				} else {
1305					userFriendlyError = error;
1306				}
1307			} else {
1308				userFriendlyError = new Error('Unknown error occurred while sending message');
1309			}
1310
1311			console.error('Error in sendMessage:', error);
1312
1313			if (onError) {
1314				onError(userFriendlyError);
1315			}
1316
1317			throw userFriendlyError;
1318		}
1319	}
1320
1321	/**
1322	 * Ends the current reasoning block of a running completion, targeted by its
1323	 * chat completion id (streamed back as `id`). Matching the completion rather
1324	 * than a slot index avoids a TOCTOU: a finished completion simply matches
1325	 * nothing server side. The model is carried so the router forwards to the
1326	 * right child, single model ignores it. Returns true on success.
1327	 */
1328	static async stopReasoning(completionId: string, model?: string | null): Promise<boolean> {
1329		if (!completionId) {
1330			console.error(
1331				'stopReasoning: no completion id for the active message, cannot target the running completion'
1332			);
1333
1334			return false;
1335		}
1336
1337		const body: Record<string, unknown> = {
1338			action: CONTROL_ACTION.END_REASONING,
1339			id: completionId
1340		};
1341
1342		if (model) body.model = model;
1343
1344		try {
1345			const res = await fetch(API_CHAT.CONTROL, {
1346				body: JSON.stringify(body),
1347				headers: getJsonHeaders(),
1348				method: 'POST'
1349			});
1350			const data = await res.json().catch(() => null);
1351
1352			if (!res.ok || data?.success !== true) {
1353				console.error('stopReasoning: control request failed', {
1354					completionId,
1355					response: data,
1356					status: res.status
1357				});
1358
1359				return false;
1360			}
1361
1362			return true;
1363		} catch (error) {
1364			console.error('stopReasoning: control request threw', { completionId, error });
1365
1366			return false;
1367		}
1368	}
1369
1370	// build the replay route url for a stream identity, from is the resume byte offset, omitted
1371	// for the cancel route
1372	private static buildStreamUrl(streamId: string, from?: number): string {
1373		const query = `${STREAM_QUERY_PARAMS.CONV_ID}=${encodeURIComponent(streamId)}`;
1374		const offset = from === undefined ? '' : `&${STREAM_QUERY_PARAMS.FROM}=${from}`;
1375
1376		return `${API_STREAM.BASE}?${query}${offset}`;
1377	}
1378
1379	/**
1380	 * Extracts model name from Chat Completions API response data.
1381	 * Handles various response formats including streaming chunks and final responses.
1382	 *
1383	 * WORKAROUND: In single model mode, llama-server returns a default/incorrect model name
1384	 * in the response. We override it with the actual model name from serverStore.
1385	 *
1386	 * @param data - Raw response data from the Chat Completions API
1387	 * @returns Model name string if found, undefined otherwise
1388	 * @private
1389	 */
1390	private static extractModelName(data: unknown): string | undefined {
1391		const asRecord = (value: unknown): Record<string, unknown> | undefined => {
1392			return typeof value === 'object' && value !== null
1393				? (value as Record<string, unknown>)
1394				: undefined;
1395		};
1396		const getTrimmedString = (value: unknown): string | undefined => {
1397			return typeof value === 'string' && value.trim() ? value.trim() : undefined;
1398		};
1399		const root = asRecord(data);
1400
1401		if (!root) return undefined;
1402
1403		// 1) root (some implementations provide `model` at the top level)
1404		const rootModel = getTrimmedString(root.model);
1405
1406		if (rootModel) {
1407			return rootModel;
1408		}
1409
1410		// 2) streaming choice (delta) or final response (message)
1411		const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
1412
1413		if (!firstChoice) {
1414			return undefined;
1415		}
1416
1417		// priority: delta.model (first chunk) else message.model (final response)
1418		const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
1419
1420		if (deltaModel) {
1421			return deltaModel;
1422		}
1423
1424		const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
1425
1426		if (messageModel) {
1427			return messageModel;
1428		}
1429
1430		// avoid guessing from non-standard locations (metadata, etc.)
1431		return undefined;
1432	}
1433
1434	/**
1435	 * Handles non-streaming response from the chat completion API.
1436	 * Parses the JSON response and extracts the generated content.
1437	 *
1438	 * @param response - The fetch Response object containing the JSON data
1439	 * @param onComplete - Optional callback invoked when response is successfully parsed
1440	 * @param onError - Optional callback invoked if an error occurs while parsing
1441	 * @returns {Promise<string>} Promise that resolves to the generated content string
1442	 * @throws {Error} if the response cannot be parsed or is malformed
1443	 */
1444	private static async handleNonStreamResponse(
1445		response: Response,
1446		onComplete?: (
1447			response: string,
1448			reasoningContent?: string,
1449			timings?: ChatMessageTimings,
1450			toolCalls?: string
1451		) => void,
1452		onError?: (error: Error) => void,
1453		onToolCallChunk?: (chunk: string) => void,
1454		onModel?: (model: string) => void
1455	): Promise<string> {
1456		try {
1457			const responseText = await response.text();
1458
1459			if (!responseText.trim()) {
1460				const noResponseError = new Error('No response received from server. Please try again.');
1461
1462				throw noResponseError;
1463			}
1464
1465			const data: ApiChatCompletionResponse = JSON.parse(responseText);
1466			const responseModel = ChatService.extractModelName(data);
1467
1468			if (responseModel) {
1469				onModel?.(responseModel);
1470			}
1471
1472			const content = data.choices[0]?.message?.content || '';
1473			const reasoningContent = data.choices[0]?.message?.reasoning_content;
1474			const toolCalls = data.choices[0]?.message?.tool_calls;
1475
1476			let serializedToolCalls: string | undefined;
1477
1478			if (toolCalls && toolCalls.length > 0) {
1479				const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls);
1480
1481				if (mergedToolCalls.length > 0) {
1482					serializedToolCalls = JSON.stringify(mergedToolCalls);
1483
1484					if (serializedToolCalls) {
1485						onToolCallChunk?.(serializedToolCalls);
1486					}
1487				}
1488			}
1489
1490			if (!content.trim() && !serializedToolCalls) {
1491				const noResponseError = new Error('No response received from server. Please try again.');
1492
1493				throw noResponseError;
1494			}
1495
1496			onComplete?.(content, reasoningContent, undefined, serializedToolCalls);
1497
1498			return content;
1499		} catch (error) {
1500			const err = error instanceof Error ? error : new Error('Parse error');
1501
1502			onError?.(err);
1503
1504			throw err;
1505		}
1506	}
1507
1508	/**
1509	 * Merges tool call deltas into an existing array of tool calls.
1510	 * Handles both existing and new tool calls, updating existing ones and adding new ones.
1511	 *
1512	 * @param existing - The existing array of tool calls to merge into
1513	 * @param deltas - The array of tool call deltas to merge
1514	 * @param indexOffset - Optional offset to apply to the index of new tool calls
1515	 * @returns {ApiChatCompletionToolCall[]} The merged array of tool calls
1516	 */
1517	private static mergeToolCallDeltas(
1518		existing: ApiChatCompletionToolCall[],
1519		deltas: ApiChatCompletionToolCallDelta[],
1520		indexOffset = 0
1521	): ApiChatCompletionToolCall[] {
1522		const result = existing.map((call) => ({
1523			...call,
1524			function: call.function ? { ...call.function } : undefined
1525		}));
1526
1527		for (const delta of deltas) {
1528			const index =
1529				typeof delta.index === 'number' && delta.index >= 0
1530					? delta.index + indexOffset
1531					: result.length;
1532
1533			while (result.length <= index) {
1534				result.push({ function: undefined });
1535			}
1536
1537			const target = result[index]!;
1538
1539			if (delta.id) {
1540				target.id = delta.id;
1541			}
1542
1543			if (delta.type) {
1544				target.type = delta.type;
1545			}
1546
1547			if (delta.function) {
1548				const fn = target.function ? { ...target.function } : {};
1549
1550				if (delta.function.name) {
1551					fn.name = delta.function.name;
1552				}
1553
1554				if (delta.function.arguments) {
1555					fn.arguments = (fn.arguments ?? '') + delta.function.arguments;
1556				}
1557
1558				target.function = fn;
1559			}
1560		}
1561
1562		return result;
1563	}
1564
1565	/**
1566	 * Calls the onTimings callback with timing data from streaming response.
1567	 *
1568	 * @param timings - Timing information from the Chat Completions API response
1569	 * @param promptProgress - Prompt processing progress data
1570	 * @param onTimingsCallback - Callback function to invoke with timing data
1571	 * @private
1572	 */
1573	private static notifyTimings(
1574		timings: ChatMessageTimings | undefined,
1575		promptProgress: ChatMessagePromptProgress | undefined,
1576		onTimingsCallback:
1577			| ((timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void)
1578			| undefined
1579	): void {
1580		if (!onTimingsCallback || (!timings && !promptProgress)) return;
1581
1582		onTimingsCallback(timings, promptProgress);
1583	}
1584
1585	/**
1586	 * Parses error response and creates appropriate error with context information
1587	 * @param response - HTTP response object
1588	 * @returns Promise<Error> - Parsed error with context info if available
1589	 */
1590	private static async parseErrorResponse(
1591		response: Response
1592	): Promise<Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }> {
1593		try {
1594			const errorText = await response.text();
1595			const errorData: ApiErrorResponse = JSON.parse(errorText);
1596			const message = errorData.error?.message || 'Unknown server error';
1597			const error = new Error(message) as Error & {
1598				contextInfo?: { n_prompt_tokens: number; n_ctx: number };
1599			};
1600
1601			error.name = response.status === 400 ? 'ServerError' : 'HttpError';
1602
1603			if (errorData.error && 'n_prompt_tokens' in errorData.error && 'n_ctx' in errorData.error) {
1604				error.contextInfo = {
1605					n_ctx: errorData.error.n_ctx,
1606					n_prompt_tokens: errorData.error.n_prompt_tokens
1607				};
1608			}
1609
1610			return error;
1611		} catch {
1612			const fallback = new Error(
1613				`Server error (${response.status}): ${response.statusText}`
1614			) as Error & {
1615				contextInfo?: { n_prompt_tokens: number; n_ctx: number };
1616			};
1617
1618			fallback.name = 'HttpError';
1619
1620			return fallback;
1621		}
1622	}
1623
1624	/**
1625	 * Strips legacy inline reasoning content tags from message content.
1626	 * Handles both plain string content and multipart content arrays.
1627	 */
1628	private static stripReasoningContent(
1629		content: string | ApiChatMessageContentPart[]
1630	): string | ApiChatMessageContentPart[] {
1631		const stripFromString = (text: string): string =>
1632			text.replace(LEGACY_AGENTIC_REGEX.REASONING_BLOCK, '').trim();
1633
1634		if (typeof content === 'string') {
1635			return stripFromString(content);
1636		}
1637
1638		return content.map((part) => {
1639			if (part.type === ContentPartType.TEXT && part.text) {
1640				return { ...part, text: stripFromString(part.text) };
1641			}
1642
1643			return part;
1644		});
1645	}
1646
1647	// write the resume state straight to localStorage, bypassing the throttle
1648	private static writeStreamState(
1649		conversationId: string,
1650		bytesReceived: number,
1651		model?: string | null
1652	): void {
1653		try {
1654			const state: ResumableStreamState = {
1655				bytesReceived,
1656				model: model ?? null,
1657				updatedAt: Date.now()
1658			};
1659
1660			localStorage.setItem(streamStorageKey(conversationId), JSON.stringify(state));
1661		} catch {
1662			// localStorage may be full or disabled, silently ignore
1663		}
1664	}
1665}