| | import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
|
| | import { ATTACHMENT_LABEL_PDF_FILE } from '$lib/constants/attachment-labels';
|
| | import {
|
| | AttachmentType,
|
| | ContentPartType,
|
| | MessageRole,
|
| | ReasoningFormat,
|
| | UrlPrefix
|
| | } from '$lib/enums';
|
| | import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
|
| | import { modelsStore } from '$lib/stores/models.svelte';
|
| | import { AGENTIC_REGEX } from '$lib/constants/agentic';
|
| |
|
| | export class ChatService {
|
| | private static stripReasoningContent(
|
| | content: ApiChatMessageData['content'] | null | undefined
|
| | ): ApiChatMessageData['content'] | null | undefined {
|
| | if (!content) {
|
| | return content;
|
| | }
|
| |
|
| | if (typeof content === 'string') {
|
| | return content
|
| | .replace(AGENTIC_REGEX.REASONING_BLOCK, '')
|
| | .replace(AGENTIC_REGEX.REASONING_OPEN, '');
|
| | }
|
| |
|
| | if (!Array.isArray(content)) {
|
| | return content;
|
| | }
|
| |
|
| | return content.map((part: ApiChatMessageContentPart) => {
|
| | if (part.type !== ContentPartType.TEXT || !part.text) return part;
|
| | return {
|
| | ...part,
|
| | text: part.text
|
| | .replace(AGENTIC_REGEX.REASONING_BLOCK, '')
|
| | .replace(AGENTIC_REGEX.REASONING_OPEN, '')
|
| | };
|
| | });
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | static async sendMessage(
|
| | messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
|
| | options: SettingsChatServiceOptions = {},
|
| | conversationId?: string,
|
| | signal?: AbortSignal
|
| | ): Promise<string | void> {
|
| | const {
|
| | stream,
|
| | onChunk,
|
| | onComplete,
|
| | onError,
|
| | onReasoningChunk,
|
| | onToolCallChunk,
|
| | onModel,
|
| | onTimings,
|
| |
|
| | tools,
|
| |
|
| | temperature,
|
| | max_tokens,
|
| |
|
| | dynatemp_range,
|
| | dynatemp_exponent,
|
| | top_k,
|
| | top_p,
|
| | min_p,
|
| | xtc_probability,
|
| | xtc_threshold,
|
| | typ_p,
|
| |
|
| | repeat_last_n,
|
| | repeat_penalty,
|
| | presence_penalty,
|
| | frequency_penalty,
|
| | dry_multiplier,
|
| | dry_base,
|
| | dry_allowed_length,
|
| | dry_penalty_last_n,
|
| |
|
| | samplers,
|
| | backend_sampling,
|
| | custom,
|
| | timings_per_token,
|
| |
|
| | disableReasoningParsing
|
| | } = options;
|
| |
|
| | const normalizedMessages: ApiChatMessageData[] = messages
|
| | .map((msg) => {
|
| | if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
|
| | const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] };
|
| |
|
| | return ChatService.convertDbMessageToApiChatMessageData(dbMsg);
|
| | } else {
|
| | return msg as ApiChatMessageData;
|
| | }
|
| | })
|
| | .filter((msg) => {
|
| |
|
| | if (msg.role === MessageRole.SYSTEM) {
|
| | const content = typeof msg.content === 'string' ? msg.content : '';
|
| |
|
| | return content.trim().length > 0;
|
| | }
|
| |
|
| | return true;
|
| | });
|
| |
|
| |
|
| | if (options.model && !modelsStore.modelSupportsVision(options.model)) {
|
| | normalizedMessages.forEach((msg) => {
|
| | if (Array.isArray(msg.content)) {
|
| | msg.content = msg.content.filter((part: ApiChatMessageContentPart) => {
|
| | if (part.type === ContentPartType.IMAGE_URL) {
|
| | console.info(
|
| | `[ChatService] Skipping image attachment in message history (model "${options.model}" does not support vision)`
|
| | );
|
| |
|
| | return false;
|
| | }
|
| |
|
| | return true;
|
| | });
|
| |
|
| | if (msg.content.length === 1 && msg.content[0].type === ContentPartType.TEXT) {
|
| | msg.content = msg.content[0].text;
|
| | }
|
| | }
|
| | });
|
| | }
|
| |
|
| | const requestBody: ApiChatCompletionRequest = {
|
| | messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
|
| | role: msg.role,
|
| |
|
| |
|
| | content: ChatService.stripReasoningContent(msg.content),
|
| | tool_calls: msg.tool_calls,
|
| | tool_call_id: msg.tool_call_id
|
| | })),
|
| | stream,
|
| | return_progress: stream ? true : undefined,
|
| | tools: tools && tools.length > 0 ? tools : undefined
|
| | };
|
| |
|
| |
|
| | if (options.model) {
|
| | requestBody.model = options.model;
|
| | }
|
| |
|
| | requestBody.reasoning_format = disableReasoningParsing
|
| | ? ReasoningFormat.NONE
|
| | : ReasoningFormat.AUTO;
|
| |
|
| | if (temperature !== undefined) requestBody.temperature = temperature;
|
| | if (max_tokens !== undefined) {
|
| |
|
| | requestBody.max_tokens = max_tokens !== null && max_tokens !== 0 ? max_tokens : -1;
|
| | }
|
| |
|
| | if (dynatemp_range !== undefined) requestBody.dynatemp_range = dynatemp_range;
|
| | if (dynatemp_exponent !== undefined) requestBody.dynatemp_exponent = dynatemp_exponent;
|
| | if (top_k !== undefined) requestBody.top_k = top_k;
|
| | if (top_p !== undefined) requestBody.top_p = top_p;
|
| | if (min_p !== undefined) requestBody.min_p = min_p;
|
| | if (xtc_probability !== undefined) requestBody.xtc_probability = xtc_probability;
|
| | if (xtc_threshold !== undefined) requestBody.xtc_threshold = xtc_threshold;
|
| | if (typ_p !== undefined) requestBody.typ_p = typ_p;
|
| |
|
| | if (repeat_last_n !== undefined) requestBody.repeat_last_n = repeat_last_n;
|
| | if (repeat_penalty !== undefined) requestBody.repeat_penalty = repeat_penalty;
|
| | if (presence_penalty !== undefined) requestBody.presence_penalty = presence_penalty;
|
| | if (frequency_penalty !== undefined) requestBody.frequency_penalty = frequency_penalty;
|
| | if (dry_multiplier !== undefined) requestBody.dry_multiplier = dry_multiplier;
|
| | if (dry_base !== undefined) requestBody.dry_base = dry_base;
|
| | if (dry_allowed_length !== undefined) requestBody.dry_allowed_length = dry_allowed_length;
|
| | if (dry_penalty_last_n !== undefined) requestBody.dry_penalty_last_n = dry_penalty_last_n;
|
| |
|
| | if (samplers !== undefined) {
|
| | requestBody.samplers =
|
| | typeof samplers === 'string'
|
| | ? samplers.split(';').filter((s: string) => s.trim())
|
| | : samplers;
|
| | }
|
| |
|
| | if (backend_sampling !== undefined) requestBody.backend_sampling = backend_sampling;
|
| |
|
| | if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token;
|
| |
|
| | if (custom) {
|
| | try {
|
| | const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom;
|
| | Object.assign(requestBody, customParams);
|
| | } catch (error) {
|
| | console.warn('Failed to parse custom parameters:', error);
|
| | }
|
| | }
|
| |
|
| | try {
|
| | const response = await fetch(`./v1/chat/completions`, {
|
| | method: 'POST',
|
| | headers: getJsonHeaders(),
|
| | body: JSON.stringify(requestBody),
|
| | signal
|
| | });
|
| |
|
| | if (!response.ok) {
|
| | const error = await ChatService.parseErrorResponse(response);
|
| |
|
| | if (onError) {
|
| | onError(error);
|
| | }
|
| |
|
| | throw error;
|
| | }
|
| |
|
| | if (stream) {
|
| | await ChatService.handleStreamResponse(
|
| | response,
|
| | onChunk,
|
| | onComplete,
|
| | onError,
|
| | onReasoningChunk,
|
| | onToolCallChunk,
|
| | onModel,
|
| | onTimings,
|
| | conversationId,
|
| | signal
|
| | );
|
| |
|
| | return;
|
| | } else {
|
| | return ChatService.handleNonStreamResponse(
|
| | response,
|
| | onComplete,
|
| | onError,
|
| | onToolCallChunk,
|
| | onModel
|
| | );
|
| | }
|
| | } catch (error) {
|
| | if (isAbortError(error)) {
|
| | console.log('Chat completion request was aborted');
|
| | return;
|
| | }
|
| |
|
| | let userFriendlyError: Error;
|
| |
|
| | if (error instanceof Error) {
|
| | if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
| | userFriendlyError = new Error(
|
| | 'Unable to connect to server - please check if the server is running'
|
| | );
|
| | userFriendlyError.name = 'NetworkError';
|
| | } else if (error.message.includes('ECONNREFUSED')) {
|
| | userFriendlyError = new Error('Connection refused - server may be offline');
|
| | userFriendlyError.name = 'NetworkError';
|
| | } else if (error.message.includes('ETIMEDOUT')) {
|
| | userFriendlyError = new Error('Request timed out - the server took too long to respond');
|
| | userFriendlyError.name = 'TimeoutError';
|
| | } else {
|
| | userFriendlyError = error;
|
| | }
|
| | } else {
|
| | userFriendlyError = new Error('Unknown error occurred while sending message');
|
| | }
|
| |
|
| | console.error('Error in sendMessage:', error);
|
| |
|
| | if (onError) {
|
| | onError(userFriendlyError);
|
| | }
|
| |
|
| | throw userFriendlyError;
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | private static async handleStreamResponse(
|
| | response: Response,
|
| | onChunk?: (chunk: string) => void,
|
| | onComplete?: (
|
| | response: string,
|
| | reasoningContent?: string,
|
| | timings?: ChatMessageTimings,
|
| | toolCalls?: string
|
| | ) => void,
|
| | onError?: (error: Error) => void,
|
| | onReasoningChunk?: (chunk: string) => void,
|
| | onToolCallChunk?: (chunk: string) => void,
|
| | onModel?: (model: string) => void,
|
| | onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
|
| | conversationId?: string,
|
| | abortSignal?: AbortSignal
|
| | ): Promise<void> {
|
| | const reader = response.body?.getReader();
|
| |
|
| | if (!reader) {
|
| | throw new Error('No response body');
|
| | }
|
| |
|
| | const decoder = new TextDecoder();
|
| | let aggregatedContent = '';
|
| | let fullReasoningContent = '';
|
| | let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
|
| | let lastTimings: ChatMessageTimings | undefined;
|
| | let streamFinished = false;
|
| | let modelEmitted = false;
|
| | let toolCallIndexOffset = 0;
|
| | let hasOpenToolCallBatch = false;
|
| |
|
| | const finalizeOpenToolCallBatch = () => {
|
| | if (!hasOpenToolCallBatch) {
|
| | return;
|
| | }
|
| |
|
| | toolCallIndexOffset = aggregatedToolCalls.length;
|
| | hasOpenToolCallBatch = false;
|
| | };
|
| |
|
| | const processToolCallDelta = (toolCalls?: ApiChatCompletionToolCallDelta[]) => {
|
| | if (!toolCalls || toolCalls.length === 0) {
|
| | return;
|
| | }
|
| |
|
| | aggregatedToolCalls = ChatService.mergeToolCallDeltas(
|
| | aggregatedToolCalls,
|
| | toolCalls,
|
| | toolCallIndexOffset
|
| | );
|
| |
|
| | if (aggregatedToolCalls.length === 0) {
|
| | return;
|
| | }
|
| |
|
| | hasOpenToolCallBatch = true;
|
| |
|
| | const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
|
| |
|
| | if (import.meta.env.DEV) {
|
| | console.log('[ChatService] Aggregated tool calls:', serializedToolCalls);
|
| | }
|
| |
|
| | if (!serializedToolCalls) {
|
| | return;
|
| | }
|
| |
|
| | if (!abortSignal?.aborted) {
|
| | onToolCallChunk?.(serializedToolCalls);
|
| | }
|
| | };
|
| |
|
| | try {
|
| | let chunk = '';
|
| | while (true) {
|
| | if (abortSignal?.aborted) break;
|
| |
|
| | const { done, value } = await reader.read();
|
| | if (done) break;
|
| |
|
| | if (abortSignal?.aborted) break;
|
| |
|
| | chunk += decoder.decode(value, { stream: true });
|
| | const lines = chunk.split('\n');
|
| | chunk = lines.pop() || '';
|
| |
|
| | for (const line of lines) {
|
| | if (abortSignal?.aborted) break;
|
| |
|
| | if (line.startsWith(UrlPrefix.DATA)) {
|
| | const data = line.slice(6);
|
| | if (data === '[DONE]') {
|
| | streamFinished = true;
|
| |
|
| | continue;
|
| | }
|
| |
|
| | try {
|
| | const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
|
| | const content = parsed.choices[0]?.delta?.content;
|
| | const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
|
| | const toolCalls = parsed.choices[0]?.delta?.tool_calls;
|
| | const timings = parsed.timings;
|
| | const promptProgress = parsed.prompt_progress;
|
| |
|
| | const chunkModel = ChatService.extractModelName(parsed);
|
| | if (chunkModel && !modelEmitted) {
|
| | modelEmitted = true;
|
| | onModel?.(chunkModel);
|
| | }
|
| |
|
| | if (promptProgress) {
|
| | ChatService.notifyTimings(undefined, promptProgress, onTimings);
|
| | }
|
| |
|
| | if (timings) {
|
| | ChatService.notifyTimings(timings, promptProgress, onTimings);
|
| | lastTimings = timings;
|
| | }
|
| |
|
| | if (content) {
|
| | finalizeOpenToolCallBatch();
|
| | aggregatedContent += content;
|
| | if (!abortSignal?.aborted) {
|
| | onChunk?.(content);
|
| | }
|
| | }
|
| |
|
| | if (reasoningContent) {
|
| | finalizeOpenToolCallBatch();
|
| | fullReasoningContent += reasoningContent;
|
| | if (!abortSignal?.aborted) {
|
| | onReasoningChunk?.(reasoningContent);
|
| | }
|
| | }
|
| |
|
| | processToolCallDelta(toolCalls);
|
| | } catch (e) {
|
| | console.error('Error parsing JSON chunk:', e);
|
| | }
|
| | }
|
| | }
|
| |
|
| | if (abortSignal?.aborted) break;
|
| | }
|
| |
|
| | if (abortSignal?.aborted) return;
|
| |
|
| | if (streamFinished) {
|
| | finalizeOpenToolCallBatch();
|
| |
|
| | const finalToolCalls =
|
| | aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined;
|
| |
|
| | onComplete?.(
|
| | aggregatedContent,
|
| | fullReasoningContent || undefined,
|
| | lastTimings,
|
| | finalToolCalls
|
| | );
|
| | }
|
| | } catch (error) {
|
| | const err = error instanceof Error ? error : new Error('Stream error');
|
| |
|
| | onError?.(err);
|
| |
|
| | throw err;
|
| | } finally {
|
| | reader.releaseLock();
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | private static async handleNonStreamResponse(
|
| | response: Response,
|
| | onComplete?: (
|
| | response: string,
|
| | reasoningContent?: string,
|
| | timings?: ChatMessageTimings,
|
| | toolCalls?: string
|
| | ) => void,
|
| | onError?: (error: Error) => void,
|
| | onToolCallChunk?: (chunk: string) => void,
|
| | onModel?: (model: string) => void
|
| | ): Promise<string> {
|
| | try {
|
| | const responseText = await response.text();
|
| |
|
| | if (!responseText.trim()) {
|
| | const noResponseError = new Error('No response received from server. Please try again.');
|
| |
|
| | throw noResponseError;
|
| | }
|
| |
|
| | const data: ApiChatCompletionResponse = JSON.parse(responseText);
|
| |
|
| | const responseModel = ChatService.extractModelName(data);
|
| | if (responseModel) {
|
| | onModel?.(responseModel);
|
| | }
|
| |
|
| | const content = data.choices[0]?.message?.content || '';
|
| | const reasoningContent = data.choices[0]?.message?.reasoning_content;
|
| | const toolCalls = data.choices[0]?.message?.tool_calls;
|
| |
|
| | let serializedToolCalls: string | undefined;
|
| |
|
| | if (toolCalls && toolCalls.length > 0) {
|
| | const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls);
|
| |
|
| | if (mergedToolCalls.length > 0) {
|
| | serializedToolCalls = JSON.stringify(mergedToolCalls);
|
| | if (serializedToolCalls) {
|
| | onToolCallChunk?.(serializedToolCalls);
|
| | }
|
| | }
|
| | }
|
| |
|
| | if (!content.trim() && !serializedToolCalls) {
|
| | const noResponseError = new Error('No response received from server. Please try again.');
|
| |
|
| | throw noResponseError;
|
| | }
|
| |
|
| | onComplete?.(content, reasoningContent, undefined, serializedToolCalls);
|
| |
|
| | return content;
|
| | } catch (error) {
|
| | const err = error instanceof Error ? error : new Error('Parse error');
|
| |
|
| | onError?.(err);
|
| |
|
| | throw err;
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | private static mergeToolCallDeltas(
|
| | existing: ApiChatCompletionToolCall[],
|
| | deltas: ApiChatCompletionToolCallDelta[],
|
| | indexOffset = 0
|
| | ): ApiChatCompletionToolCall[] {
|
| | const result = existing.map((call) => ({
|
| | ...call,
|
| | function: call.function ? { ...call.function } : undefined
|
| | }));
|
| |
|
| | for (const delta of deltas) {
|
| | const index =
|
| | typeof delta.index === 'number' && delta.index >= 0
|
| | ? delta.index + indexOffset
|
| | : result.length;
|
| |
|
| | while (result.length <= index) {
|
| | result.push({ function: undefined });
|
| | }
|
| |
|
| | const target = result[index]!;
|
| |
|
| | if (delta.id) {
|
| | target.id = delta.id;
|
| | }
|
| |
|
| | if (delta.type) {
|
| | target.type = delta.type;
|
| | }
|
| |
|
| | if (delta.function) {
|
| | const fn = target.function ? { ...target.function } : {};
|
| |
|
| | if (delta.function.name) {
|
| | fn.name = delta.function.name;
|
| | }
|
| |
|
| | if (delta.function.arguments) {
|
| | fn.arguments = (fn.arguments ?? '') + delta.function.arguments;
|
| | }
|
| |
|
| | target.function = fn;
|
| | }
|
| | }
|
| |
|
| | return result;
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | static convertDbMessageToApiChatMessageData(
|
| | message: DatabaseMessage & { extra?: DatabaseMessageExtra[] }
|
| | ): ApiChatMessageData {
|
| |
|
| | if (message.role === MessageRole.TOOL && message.toolCallId) {
|
| | return {
|
| | role: MessageRole.TOOL,
|
| | content: message.content,
|
| | tool_call_id: message.toolCallId
|
| | };
|
| | }
|
| |
|
| |
|
| | let toolCalls: ApiChatCompletionToolCall[] | undefined;
|
| | if (message.toolCalls) {
|
| | try {
|
| | toolCalls = JSON.parse(message.toolCalls);
|
| | } catch {
|
| |
|
| | }
|
| | }
|
| |
|
| | if (!message.extra || message.extra.length === 0) {
|
| | const result: ApiChatMessageData = {
|
| | role: message.role as MessageRole,
|
| | content: message.content
|
| | };
|
| |
|
| | if (toolCalls && toolCalls.length > 0) {
|
| | result.tool_calls = toolCalls;
|
| | }
|
| |
|
| | return result;
|
| | }
|
| |
|
| | const contentParts: ApiChatMessageContentPart[] = [];
|
| |
|
| | if (message.content) {
|
| | contentParts.push({
|
| | type: ContentPartType.TEXT,
|
| | text: message.content
|
| | });
|
| | }
|
| |
|
| |
|
| | const imageFiles = message.extra.filter(
|
| | (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
|
| | extra.type === AttachmentType.IMAGE
|
| | );
|
| |
|
| | for (const image of imageFiles) {
|
| | contentParts.push({
|
| | type: ContentPartType.IMAGE_URL,
|
| | image_url: { url: image.base64Url }
|
| | });
|
| | }
|
| |
|
| | const textFiles = message.extra.filter(
|
| | (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
|
| | extra.type === AttachmentType.TEXT
|
| | );
|
| |
|
| | for (const textFile of textFiles) {
|
| | contentParts.push({
|
| | type: ContentPartType.TEXT,
|
| | text: formatAttachmentText('File', textFile.name, textFile.content)
|
| | });
|
| | }
|
| |
|
| |
|
| | const legacyContextFiles = message.extra.filter(
|
| | (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext =>
|
| | extra.type === AttachmentType.LEGACY_CONTEXT
|
| | );
|
| |
|
| | for (const legacyContextFile of legacyContextFiles) {
|
| | contentParts.push({
|
| | type: ContentPartType.TEXT,
|
| | text: formatAttachmentText('File', legacyContextFile.name, legacyContextFile.content)
|
| | });
|
| | }
|
| |
|
| | const audioFiles = message.extra.filter(
|
| | (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile =>
|
| | extra.type === AttachmentType.AUDIO
|
| | );
|
| |
|
| | for (const audio of audioFiles) {
|
| | contentParts.push({
|
| | type: ContentPartType.INPUT_AUDIO,
|
| | input_audio: {
|
| | data: audio.base64Data,
|
| | format: audio.mimeType.includes('wav') ? 'wav' : 'mp3'
|
| | }
|
| | });
|
| | }
|
| |
|
| | const pdfFiles = message.extra.filter(
|
| | (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
|
| | extra.type === AttachmentType.PDF
|
| | );
|
| |
|
| | for (const pdfFile of pdfFiles) {
|
| | if (pdfFile.processedAsImages && pdfFile.images) {
|
| | for (let i = 0; i < pdfFile.images.length; i++) {
|
| | contentParts.push({
|
| | type: ContentPartType.IMAGE_URL,
|
| | image_url: { url: pdfFile.images[i] }
|
| | });
|
| | }
|
| | } else {
|
| | contentParts.push({
|
| | type: ContentPartType.TEXT,
|
| | text: formatAttachmentText(ATTACHMENT_LABEL_PDF_FILE, pdfFile.name, pdfFile.content)
|
| | });
|
| | }
|
| | }
|
| |
|
| | const result: ApiChatMessageData = {
|
| | role: message.role as MessageRole,
|
| | content: contentParts
|
| | };
|
| |
|
| | return result;
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| | |
| | |
| |
|
| | private static async parseErrorResponse(
|
| | response: Response
|
| | ): Promise<Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }> {
|
| | try {
|
| | const errorText = await response.text();
|
| | const errorData: ApiErrorResponse = JSON.parse(errorText);
|
| |
|
| | const message = errorData.error?.message || 'Unknown server error';
|
| | const error = new Error(message) as Error & {
|
| | contextInfo?: { n_prompt_tokens: number; n_ctx: number };
|
| | };
|
| | error.name = response.status === 400 ? 'ServerError' : 'HttpError';
|
| |
|
| | if (errorData.error && 'n_prompt_tokens' in errorData.error && 'n_ctx' in errorData.error) {
|
| | error.contextInfo = {
|
| | n_prompt_tokens: errorData.error.n_prompt_tokens,
|
| | n_ctx: errorData.error.n_ctx
|
| | };
|
| | }
|
| |
|
| | return error;
|
| | } catch {
|
| | const fallback = new Error(
|
| | `Server error (${response.status}): ${response.statusText}`
|
| | ) as Error & {
|
| | contextInfo?: { n_prompt_tokens: number; n_ctx: number };
|
| | };
|
| | fallback.name = 'HttpError';
|
| |
|
| | return fallback;
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | private static extractModelName(data: unknown): string | undefined {
|
| | const asRecord = (value: unknown): Record<string, unknown> | undefined => {
|
| | return typeof value === 'object' && value !== null
|
| | ? (value as Record<string, unknown>)
|
| | : undefined;
|
| | };
|
| |
|
| | const getTrimmedString = (value: unknown): string | undefined => {
|
| | return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
| | };
|
| |
|
| | const root = asRecord(data);
|
| | if (!root) return undefined;
|
| |
|
| |
|
| | const rootModel = getTrimmedString(root.model);
|
| | if (rootModel) {
|
| | return rootModel;
|
| | }
|
| |
|
| |
|
| | const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
|
| | if (!firstChoice) {
|
| | return undefined;
|
| | }
|
| |
|
| |
|
| | const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
|
| | if (deltaModel) {
|
| | return deltaModel;
|
| | }
|
| |
|
| | const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
|
| | if (messageModel) {
|
| | return messageModel;
|
| | }
|
| |
|
| |
|
| | return undefined;
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | private static notifyTimings(
|
| | timings: ChatMessageTimings | undefined,
|
| | promptProgress: ChatMessagePromptProgress | undefined,
|
| | onTimingsCallback:
|
| | | ((timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void)
|
| | | undefined
|
| | ): void {
|
| | if (!onTimingsCallback || (!timings && !promptProgress)) return;
|
| |
|
| | onTimingsCallback(timings, promptProgress);
|
| | }
|
| | }
|
| |
|