char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
521a64cd0
char/aliceai-t5-moe
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 { 12API_CHAT , 13API_SLOTS , 14API_STREAM , 15CONTROL_ACTION , 16HEADERS , 17LEGACY_AGENTIC_REGEX , 18REASONING_EFFORT_TOKENS , 19SETTINGS_KEYS , 20SSE_DATA_PREFIX , 21SSE_DONE_MARKER , 22SSE_LINE_SEPARATOR , 23STREAM_QUERY_PARAMS , 24STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX , 25STREAM_VISIBILITY_KICK_MS 26} from '$lib/constants' ; 27import { 28AttachmentLabel , 29AttachmentType , 30ContentPartType , 31MessageRole , 32ReasoningFormat , 33StreamConnectionState 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 { 39ApiChatCompletionToolCall , 40ApiChatMessageContentPart , 41ApiChatMessageData , 42ApiStreamSession 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 { 51bytesReceived :number ; 52updatedAt :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 56model ?:string | null ; 57} 58 59function streamStorageKey ( conversationId :string ) :string { 60return 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. 71private static readonly STREAM_STATE_SAVE_INTERVAL_MS = 500 ; 72 73private static streamStateSaveTrackers = new Map < 74string , 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*/ 88static async areAllSlotsIdle ( model ?:string | null , signal ?:AbortSignal ) :Promise < boolean > { 89try { 90const url = model ?` ${ API_SLOTS . LIST } ?model= ${ encodeURIComponent ( model )} ` :API_SLOTS . LIST ; 91const res = await fetch ( url , { signal}); 92 93if ( ! res . ok ) return true ; 94 95const slots :{ is_processing :boolean }[] = await res . json (); 96 97return slots . every (( s ) => ! s . is_processing ); 98} catch { 99return true ; 100} 101} 102 103/** 104* Cancels the server-side replay buffer for a conversation, freeing its slot. 105*/ 106static async cancelServerStream ( conversationId :string , model ?:string | null ) :Promise < void > { 107if ( ! conversationId ) return ; 108 109try { 110const id = streamIdentity ( conversationId , model ); 111 112await fetch ( ChatService . buildStreamUrl ( id ), { 113headers :getAuthHeaders (), 114method :'DELETE' 115}); 116} catch ( e ) { 117console . warn ( 'cancelServerStream failed:' , e ); 118} 119} 120 121static clearStreamState ( conversationId :string ) :void { 122if ( ! conversationId ) return ; 123 124ChatService . streamStateSaveTrackers . delete ( conversationId ); 125 126try { 127localStorage . 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*/ 138static async convertDbMessageToApiChatMessageData ( 139message :DatabaseMessage & { extra ?:DatabaseMessageExtra [] } 140) :Promise < ApiChatMessageData > { 141// Handle tool result messages (role: 'tool') 142if ( message . role === MessageRole . TOOL && message . toolCallId ) { 143return { 144content :message . content , 145role :MessageRole . TOOL , 146tool_call_id :message . toolCallId 147}; 148} 149 150// Parse tool calls for assistant messages 151let toolCalls :ApiChatCompletionToolCall [] | undefined ; 152 153if ( message . toolCalls ) { 154try { 155toolCalls = JSON . parse ( message . toolCalls ); 156} catch { 157// Ignore parse errors for malformed tool calls 158} 159} 160 161if ( ! message . extra || message . extra . length === 0 ) { 162const result :ApiChatMessageData = { 163content :message . content , 164role :message . role as MessageRole 165}; 166 167if ( message . reasoningContent ) { 168result . reasoning_content = message . reasoningContent ; 169} 170 171if ( toolCalls && toolCalls . length > 0 ) { 172result . tool_calls = toolCalls ; 173} 174 175return result ; 176} 177 178const contentParts :ApiChatMessageContentPart [] = []; 179const textFiles = message . extra . filter ( 180( extra :DatabaseMessageExtra ) :extra isDatabaseMessageExtraTextFile => 181extra . type === AttachmentType . TEXT 182); 183 184for ( const textFile of textFiles ) { 185contentParts . push ({ 186text :formatAttachmentText ( AttachmentLabel . FILE , textFile . name , textFile . content ), 187type :ContentPartType . TEXT 188}); 189} 190 191// Handle legacy 'context' type from the old UI (pasted content) 192const legacyContextFiles = message . extra . filter ( 193( extra :DatabaseMessageExtra ) :extra isDatabaseMessageExtraLegacyContext => 194extra . type === AttachmentType . LEGACY_CONTEXT 195); 196 197for ( const legacyContextFile of legacyContextFiles ) { 198contentParts . push ({ 199text :formatAttachmentText ( 200AttachmentLabel . FILE , 201legacyContextFile . name , 202legacyContextFile . content 203), 204type :ContentPartType . TEXT 205}); 206} 207 208const imageFiles = message . extra . filter ( 209( extra :DatabaseMessageExtra ) :extra isDatabaseMessageExtraImageFile => 210extra . type === AttachmentType . IMAGE 211); 212 213for ( const image of imageFiles ) { 214const 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 217const base64Url = await capImageDataURLSize ( image . base64Url , maxImageResolution ); 218 219contentParts . push ({ 220image_url :{ url :base64Url }, 221type :ContentPartType . IMAGE_URL 222}); 223} 224 225const audioFiles = message . extra . filter ( 226( extra :DatabaseMessageExtra ) :extra isDatabaseMessageExtraAudioFile => 227extra . type === AttachmentType . AUDIO 228); 229 230for ( const audio of audioFiles ) { 231contentParts . push ({ 232input_audio :{ 233data :audio . base64Data , 234format :getAudioInputFormat ( audio . mimeType ) 235}, 236type :ContentPartType . INPUT_AUDIO 237}); 238} 239 240if ( message . content ) { 241contentParts . push ({ 242text :message . content , 243type :ContentPartType . TEXT 244}); 245} 246 247const videoFiles = message . extra . filter ( 248( extra :DatabaseMessageExtra ) :extra isDatabaseMessageExtraVideoFile => 249extra . type === AttachmentType . VIDEO 250); 251 252for ( const video of videoFiles ) { 253contentParts . push ({ 254input_video :{ 255data :video . base64Data , 256format :video . mimeType . includes ( 'mp4' ) 257 ?'mp4' 258 :video . mimeType . includes ( 'ogg' ) 259 ?'ogg' 260 :'auto' 261}, 262type :ContentPartType . INPUT_VIDEO 263}); 264} 265 266const pdfFiles = message . extra . filter ( 267( extra :DatabaseMessageExtra ) :extra isDatabaseMessageExtraPdfFile => 268extra . type === AttachmentType . ); 270 271for ( const pdfFile of pdfFiles ) { 272if ( pdfFile . processedAsImages && pdfFile . images ) { 273for ( let i = 0 ; i < pdfFile . images . length ; i ++ ) { 274contentParts . push ({ 275image_url :{ url :pdfFile . images [ i ] }, 276type :ContentPartType . IMAGE_URL 277}); 278} 279} else { 280contentParts . push ({ 281text :formatAttachmentText ( AttachmentLabel . PDF_FILE , pdfFile . name , pdfFile . content ), 282type :ContentPartType . TEXT 283}); 284} 285} 286 287const mcpPrompts = message . extra . filter ( 288( extra :DatabaseMessageExtra ) :extra isDatabaseMessageExtraMcpPrompt => 289extra . type === AttachmentType . MCP_PROMPT 290); 291 292for ( const mcpPrompt of mcpPrompts ) { 293contentParts . push ({ 294text :formatAttachmentText ( 295AttachmentLabel . MCP_PROMPT , 296mcpPrompt . name , 297mcpPrompt . content , 298mcpPrompt . serverName 299), 300type :ContentPartType . TEXT 301}); 302} 303 304const mcpResources = message . extra . filter ( 305( extra :DatabaseMessageExtra ) :extra isDatabaseMessageExtraMcpResource => 306extra . type === AttachmentType . MCP_RESOURCE 307); 308 309for ( const mcpResource of mcpResources ) { 310contentParts . push ({ 311text :formatAttachmentText ( 312AttachmentLabel . MCP_RESOURCE , 313mcpResource . name , 314mcpResource . content , 315mcpResource . serverName 316), 317type :ContentPartType . TEXT 318}); 319} 320 321const result :ApiChatMessageData = { 322content :contentParts , 323role :message . role as MessageRole 324}; 325 326if ( message . reasoningContent ) { 327result . reasoning_content = message . reasoningContent ; 328} 329 330if ( toolCalls && toolCalls . length > 0 ) { 331result . tool_calls = toolCalls ; 332} 333 334return 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*/ 341static async fetchStreamReplay ( streamId :string ) :Promise < Response > { 342const resp = await fetch ( ChatService . buildStreamUrl ( streamId , 0 ), { 343headers :getAuthHeaders () 344}); 345 346if ( ! resp . ok ) { 347throw new ApiError ( `Stream replay failed with HTTP ${ resp . status } ` , resp . status ); 348} 349 350return 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 356static flushStreamState ( conversationId :string ) :void { 357const tracker = ChatService . streamStateSaveTrackers . get ( conversationId ); 358 359if ( ! tracker || tracker . pendingBytes === null ) return ; 360 361const { model, pendingBytes} = tracker ; 362 363tracker . lastSavedAt = Date . now (); 364tracker . pendingBytes = null ; 365 366ChatService . 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*/ 379static async generateTitle ( 380message :ApiChatMessageData , 381model ?:string | null , 382signal ?:AbortSignal 383) :Promise < string > { 384let titleResponse = '' ; 385 386try { 387await ChatService . sendMessage ( 388[ message ], 389{ 390custom :{ chat_template_kwargs :{ enable_thinking :false } }, 391model :model || undefined , 392onChunk :( chunk :string ) => { 393titleResponse += chunk ; 394}, 395stream :true 396}, 397undefined , 398signal 399); 400} catch { 401return '' ; 402} 403 404return titleResponse ; 405} 406 407static getStreamState ( conversationId :string ) :ResumableStreamState | null { 408if ( ! conversationId ) return null ; 409 410try { 411const raw = localStorage . getItem ( streamStorageKey ( conversationId )); 412 413if ( ! raw ) return null ; 414 415const parsed = JSON . parse ( raw ) as ResumableStreamState ; 416 417if ( ! parsed || typeof parsed . bytesReceived !== 'number' ) return null ; 418 419return parsed ; 420} catch { 421return null ; 422} 423} 424 425/** 426* Handles streaming response from the chat completion API. 427*/ 428static async handleStreamResponse ( 429response :Response , 430onChunk ?:( chunk :string ) => void , 431onComplete ?:( 432response :string , 433reasoningContent ?:string , 434timings ?:ChatMessageTimings , 435toolCalls ?:string 436) => void , 437onError ?:( error :Error ) => void , 438onReasoningChunk ?:( chunk :string ) => void , 439onToolCallChunk ?:( chunk :string ) => void , 440onModel ?:( model :string ) => void , 441onCompletionId ?:( id :string ) => void , 442onTimings ?:( timings ?:ChatMessageTimings , promptProgress ?:ChatMessagePromptProgress ) => void , 443conversationId ?:string , 444abortSignal ?:AbortSignal , 445onConnectionState ?:( state :StreamConnectionState ) => void , 446streamModel ?:string | null 447) :Promise < void > { 448let reader = response . body ?. getReader (); 449 450if ( ! reader ) { 451throw 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 457let bytesParsed = 0 ; 458let segmentStartOffset = 0 ; 459let segmentBytesRead = 0 ; 460let 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 464let madeProgress = true ; 465 466const encoder = new TextEncoder (); 467 468if ( conversationId ) { 469ChatService . saveStreamState ( conversationId , 0 , streamModel ); 470} 471 472onConnectionState ?.( StreamConnectionState . STREAMING ); 473 474let decoder = new TextDecoder (); 475let aggregatedContent = '' ; 476let fullReasoningContent = '' ; 477let aggregatedToolCalls :ApiChatCompletionToolCall [] = []; 478let lastTimings :ChatMessageTimings | undefined ; 479let streamFinished = false ; 480let modelEmitted = false ; 481let idEmitted = false ; 482let toolCallIndexOffset = 0 ; 483let hasOpenToolCallBatch = false ; 484 485const finalizeOpenToolCallBatch = () => { 486if ( ! hasOpenToolCallBatch ) { 487return ; 488} 489 490toolCallIndexOffset = aggregatedToolCalls . length ; 491hasOpenToolCallBatch = false ; 492}; 493const processToolCallDelta = ( toolCalls ?:ApiChatCompletionToolCallDelta []) => { 494if ( ! toolCalls || toolCalls . length === 0 ) { 495return ; 496} 497 498aggregatedToolCalls = ChatService . mergeToolCallDeltas ( 499aggregatedToolCalls , 500toolCalls , 501toolCallIndexOffset 502); 503 504if ( aggregatedToolCalls . length === 0 ) { 505return ; 506} 507 508hasOpenToolCallBatch = true ; 509 510const serializedToolCalls = JSON . stringify ( aggregatedToolCalls ); 511 512if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) { 513console . log ( '[ChatService] Aggregated tool calls:' , serializedToolCalls ); 514} 515 516if ( ! serializedToolCalls ) { 517return ; 518} 519 520if ( ! abortSignal ?. aborted ) { 521onToolCallChunk ?.( serializedToolCalls ); 522} 523}; 524const onVisibilityChange = () => { 525if ( typeof document === 'undefined' ) return ; 526 527if ( 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 530if ( conversationId ) ChatService . flushStreamState ( conversationId ); 531 532return ; 533} 534 535if ( streamFinished ) return ; 536 537if ( ! 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 541if ( Date . now () - lastByteAt > STREAM_VISIBILITY_KICK_MS ) { 542reader ! . cancel (). catch (() => {}); 543} 544}; 545const 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 548if ( conversationId ) ChatService . flushStreamState ( conversationId ); 549}; 550 551if ( typeof document !== 'undefined' ) { 552document . addEventListener ( 'visibilitychange' , onVisibilityChange ); 553window . addEventListener ( 'pagehide' , onPageHide ); 554} 555 556try { 557let chunk = '' ; 558 559// outer loop drives the resume cycle, swaps reader on premature end of stream 560while ( true ) { 561while ( true ) { 562if ( abortSignal ?. aborted ) break ; 563 564let done :boolean ; 565let value :Uint8Array | undefined ; 566 567try { 568const r = await reader . read (); 569 570done = r . done ; 571value = 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 576if ( isAbortError ( readErr )) { 577throw readErr ; 578} 579 580console . warn ( 'reader.read() rejected, treating as premature end:' , readErr ); 581done = true ; 582value = undefined ; 583} 584 585if ( done ) break ; 586 587if ( abortSignal ?. aborted ) break ; 588 589if ( value && value . byteLength > 0 ) { 590segmentBytesRead += value . byteLength ; 591lastByteAt = Date . now (); 592 593if ( ! madeProgress ) { 594madeProgress = true ; 595onConnectionState ?.( StreamConnectionState . STREAMING ); 596} 597} 598 599chunk += decoder . decode ( value , { stream :true }); 600const lines = chunk . split ( SSE_LINE_SEPARATOR ); 601 602chunk = 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 606if ( conversationId ) { 607const tailBytes = encoder . encode ( chunk ). byteLength ; 608 609bytesParsed = segmentStartOffset + segmentBytesRead - tailBytes ; 610ChatService . saveStreamStateThrottled ( conversationId , bytesParsed , streamModel ); 611} 612 613for ( const line of lines ) { 614if ( abortSignal ?. aborted ) break ; 615 616if ( line . startsWith ( SSE_DATA_PREFIX )) { 617const data = line . slice ( SSE_DATA_PREFIX . length ). trim (); 618 619if ( data === SSE_DONE_MARKER ) { 620streamFinished = true ; 621 622continue ; 623} 624 625try { 626const parsed :ApiChatCompletionStreamChunk = JSON . parse ( data ); 627const choice = parsed . choices ?.[ 0 ]; 628const content = choice ?. delta ?. content ; 629const reasoningContent = choice ?. delta ?. reasoning_content ; 630const toolCalls = choice ?. delta ?. tool_calls ; 631const timings = parsed . timings ; 632const promptProgress = parsed . prompt_progress ; 633const chunkModel = ChatService . extractModelName ( parsed ); 634 635if ( chunkModel && ! modelEmitted ) { 636modelEmitted = true ; 637onModel ?.( chunkModel ); 638} 639 640if ( parsed . id && ! idEmitted ) { 641idEmitted = true ; 642onCompletionId ?.( parsed . id ); 643} 644 645if ( promptProgress ) { 646ChatService . notifyTimings ( undefined , promptProgress , onTimings ); 647} 648 649if ( timings ) { 650ChatService . notifyTimings ( timings , promptProgress , onTimings ); 651lastTimings = timings ; 652} 653 654if ( content ) { 655finalizeOpenToolCallBatch (); 656aggregatedContent += content ; 657 658if ( ! abortSignal ?. aborted ) { 659onChunk ?.( content ); 660} 661} 662 663if ( reasoningContent ) { 664finalizeOpenToolCallBatch (); 665fullReasoningContent += reasoningContent ; 666 667if ( ! abortSignal ?. aborted ) { 668onReasoningChunk ?.( reasoningContent ); 669} 670} 671 672processToolCallDelta ( toolCalls ); 673} catch ( e ) { 674console . error ( 'Error parsing JSON chunk:' , e ); 675} 676} 677} 678 679if ( abortSignal ?. aborted ) break ; 680 681if ( streamFinished ) break ; 682} 683 684// inner reader done, decide whether to try a resume 685if ( abortSignal ?. aborted ) break ; 686 687if ( streamFinished ) break ; 688 689if ( ! conversationId ) break ; 690 691if ( ! madeProgress ) { 692onConnectionState ?.( StreamConnectionState . LOST ); 693onError ?.( new Error ( 'Stream resume produced no new bytes, giving up' )); 694 695break ; 696} 697 698onConnectionState ?.( StreamConnectionState . RESUMING ); 699madeProgress = 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 706ChatService . flushStreamState ( conversationId ); 707const resumeResp = await ChatService . resumeStream ( 708conversationId , 709abortSignal , 710streamModel 711). catch (() => null ); 712 713// an abort landing during the resume request is intentional, not a lost connection 714if ( abortSignal ?. aborted ) break ; 715 716if ( ! resumeResp || resumeResp . status !== 200 ) { 717onConnectionState ?.( StreamConnectionState . LOST ); 718onError ?.( new Error ( 'Stream connection lost and could not be resumed' )); 719 720break ; 721} 722 723const newReader = resumeResp . body ?. getReader (); 724 725if ( ! newReader ) break ; 726 727try { 728reader . releaseLock (); 729} catch { 730/* ignore */ 731} 732reader = newReader ; 733decoder = new TextDecoder (); 734chunk = '' ; 735segmentStartOffset = bytesParsed ; 736segmentBytesRead = 0 ; 737lastByteAt = Date . now (); 738} 739 740if ( abortSignal ?. aborted ) return ; 741 742if ( streamFinished ) { 743finalizeOpenToolCallBatch (); 744 745if ( conversationId ) { 746ChatService . clearStreamState ( conversationId ); 747} 748 749const finalToolCalls = 750aggregatedToolCalls . length > 0 ?JSON . stringify ( aggregatedToolCalls ) :undefined ; 751 752onComplete ?.( 753aggregatedContent , 754fullReasoningContent || undefined , 755lastTimings , 756finalToolCalls 757); 758} 759} catch ( error ) { 760const err = error instanceof Error ?error :new Error ( 'Stream error' ); 761 762onError ?.( err ); 763 764throw err ; 765} finally { 766if ( typeof document !== 'undefined' ) { 767document . removeEventListener ( 'visibilitychange' , onVisibilityChange ); 768window . removeEventListener ( 'pagehide' , onPageHide ); 769} 770 771try { 772reader . 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*/ 783static async lookupStreamSessions ( conversationIds :string []) :Promise < ApiStreamSession []> { 784const resp = await fetch ( API_STREAM . LOOKUP , { 785body :JSON . stringify ({ conversation_ids :conversationIds }), 786headers :getJsonHeaders (), 787method :'POST' 788}); 789 790if ( ! resp . ok ) { 791throw new ApiError ( `Stream lookup failed with HTTP ${ resp . status } ` , resp . status ); 792} 793 794const body = ( await resp . json ()) as unknown ; 795 796if ( ! Array . isArray ( body )) { 797throw new Error ( 'Stream lookup returned a non-array response' ); 798} 799 800return 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*/ 808static async normalizeMessagesForApi ( 809messages :ApiChatMessageData [] | ( DatabaseMessage & { extra ?:DatabaseMessageExtra [] })[] 810) :Promise < ApiChatMessageData []> { 811return ( 812await Promise . all ( 813messages . map (( msg ) => { 814if ( 'id' in msg && 'convId' in msg && 'timestamp' in msg ) { 815return ChatService . convertDbMessageToApiChatMessageData ( 816msg as DatabaseMessage & { extra ?:DatabaseMessageExtra [] } 817); 818} 819 820return msg as ApiChatMessageData ; 821}) 822) 823). filter (( msg :{ role :ChatRole ; content :string | ApiChatMessageContentPart [] }) => { 824// Filter out empty system messages 825if ( msg . role === MessageRole . SYSTEM ) { 826const content = typeof msg . content === 'string' ?msg . content :'' ; 827 828return content . trim (). length > 0 ; 829} 830 831return 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*/ 840static async preEncode ( 841messages :ApiChatMessageData [] | ( DatabaseMessage & { extra ?:DatabaseMessageExtra [] })[], 842model ?:string | null , 843excludeReasoning ?:boolean , 844signal ?:AbortSignal 845) :Promise < void > { 846const normalizedMessages :ApiChatMessageData [] = 847await ChatService . normalizeMessagesForApi ( messages ); 848const requestBody :Record < string , unknown > = { 849messages :normalizedMessages . map (( msg :ApiChatMessageData ) => { 850const mapped :Record < string , unknown > = { 851content :excludeReasoning ?ChatService . stripReasoningContent ( msg . content ) :msg . content , 852role :msg . role , 853tool_call_id :msg . tool_call_id , 854tool_calls :msg . tool_calls 855}; 856 857if ( ! excludeReasoning && msg . reasoning_content ) { 858mapped . reasoning_content = msg . reasoning_content ; 859} 860 861return mapped ; 862}), 863n_predict :0 , 864stream :false 865}; 866 867if ( model ) { 868requestBody . model = model ; 869} 870 871try { 872await fetch ( API_CHAT . COMPLETIONS , { 873body :JSON . stringify ( requestBody ), 874headers :getJsonHeaders (), 875method :'POST' , 876 signal 877}); 878} catch ( error ) { 879if ( ! isAbortError ( error )) { 880console . 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 887static async probeResumeStatus ( streamId :string ) :Promise < number > { 888if ( ! streamId ) return 0 ; 889 890const ac = new AbortController (); 891 892try { 893const resp = await fetch ( ChatService . buildStreamUrl ( streamId , 0 ), { 894headers :getAuthHeaders (), 895signal :ac . signal 896}); 897 898ac . abort (); 899 900return resp . status ; 901} catch { 902return 0 ; 903} 904} 905 906static async resumeStream ( 907conversationId :string , 908signal ?:AbortSignal , 909model ?:string | null 910) :Promise < Response | null > { 911if ( ! conversationId ) return null ; 912 913const state = ChatService . getStreamState ( conversationId ); 914const from = state ?. bytesReceived ?? 0 ; 915const id = streamIdentity ( conversationId , model ); 916const url = ChatService . buildStreamUrl ( id , from ); 917 918return 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*/ 926static resumeStreamIdentity ( 927conversationId :string , 928state :ResumableStreamState | null , 929fallbackModel :string | null 930) :string { 931const model = state && state . model !== undefined ?state . model :fallbackModel ; 932 933return 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. 940static saveStreamState ( 941conversationId :string , 942bytesReceived :number , 943model ?:string | null 944) :void { 945if ( ! conversationId ) return ; 946 947ChatService . 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 950ChatService . streamStateSaveTrackers . set ( conversationId , { 951lastSavedAt :Date . now (), 952model :model ?? null , 953pendingBytes :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 960static saveStreamStateThrottled ( 961conversationId :string , 962bytesReceived :number , 963model ?:string | null 964) :void { 965if ( ! conversationId ) return ; 966 967const tracker = ChatService . streamStateSaveTrackers . get ( conversationId ) ?? { 968lastSavedAt :0 , 969model :null , 970pendingBytes :null 971}; 972 973tracker . model = model ?? null ; 974 975if ( Date . now () - tracker . lastSavedAt >= ChatService . STREAM_STATE_SAVE_INTERVAL_MS ) { 976tracker . lastSavedAt = Date . now (); 977tracker . pendingBytes = null ; 978ChatService . writeStreamState ( conversationId , bytesReceived , model ); 979} else { 980tracker . pendingBytes = bytesReceived ; 981} 982 983ChatService . 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*/ 996static selectActiveStream ( 997sessions :ApiStreamSession [] | null | undefined 998) :ApiStreamSession | null { 999if ( ! Array . isArray ( sessions ) || sessions . length === 0 ) { 1000return null ; 1001} 1002 1003const running = sessions . filter (( s ) => ! s . is_done ); 1004 1005if ( running . length === 0 ) { 1006return null ; 1007} 1008 1009return 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*/ 1022static async sendMessage ( 1023messages :ApiChatMessageData [] | ( DatabaseMessage & { extra ?:DatabaseMessageExtra [] })[], 1024options :SettingsChatServiceOptions = {}, 1025conversationId ?:string , 1026signal ?:AbortSignal 1027) :Promise < string | void > { 1028const { 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 ; 1074const normalizedMessages :ApiChatMessageData [] = 1075await ChatService . normalizeMessagesForApi ( messages ); 1076 1077// Filter out image attachments if the model doesn't support vision 1078if ( options . model && ! modelsStore . props . modelSupportsVision ( options . model )) { 1079normalizedMessages . forEach (( msg ) => { 1080if ( Array . isArray ( msg . content )) { 1081msg . content = msg . content . filter (( part :ApiChatMessageContentPart ) => { 1082if ( part . type === ContentPartType . IMAGE_URL ) { 1083console . info ( 1084`[ChatService] Skipping image attachment in message history (model " ${ options . model } " does not support vision)` 1085); 1086 1087return false ; 1088} 1089 1090return true ; 1091}); 1092 1093// If only text remains and it's a single part, simplify to string 1094if ( 1095msg . content . length === 1 && 1096msg . content [ 0 ]. type === ContentPartType . TEXT && 1097typeof msg . content [ 0 ]. text === 'string' 1098) { 1099msg . content = msg . content [ 0 ]. text ; 1100} 1101} 1102}); 1103} 1104 1105const requestBody :ApiChatCompletionRequest = { 1106messages :normalizedMessages . map (( msg :ApiChatMessageData ) => { 1107const mapped :ApiChatCompletionRequest [ 'messages' ][ 0 ] = { 1108content :msg . content , 1109role :msg . role , 1110tool_call_id :msg . tool_call_id , 1111tool_calls :msg . tool_calls 1112}; 1113 1114// Include reasoning_content from the dedicated field 1115if ( ! excludeReasoningFromContext && msg . reasoning_content ) { 1116mapped . reasoning_content = msg . reasoning_content ; 1117} 1118 1119return mapped ; 1120}), 1121return_progress :stream ?true :undefined , 1122sse_ping_interval :stream ?1 :undefined , 1123 stream, 1124tools :tools && tools . length > 0 ?tools :undefined 1125}; 1126 1127// Include model in request if provided (required in ROUTER mode) 1128if ( options . model ) { 1129requestBody . model = options . model ; 1130} 1131 1132requestBody . reasoning_format = disableReasoningParsing 1133 ?ReasoningFormat . NONE 1134 :ReasoningFormat . AUTO ; 1135 1136const reasoningBudgetTokens = 1137enableThinking && 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) 1141if ( enableThinking !== undefined ) { 1142requestBody . chat_template_kwargs = { 1143 ...( requestBody . chat_template_kwargs ?? {}), 1144enable_thinking :enableThinking 1145}; 1146} 1147 1148if ( reasoningBudgetTokens >= 0 ) { 1149requestBody . thinking_budget_tokens = reasoningBudgetTokens ; 1150} 1151 1152// arms the budget sampler so reasoning can be ended at runtime via the control endpoint 1153requestBody . reasoning_control = true ; 1154 1155if ( continueFinalMessage ) { 1156requestBody . continue_final_message = true ; 1157requestBody . add_generation_prompt = false ; 1158} 1159 1160if ( temperature !== undefined ) requestBody . temperature = temperature ; 1161 1162if ( max_tokens !== undefined ) { 1163// Set max_tokens to -1 (infinite) when explicitly configured as 0 or null 1164requestBody . max_tokens = max_tokens !== null && max_tokens !== 0 ?max_tokens :- 1 ; 1165} 1166 1167if ( dynatemp_range !== undefined ) requestBody . dynatemp_range = dynatemp_range ; 1168 1169if ( dynatemp_exponent !== undefined ) requestBody . dynatemp_exponent = dynatemp_exponent ; 1170 1171if ( top_k !== undefined ) requestBody . top_k = top_k ; 1172 1173if ( top_p !== undefined ) requestBody . top_p = top_p ; 1174 1175if ( min_p !== undefined ) requestBody . min_p = min_p ; 1176 1177if ( xtc_probability !== undefined ) requestBody . xtc_probability = xtc_probability ; 1178 1179if ( xtc_threshold !== undefined ) requestBody . xtc_threshold = xtc_threshold ; 1180 1181if ( typ_p !== undefined ) requestBody . typ_p = typ_p ; 1182 1183if ( repeat_last_n !== undefined ) requestBody . repeat_last_n = repeat_last_n ; 1184 1185if ( repeat_penalty !== undefined ) requestBody . repeat_penalty = repeat_penalty ; 1186 1187if ( presence_penalty !== undefined ) requestBody . presence_penalty = presence_penalty ; 1188 1189if ( frequency_penalty !== undefined ) requestBody . frequency_penalty = frequency_penalty ; 1190 1191if ( dry_multiplier !== undefined ) requestBody . dry_multiplier = dry_multiplier ; 1192 1193if ( dry_base !== undefined ) requestBody . dry_base = dry_base ; 1194 1195if ( dry_allowed_length !== undefined ) requestBody . dry_allowed_length = dry_allowed_length ; 1196 1197if ( dry_penalty_last_n !== undefined ) requestBody . dry_penalty_last_n = dry_penalty_last_n ; 1198 1199if ( samplers !== undefined ) { 1200requestBody . samplers = 1201typeof samplers === 'string' 1202 ?samplers . split ( ';' ). filter (( s :string ) => s . trim ()) 1203 :samplers ; 1204} 1205 1206if ( backend_sampling !== undefined ) requestBody . backend_sampling = backend_sampling ; 1207 1208if ( timings_per_token !== undefined ) requestBody . timings_per_token = timings_per_token ; 1209 1210if ( custom ) { 1211try { 1212const customParams = typeof custom === 'string' ?JSON . parse ( custom ) :custom ; 1213 1214Object . assign ( requestBody , customParams ); 1215} catch ( error ) { 1216console . warn ( 'Failed to parse custom parameters:' , error ); 1217} 1218} 1219 1220try { 1221const 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 1226if ( stream && conversationId ) { 1227headers [ 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 1230ChatService . saveStreamState ( conversationId , 0 , options . model ?? null ); 1231} 1232 1233const response = await fetch ( API_CHAT . COMPLETIONS , { 1234body :JSON . stringify ( requestBody ), 1235 headers, 1236method :'POST' , 1237 signal 1238}); 1239 1240if ( ! response . ok ) { 1241// a rejected request (including one cancelled by a stop during the model load) 1242// leaves nothing to resume 1243if ( conversationId ) { 1244ChatService . clearStreamState ( conversationId ); 1245} 1246 1247const error = await ChatService . parseErrorResponse ( response ); 1248 1249if ( onError ) { 1250onError ( error ); 1251} 1252 1253throw error ; 1254} 1255 1256if ( stream ) { 1257await ChatService . handleStreamResponse ( 1258response , 1259onChunk , 1260onComplete , 1261onError , 1262onReasoningChunk , 1263onToolCallChunk , 1264onModel , 1265onCompletionId , 1266onTimings , 1267conversationId , 1268signal , 1269onConnectionState , 1270options . model 1271); 1272 1273return ; 1274} else { 1275return ChatService . handleNonStreamResponse ( 1276response , 1277onComplete , 1278onError , 1279onToolCallChunk , 1280onModel 1281); 1282} 1283} catch ( error ) { 1284if ( isAbortError ( error )) { 1285console . log ( 'Chat completion request was aborted' ); 1286 1287return ; 1288} 1289 1290let userFriendlyError :Error ; 1291 1292if ( error instanceof Error ) { 1293if ( error . name === 'TypeError' && error . message . includes ( 'fetch' )) { 1294userFriendlyError = new Error ( 1295'Unable to connect to server - please check if the server is running' 1296); 1297userFriendlyError . name = 'NetworkError' ; 1298} else if ( error . message . includes ( 'ECONNREFUSED' )) { 1299userFriendlyError = new Error ( 'Connection refused - server may be offline' ); 1300userFriendlyError . name = 'NetworkError' ; 1301} else if ( error . message . includes ( 'ETIMEDOUT' )) { 1302userFriendlyError = new Error ( 'Request timed out - the server took too long to respond' ); 1303userFriendlyError . name = 'TimeoutError' ; 1304} else { 1305userFriendlyError = error ; 1306} 1307} else { 1308userFriendlyError = new Error ( 'Unknown error occurred while sending message' ); 1309} 1310 1311console . error ( 'Error in sendMessage:' , error ); 1312 1313if ( onError ) { 1314onError ( userFriendlyError ); 1315} 1316 1317throw 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*/ 1328static async stopReasoning ( completionId :string , model ?:string | null ) :Promise < boolean > { 1329if ( ! completionId ) { 1330console . error ( 1331'stopReasoning: no completion id for the active message, cannot target the running completion' 1332); 1333 1334return false ; 1335} 1336 1337const body :Record < string , unknown > = { 1338action :CONTROL_ACTION . END_REASONING , 1339id :completionId 1340}; 1341 1342if ( model ) body . model = model ; 1343 1344try { 1345const res = await fetch ( API_CHAT . CONTROL , { 1346body :JSON . stringify ( body ), 1347headers :getJsonHeaders (), 1348method :'POST' 1349}); 1350const data = await res . json (). catch (() => null ); 1351 1352if ( ! res . ok || data ?. success !== true ) { 1353console . error ( 'stopReasoning: control request failed' , { 1354 completionId, 1355response :data , 1356status :res . status 1357}); 1358 1359return false ; 1360} 1361 1362return true ; 1363} catch ( error ) { 1364console . error ( 'stopReasoning: control request threw' , { completionId, error}); 1365 1366return 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 1372private static buildStreamUrl ( streamId :string , from ?:number ) :string { 1373const query = ` ${ STREAM_QUERY_PARAMS . CONV_ID } = ${ encodeURIComponent ( streamId )} ` ; 1374const offset = from === undefined ?'' :`& ${ STREAM_QUERY_PARAMS . FROM } = ${ from } ` ; 1375 1376return ` ${ 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*/ 1390private static extractModelName ( data :unknown ) :string | undefined { 1391const asRecord = ( value :unknown ) :Record < string , unknown > | undefined => { 1392return typeof value === 'object' && value !== null 1393 ?( value as Record < string , unknown >) 1394 :undefined ; 1395}; 1396const getTrimmedString = ( value :unknown ) :string | undefined => { 1397return typeof value === 'string' && value . trim () ?value . trim () :undefined ; 1398}; 1399const root = asRecord ( data ); 1400 1401if ( ! root ) return undefined ; 1402 1403// 1) root (some implementations provide `model` at the top level) 1404const rootModel = getTrimmedString ( root . model ); 1405 1406if ( rootModel ) { 1407return rootModel ; 1408} 1409 1410// 2) streaming choice (delta) or final response (message) 1411const firstChoice = Array . isArray ( root . choices ) ?asRecord ( root . choices [ 0 ]) :undefined ; 1412 1413if ( ! firstChoice ) { 1414return undefined ; 1415} 1416 1417// priority: delta.model (first chunk) else message.model (final response) 1418const deltaModel = getTrimmedString ( asRecord ( firstChoice . delta )?. model ); 1419 1420if ( deltaModel ) { 1421return deltaModel ; 1422} 1423 1424const messageModel = getTrimmedString ( asRecord ( firstChoice . message )?. model ); 1425 1426if ( messageModel ) { 1427return messageModel ; 1428} 1429 1430// avoid guessing from non-standard locations (metadata, etc.) 1431return 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*/ 1444private static async handleNonStreamResponse ( 1445response :Response , 1446onComplete ?:( 1447response :string , 1448reasoningContent ?:string , 1449timings ?:ChatMessageTimings , 1450toolCalls ?:string 1451) => void , 1452onError ?:( error :Error ) => void , 1453onToolCallChunk ?:( chunk :string ) => void , 1454onModel ?:( model :string ) => void 1455) :Promise < string > { 1456try { 1457const responseText = await response . text (); 1458 1459if ( ! responseText . trim ()) { 1460const noResponseError = new Error ( 'No response received from server. Please try again.' ); 1461 1462throw noResponseError ; 1463} 1464 1465const data :ApiChatCompletionResponse = JSON . parse ( responseText ); 1466const responseModel = ChatService . extractModelName ( data ); 1467 1468if ( responseModel ) { 1469onModel ?.( responseModel ); 1470} 1471 1472const content = data . choices [ 0 ]?. message ?. content || '' ; 1473const reasoningContent = data . choices [ 0 ]?. message ?. reasoning_content ; 1474const toolCalls = data . choices [ 0 ]?. message ?. tool_calls ; 1475 1476let serializedToolCalls :string | undefined ; 1477 1478if ( toolCalls && toolCalls . length > 0 ) { 1479const mergedToolCalls = ChatService . mergeToolCallDeltas ([], toolCalls ); 1480 1481if ( mergedToolCalls . length > 0 ) { 1482serializedToolCalls = JSON . stringify ( mergedToolCalls ); 1483 1484if ( serializedToolCalls ) { 1485onToolCallChunk ?.( serializedToolCalls ); 1486} 1487} 1488} 1489 1490if ( ! content . trim () && ! serializedToolCalls ) { 1491const noResponseError = new Error ( 'No response received from server. Please try again.' ); 1492 1493throw noResponseError ; 1494} 1495 1496onComplete ?.( content , reasoningContent , undefined , serializedToolCalls ); 1497 1498return content ; 1499} catch ( error ) { 1500const err = error instanceof Error ?error :new Error ( 'Parse error' ); 1501 1502onError ?.( err ); 1503 1504throw 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*/ 1517private static mergeToolCallDeltas ( 1518existing :ApiChatCompletionToolCall [], 1519deltas :ApiChatCompletionToolCallDelta [], 1520indexOffset = 0 1521) :ApiChatCompletionToolCall [] { 1522const result = existing . map (( call ) => ({ 1523 ...call , 1524function :call . function ?{ ...call . function } :undefined 1525})); 1526 1527for ( const delta of deltas ) { 1528const index = 1529typeof delta . index === 'number' && delta . index >= 0 1530 ?delta . index + indexOffset 1531 :result . length ; 1532 1533while ( result . length <= index ) { 1534result . push ({ function :undefined }); 1535} 1536 1537const target = result [ index ] ! ; 1538 1539if ( delta . id ) { 1540target . id = delta . id ; 1541} 1542 1543if ( delta . type ) { 1544target . type = delta . type ; 1545} 1546 1547if ( delta . function ) { 1548const fn = target . function ?{ ...target . function } :{}; 1549 1550if ( delta . function . name ) { 1551fn . name = delta . function . name ; 1552} 1553 1554if ( delta . function . arguments ) { 1555fn . arguments = ( fn . arguments ?? '' ) + delta . function . arguments ; 1556} 1557 1558target . function = fn ; 1559} 1560} 1561 1562return 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*/ 1573private static notifyTimings ( 1574timings :ChatMessageTimings | undefined , 1575promptProgress :ChatMessagePromptProgress | undefined , 1576onTimingsCallback : 1577| (( timings ?:ChatMessageTimings , promptProgress ?:ChatMessagePromptProgress ) => void ) 1578| undefined 1579) :void { 1580if ( ! onTimingsCallback || ( ! timings && ! promptProgress )) return ; 1581 1582onTimingsCallback ( 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*/ 1590private static async parseErrorResponse ( 1591response :Response 1592) :Promise < Error & { contextInfo ?:{ n_prompt_tokens :number ; n_ctx :number } }> { 1593try { 1594const errorText = await response . text (); 1595const errorData :ApiErrorResponse = JSON . parse ( errorText ); 1596const message = errorData . error ?. message || 'Unknown server error' ; 1597const error = new Error ( message ) as Error & { 1598contextInfo ?:{ n_prompt_tokens :number ; n_ctx :number }; 1599}; 1600 1601error . name = response . status === 400 ?'ServerError' :'HttpError' ; 1602 1603if ( errorData . error && 'n_prompt_tokens' in errorData . error && 'n_ctx' in errorData . error ) { 1604error . contextInfo = { 1605n_ctx :errorData . error . n_ctx , 1606n_prompt_tokens :errorData . error . n_prompt_tokens 1607}; 1608} 1609 1610return error ; 1611} catch { 1612const fallback = new Error ( 1613`Server error ( ${ response . status } ): ${ response . statusText } ` 1614) as Error & { 1615contextInfo ?:{ n_prompt_tokens :number ; n_ctx :number }; 1616}; 1617 1618fallback . name = 'HttpError' ; 1619 1620return 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*/ 1628private static stripReasoningContent ( 1629content :string | ApiChatMessageContentPart [] 1630) :string | ApiChatMessageContentPart [] { 1631const stripFromString = ( text :string ) :string => 1632text . replace ( LEGACY_AGENTIC_REGEX . REASONING_BLOCK , '' ). trim (); 1633 1634if ( typeof content === 'string' ) { 1635return stripFromString ( content ); 1636} 1637 1638return content . map (( part ) => { 1639if ( part . type === ContentPartType . TEXT && part . text ) { 1640return { ...part , text :stripFromString ( part . text ) }; 1641} 1642 1643return part ; 1644}); 1645} 1646 1647// write the resume state straight to localStorage, bypassing the throttle 1648private static writeStreamState ( 1649conversationId :string , 1650bytesReceived :number , 1651model ?:string | null 1652) :void { 1653try { 1654const state :ResumableStreamState = { 1655 bytesReceived, 1656model :model ?? null , 1657updatedAt :Date . now () 1658}; 1659 1660localStorage . setItem ( streamStorageKey ( conversationId ), JSON . stringify ( state )); 1661} catch { 1662// localStorage may be full or disabled, silently ignore 1663} 1664} 1665}