diff --git a/AIClasses/BaseAIClass.ts b/AIClasses/BaseAIClass.ts index b96002b..148ecb7 100644 --- a/AIClasses/BaseAIClass.ts +++ b/AIClasses/BaseAIClass.ts @@ -16,6 +16,7 @@ import type { AbortService } from "Services/AbortService"; import type { IAIFileService } from "./IAIFileService"; import { AgentType } from "Enums/AgentType"; import { AIToolUsageMode } from "Enums/AIToolUsageMode"; +import type { MimeType } from "Enums/MimeType"; export abstract class BaseAIClass implements IAIClass { @@ -95,6 +96,103 @@ export abstract class BaseAIClass implements IAIClass { protected abstract extractContents(conversationContent: ConversationContent[]): unknown; protected abstract mapFunctionDefinitions(aiToolDefinitions: IAIToolDefinition[]): object; + /** The MIME types this provider can accept as attachments. */ + protected abstract get supportedMimeTypes(): MimeType[]; + + protected isSupportedMimeType(mimeType: MimeType): boolean { + return this.supportedMimeTypes.includes(mimeType); + } + + /** + * Maps the current AIToolUsageMode to a provider-specific tool-choice value. + * The branching is identical across providers; only the returned shape differs + * (a bare string for Chat Completions/Responses, an object for Claude/Gemini), + * so callers supply the three concrete values. + */ + protected buildToolChoice(choices: { auto: T; enabled: T; disabled: T }): T { + if (this.aiToolDefinitions.length === 0) { + return choices.auto; + } + + switch (this.aiToolUsageMode) { + case AIToolUsageMode.Auto: + return choices.auto; + case AIToolUsageMode.Enabled: + return choices.enabled; + case AIToolUsageMode.Disabled: + return choices.disabled; + } + } + + /** + * Extracts a retry delay (in seconds) from a rate-limit error's HTTP response headers. + * Shared by providers that surface rate limits via the standard `Retry-After` header + * (Claude, OpenAI, Mistral). Providers that signal retry timing in the response body + * (e.g. Gemini's RetryInfo) override this instead. + */ + protected extractRetryDelay(error: ApiError): number | undefined { + if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) { + return undefined; + } + + const headers = error.info.responseHeaders; + + // 1. Prefer the standard Retry-After header (seconds or HTTP-date) + const retryAfter = headers.get('retry-after'); + if (retryAfter) { + const seconds = Number(retryAfter); + if (!Number.isNaN(seconds)) { + return Math.max(0, seconds); + } + + const date = new Date(retryAfter); + if (!Number.isNaN(date.getTime())) { + const delayMs = date.getTime() - Date.now(); + return Math.max(0, Math.ceil(delayMs / 1000)); + } + } + + // 2. Fall back to provider-specific reset headers (e.g. OpenAI) + const resetHeader = + headers.get('x-ratelimit-reset-requests') ?? + headers.get('x-ratelimit-reset-tokens'); + + if (resetHeader) { + return this.parseDurationToSeconds(resetHeader); + } + + return undefined; + } + + /** + * Parses duration strings (e.g. "15s", "600ms", "2m", "1h") into seconds. + * Returns undefined if parsing fails. + */ + protected parseDurationToSeconds(value: string): number | undefined { + const trimmed = value.trim(); + const numericValue = parseFloat(trimmed); + + if (Number.isNaN(numericValue)) { + return undefined; + } + + if (trimmed.endsWith('ms')) { + return Math.max(0, Math.ceil(numericValue / 1000)); + } + if (trimmed.endsWith('s')) { + return Math.max(0, numericValue); + } + if (trimmed.endsWith('m')) { + return Math.max(0, numericValue * 60); + } + if (trimmed.endsWith('h')) { + return Math.max(0, numericValue * 3600); + } + + // Fallback: treat as raw seconds + return Math.max(0, numericValue); + } + protected model(): string { switch (this._agentType) { case AgentType.Main: diff --git a/AIClasses/ChatCompletions/ChatCompletionsAIClass.ts b/AIClasses/ChatCompletions/ChatCompletionsAIClass.ts new file mode 100644 index 0000000..87a8724 --- /dev/null +++ b/AIClasses/ChatCompletions/ChatCompletionsAIClass.ts @@ -0,0 +1,353 @@ +import { BaseAIClass } from "AIClasses/BaseAIClass"; +import type { IStreamChunk } from "Services/StreamingService"; +import type { Conversation } from "Conversations/Conversation"; +import { AIToolCall } from "AIClasses/AIToolCall"; +import { fromString as aiToolFromString } from "Enums/AITool"; +import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition"; +import type { ConversationContent } from "Conversations/ConversationContent"; +import { Exception } from "Helpers/Exception"; +import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper"; +import type { + ChatCompletionStreamChunk, + ChatCompletionToolDefinition, + ChatCompletionMessage, + ChatCompletionContentPart +} from "./ChatCompletionsTypes"; + +/** + * Intermediate base for providers speaking the OpenAI-compatible Chat Completions + * protocol (`/v1/chat/completions`): `messages` array, `tool_calls` nested on + * assistant messages, `role:"tool"` results, and delta-accumulation streaming. + * + * Mistral is the reference implementation; future Chat Completions providers + * (e.g. a LocalAI class for Ollama/LM Studio/llama.cpp) should be thin subclasses + * supplying only `apiUrl`, `supportedMimeTypes`, and `formatBinaryFiles`. + * + * NOTE: This is deliberately separate from the OpenAI class, which targets the + * newer Responses API (`/v1/responses`) — a different wire protocol. + */ +export abstract class ChatCompletionsAIClass extends BaseAIClass { + + private readonly STOP_REASON_TOOL_CALLS: string = "tool_calls"; + + /** The Chat Completions endpoint for this provider. */ + protected abstract get apiUrl(): string; + + /** Max output tokens to request. Subclasses may override. */ + protected get maxTokens(): number { + return 16384; + } + + // Accumulation state for streaming tool calls + private accumulatedToolCalls: Map = new Map(); + + public async* streamRequest(conversation: Conversation): AsyncGenerator { + const messages = await this.buildMessages(conversation); + const tools = this.getTools(); + + const requestBody: Record = { + model: this.model(), + max_tokens: this.maxTokens, + messages: messages, + stream: true + }; + + // Only include tools if there are definitions + if (tools.length > 0) { + requestBody.tools = tools; + requestBody.tool_choice = this.buildChatCompletionsToolChoice(); + } + + const headers = { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json" + }; + + yield* this.streamingService.streamRequest( + this.apiUrl, + requestBody, + (chunk: string) => this.parseStreamChunk(chunk), + headers, + (error) => this.extractRetryDelay(error) + ); + } + + private async buildMessages(conversation: Conversation): Promise { + this.accumulatedToolCalls.clear(); + + // Refresh file cache only if conversation has attachments + if (conversation.hasAttachments()) { + await this.aiFileService.refreshCache(); + } + + const systemPrompt = `${this.systemPrompt}\n\n${this.userInstruction}`; + const messages = await this.extractContents(conversation.contents); + + return [ + { role: "system", content: systemPrompt }, + ...messages + ]; + } + + protected parseStreamChunk(chunk: string): IStreamChunk { + try { + // Chat Completions sends "[DONE]" as the final message + if (chunk.trim() === "[DONE]") { + return { content: "", isComplete: true }; + } + + const data = JSON.parse(chunk) as ChatCompletionStreamChunk; + + let text = ""; + let toolCall: AIToolCall | undefined = undefined; + let isComplete = false; + let shouldContinue = false; + let toolCallStarted: string | undefined = undefined; + + if (!data.choices || data.choices.length === 0) { + return { content: "", isComplete: false }; + } + + const choice = data.choices[0]; + + // Handle text content + if (choice.delta.content) { + text = choice.delta.content; + } + + // Handle tool call deltas + if (choice.delta.tool_calls) { + for (const tc of choice.delta.tool_calls) { + const index = tc.index ?? 0; + + if (!this.accumulatedToolCalls.has(index)) { + // New tool call starting + this.accumulatedToolCalls.set(index, { + id: tc.id || "", + name: tc.function?.name || "", + args: tc.function?.arguments || "" + }); + + if (tc.function?.name) { + toolCallStarted = tc.function.name; + } + } else { + // Accumulate arguments for existing tool call + const existing = this.accumulatedToolCalls.get(index)!; + if (tc.id) existing.id = tc.id; + if (tc.function?.name) existing.name += tc.function.name; + if (tc.function?.arguments) existing.args += tc.function.arguments; + } + } + } + + // Handle completion + if (choice.finish_reason) { + isComplete = true; + + if (choice.finish_reason === this.STOP_REASON_TOOL_CALLS) { + shouldContinue = true; + + // Finalize the first accumulated tool call + // (additional tool calls in a single response are not supported by this plugin's architecture) + const firstToolCall = this.accumulatedToolCalls.get(0); + if (firstToolCall && firstToolCall.name && firstToolCall.args) { + try { + const args = JSON.parse(firstToolCall.args) as Record; + toolCall = new AIToolCall( + aiToolFromString(firstToolCall.name), + args, + firstToolCall.id || undefined, + undefined + ); + } catch (error) { + Exception.log(error); + } + } + + this.accumulatedToolCalls.clear(); + } + } + + return { + content: text, + isComplete: isComplete, + toolCall: toolCall, + shouldContinue: shouldContinue, + toolCallStarted: toolCallStarted + }; + } catch (error) { + return this.createErrorChunk(error); + } + } + + protected async extractContents(conversationContent: ConversationContent[]): Promise { + const results: ChatCompletionMessage[] = []; + + for (const content of this.filterConversationContents(conversationContent)) { + const contentToExtract = content.content ?? ""; + + // Case 1: Assistant message with tool call + if (content.toolCall) { + const parsedContent = parseToolCall(content.toolCall); + + if (parsedContent) { + // A native tool-call id means the call originated from this provider and + // can be replayed in the structured format. Otherwise (a call from another + // provider, or no id) fall back to legacy text so history stays coherent. + const isNative = parsedContent.toolCall.id + && parsedContent.toolCall.id.trim() !== "" + && this.isNativeToolCallId(parsedContent.toolCall.id); + + if (isNative) { + // Native function call - use proper function call format + results.push({ + role: content.role, + content: contentToExtract || "", + tool_calls: [{ + id: parsedContent.toolCall.id, + type: "function", + function: { + name: parsedContent.toolCall.name, + arguments: JSON.stringify(parsedContent.toolCall.args) + } + }] + }); + } else { + // Cross-provider function call (from Claude/OpenAI) or no id - use legacy text format + const legacyText = this.convertToolCallToText(parsedContent); + const combinedContent = contentToExtract.trim() !== "" + ? `${contentToExtract}\n\n${legacyText}` + : legacyText; + + results.push({ + role: content.role, + content: combinedContent + }); + } + } else { + results.push({ + role: content.role, + content: contentToExtract.trim() !== "" ? contentToExtract : "Error parsing function call" + }); + } + continue; + } + + // Case 2: Binary file attachments + if (content.attachments && content.attachments.length > 0) { + const { formattedParts, uploadErrors } = await this.processAttachments( + content.attachments, + (attachments) => this.formatBinaryFiles(attachments) + ); + + const contentParts: ChatCompletionContentPart[] = []; + + if (contentToExtract.trim() !== "") { + contentParts.push({ type: "text", text: contentToExtract }); + } + + contentParts.push(...formattedParts); + + for (const uploadError of uploadErrors) { + contentParts.push({ + type: "text", + text: Exception.messageFrom(uploadError) + }); + } + + if (contentParts.length > 0) { + results.push({ + role: content.role, + content: contentParts + }); + } + continue; + } + + // Case 3: Function call response (tool result) + if (content.functionResponse) { + const parsedContent = parseFunctionResponse(content.functionResponse); + + if (parsedContent) { + const isNative = parsedContent.id + && parsedContent.id.trim() !== "" + && this.isNativeToolCallId(parsedContent.id); + + if (isNative) { + // Native function response - use proper format + results.push({ + role: "tool", + content: JSON.stringify(parsedContent.functionResponse.response), + tool_call_id: parsedContent.id, + name: parsedContent.functionResponse.name + }); + } else { + // Cross-provider function response (from Claude/OpenAI) or no id - use legacy text format + const legacyText = this.convertFunctionResponseToText(parsedContent); + results.push({ + role: content.role, + content: legacyText + }); + } + } else { + results.push({ + role: content.role, + content: content.functionResponse + }); + } + continue; + } + + // Case 4: Regular text message + if (contentToExtract.trim() !== "") { + results.push({ + role: content.role, + content: contentToExtract + }); + } + } + + return results; + } + + protected mapFunctionDefinitions(aiToolDefinitions: IAIToolDefinition[]): ChatCompletionToolDefinition[] { + return aiToolDefinitions.map((functionDefinition) => ({ + type: "function" as const, + function: { + name: functionDefinition.name, + description: functionDefinition.description, + parameters: { + type: "object" as const, + properties: functionDefinition.parameters.properties, + required: functionDefinition.parameters.required + } + } + })); + } + + private buildChatCompletionsToolChoice(): string { + return this.buildToolChoice({ + auto: "auto", + enabled: "any", + disabled: "none" + }); + } + + /** + * The tools advertised to the model. Defaults to the mapped function definitions. + * Subclasses override to inject provider-specific built-in tools (e.g. web search). + */ + protected getTools(): ChatCompletionToolDefinition[] { + return this.mapFunctionDefinitions(this.aiToolDefinitions); + } + + /** + * Whether a stored tool-call id is a native id for this provider (vs. one carried + * over from another provider in cross-provider conversation history). Default treats + * any non-empty id as native; providers with a known id format override this. + */ + protected isNativeToolCallId(id: string): boolean { + return id.trim() !== ""; + } +} diff --git a/AIClasses/ChatCompletions/ChatCompletionsConversationNamingService.ts b/AIClasses/ChatCompletions/ChatCompletionsConversationNamingService.ts new file mode 100644 index 0000000..254e733 --- /dev/null +++ b/AIClasses/ChatCompletions/ChatCompletionsConversationNamingService.ts @@ -0,0 +1,75 @@ +import { Resolve } from "Services/DependencyService"; +import { Services } from "Services/Services"; +import type { IConversationNamingService } from "AIClasses/IConversationNamingService"; +import type { AIProvider } from "Enums/ApiProvider"; +import { Role } from "Enums/Role"; +import { NamePrompt } from "AIPrompts/NamePrompt"; +import type { SettingsService } from "Services/SettingsService"; +import { Exception } from "Helpers/Exception"; +import type { AbortService } from "Services/AbortService"; +import type { ChatCompletionResponse } from "./ChatCompletionsTypes"; + +/** + * Base conversation-naming service for providers speaking the OpenAI-compatible + * Chat Completions protocol. Subclasses supply only the endpoint and namer model; + * the request shape and response parsing are identical across such providers. + */ +export abstract class ChatCompletionsConversationNamingService implements IConversationNamingService { + + private readonly apiKey: string; + private readonly abortService: AbortService; + + protected constructor(provider: AIProvider) { + const settingsService = Resolve(Services.SettingsService); + this.apiKey = settingsService.getApiKeyForProvider(provider); + this.abortService = Resolve(Services.AbortService); + } + + /** The Chat Completions endpoint for this provider. */ + protected abstract get apiUrl(): string; + + /** The (typically small/fast) model used to generate conversation names. */ + protected abstract get namerModel(): string; + + public async generateName(userPrompt: string): Promise { + return await this.abortService.abortableOperation(async () => { + const requestBody = { + model: this.namerModel, + max_tokens: 100, + messages: [ + { + role: "system", + content: NamePrompt + }, + { + role: Role.User, + content: userPrompt + } + ] + }; + + const response = await fetch(this.apiUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: this.abortService.signal() + }); + + if (!response.ok) { + Exception.throw(`Chat Completions API error: ${response.status} ${response.statusText} - ${await response.text()}`); + } + + const data = await response.json() as ChatCompletionResponse; + const firstChoice = data.choices?.[0]; + + if (!firstChoice || !firstChoice.message?.content) { + Exception.throw("Failed to generate conversation name"); + } + + return firstChoice.message.content.trim(); + }); + } +} diff --git a/AIClasses/ChatCompletions/ChatCompletionsTypes.ts b/AIClasses/ChatCompletions/ChatCompletionsTypes.ts new file mode 100644 index 0000000..62a75db --- /dev/null +++ b/AIClasses/ChatCompletions/ChatCompletionsTypes.ts @@ -0,0 +1,97 @@ +// Shared OpenAI-compatible Chat Completions API types. +// Spoken by Mistral and any future Chat Completions provider (Groq, DeepSeek, +// OpenRouter, Ollama, LM Studio, llama.cpp, ...). Provider-specific extensions +// (e.g. Mistral's Agents/File APIs) live in the provider's own *Types.ts. + +export interface ChatCompletionStreamChunk { + id: string; + object: "chat.completion.chunk"; + created: number; + model: string; + choices: ChatCompletionStreamChoice[]; +} + +export interface ChatCompletionStreamChoice { + index: number; + delta: ChatCompletionDelta; + finish_reason: string | null; +} + +export interface ChatCompletionDelta { + role?: string; + content?: string; + tool_calls?: ChatCompletionToolCallDelta[]; +} + +export interface ChatCompletionToolCallDelta { + id?: string; + type?: "function"; + function?: { + name?: string; + arguments?: string; + }; + index?: number; +} + +// Non-streaming response (used for conversation naming) +export interface ChatCompletionResponse { + id: string; + object: "chat.completion"; + created: number; + model: string; + choices: ChatCompletionChoice[]; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +export interface ChatCompletionChoice { + index: number; + message: { + role: string; + content: string | null; + tool_calls?: ChatCompletionToolCall[]; + }; + finish_reason: string; +} + +export interface ChatCompletionToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + +// Tool definition format +export interface ChatCompletionToolDefinition { + type: "function"; + function: { + name: string; + description: string; + parameters: { + type: "object"; + properties: Record; + required?: string[]; + }; + }; +} + +// Message types for request construction +export interface ChatCompletionMessage { + role: string; + content: string | ChatCompletionContentPart[]; + tool_calls?: ChatCompletionToolCall[]; + tool_call_id?: string; + name?: string; +} + +export interface ChatCompletionContentPart { + type: "text" | "image_url" | "document_url"; + text?: string; + image_url?: string; + document_url?: string; +} diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index 079c924..836db9a 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -13,9 +13,7 @@ import { Exception } from "Helpers/Exception"; import { MimeType, toMimeType } from "Enums/MimeType"; import { isTextFile } from "Enums/FileType"; import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping"; -import { ApiError, ApiErrorType } from "Types/ApiError"; import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper"; -import { AIToolUsageMode } from "Enums/AIToolUsageMode"; import { Copy } from "Enums/Copy"; import { replaceCopy } from 'Helpers/Helpers'; @@ -32,6 +30,10 @@ export class Claude extends BaseAIClass { MimeType.IMAGE_WEBP ]; + protected get supportedMimeTypes(): MimeType[] { + return this.SUPPORTED_MIMETYPES; + } + private accumulatedFunctionName: string | null = null; private accumulatedFunctionArgs: string = ""; private accumulatedFunctionId: string | null = null; @@ -316,10 +318,6 @@ export class Claude extends BaseAIClass { return JSON.stringify(contentBlocks); } - private isSupportedMimeType(mimeType: MimeType): boolean { - return this.SUPPORTED_MIMETYPES.includes(mimeType); - } - // Adds cache control to the last tool in the tools array. private addCacheControlToTools(tools: ToolUnion[]): ToolUnion[] { if (tools.length === 0) { @@ -384,41 +382,10 @@ export class Claude extends BaseAIClass { } private buildClaudeToolChoice(): { type: string } { - // If no tools defined, fall back to auto - if (this.aiToolDefinitions.length === 0) { - return { type: "auto" }; - } - - switch (this.aiToolUsageMode) { - case AIToolUsageMode.Auto: - return { type: "auto" }; - case AIToolUsageMode.Enabled: - return { type: "any" }; - case AIToolUsageMode.Disabled: - return { type: "none" }; - } - } - - private extractRetryDelay(error: ApiError): number | undefined { - if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) { - return undefined; - } - - const retryAfter = error.info.responseHeaders.get('Retry-After'); - if (!retryAfter) return undefined; - - // Try parsing as seconds (number) - const seconds = parseInt(retryAfter, 10); - if (!isNaN(seconds)) return seconds; - - // Try parsing as HTTP date - const date = new Date(retryAfter); - if (!isNaN(date.getTime())) { - const now = Date.now(); - const delayMs = date.getTime() - now; - return Math.max(0, Math.ceil(delayMs / 1000)); - } - - return undefined; + return this.buildToolChoice<{ type: string }>({ + auto: { type: "auto" }, + enabled: { type: "any" }, + disabled: { type: "none" } + }); } } \ No newline at end of file diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index b689bde..73da5a3 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -17,7 +17,6 @@ import { Exception } from "Helpers/Exception"; import { ApiError, ApiErrorType } from "Types/ApiError"; import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper"; import type { GeminiRetryInfo, GeminiErrorResponse } from "./GeminiTypes"; -import { AIToolUsageMode } from "Enums/AIToolUsageMode"; import { replaceCopy } from 'Helpers/Helpers'; import { Copy } from "Enums/Copy"; @@ -73,6 +72,10 @@ export class Gemini extends BaseAIClass { MimeType.APPLICATION_YAML ]; + protected get supportedMimeTypes(): MimeType[] { + return this.SUPPORTED_MIMETYPES; + } + private accumulatedFunctionName: string | null = null; private accumulatedFunctionArgs: Record = {}; private accumulatedThoughtSignature: string | null = null; @@ -368,22 +371,16 @@ export class Gemini extends BaseAIClass { } private buildGeminiToolConfig(): { function_calling_config: { mode: string } } { - // If no tools defined, fall back to auto - if (this.aiToolDefinitions.length === 0) { - return { function_calling_config: { mode: "AUTO" } }; - } - - switch (this.aiToolUsageMode) { - case AIToolUsageMode.Auto: - return { function_calling_config: { mode: "AUTO" } }; - case AIToolUsageMode.Enabled: - return { function_calling_config: { mode: "ANY" } }; - case AIToolUsageMode.Disabled: - return { function_calling_config: { mode: "NONE" } }; - } + return this.buildToolChoice<{ function_calling_config: { mode: string } }>({ + auto: { function_calling_config: { mode: "AUTO" } }, + enabled: { function_calling_config: { mode: "ANY" } }, + disabled: { function_calling_config: { mode: "NONE" } } + }); } - private extractRetryDelay(error: ApiError): number | undefined { + // Gemini signals retry timing in the response body (RetryInfo), not the + // Retry-After header, so it overrides the header-based base implementation. + protected extractRetryDelay(error: ApiError): number | undefined { if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseBody) { return undefined; } @@ -464,12 +461,8 @@ export class Gemini extends BaseAIClass { if (Number.isNaN(value)) return undefined; const unit = match[2]; - return unit === 'ms' - ? Math.ceil(value / 1000) + return unit === 'ms' + ? Math.ceil(value / 1000) : Math.ceil(value); } - - private isSupportedMimeType(mimeType: MimeType): boolean { - return this.SUPPORTED_MIMETYPES.includes(mimeType); - } } \ No newline at end of file diff --git a/AIClasses/Mistral/Mistral.ts b/AIClasses/Mistral/Mistral.ts index ed0ebbd..7857744 100644 --- a/AIClasses/Mistral/Mistral.ts +++ b/AIClasses/Mistral/Mistral.ts @@ -1,33 +1,26 @@ -import { BaseAIClass } from "AIClasses/BaseAIClass"; -import type { IStreamChunk } from "Services/StreamingService"; -import type { Conversation } from "Conversations/Conversation"; +import { ChatCompletionsAIClass } from "AIClasses/ChatCompletions/ChatCompletionsAIClass"; import type { Attachment } from "Conversations/Attachment"; import { AIProvider, AIProviderURL } from "Enums/ApiProvider"; import { AIToolCall } from "AIClasses/AIToolCall"; import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse"; -import { fromString as aiToolFromString, AITool } from "Enums/AITool"; -import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition"; -import type { ConversationContent } from "Conversations/ConversationContent"; -import { Role } from "Enums/Role"; -import { Exception } from "Helpers/Exception"; +import { AITool } from "Enums/AITool"; import { MimeType, toMimeType } from "Enums/MimeType"; import { isTextFile } from "Enums/FileType"; import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping"; -import { ApiError, ApiErrorType } from "Types/ApiError"; -import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper"; -import { AIToolUsageMode } from "Enums/AIToolUsageMode"; -import type { MistralStreamChunk, MistralToolDefinition, MistralMessage, MistralContentPart } from "./MistralTypes"; +import type { ChatCompletionToolDefinition, ChatCompletionContentPart } from "AIClasses/ChatCompletions/ChatCompletionsTypes"; import type { MistralFileService } from "./MistralFileService"; import { replaceCopy } from 'Helpers/Helpers'; import { Copy } from "Enums/Copy"; import { MistralAgent } from "./MistralAgent"; import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload"; -export class Mistral extends BaseAIClass { +export class Mistral extends ChatCompletionsAIClass { - private readonly STOP_REASON_TOOL_CALLS: string = "tool_calls"; + // Mistral requires native tool-call ids to be alphanumeric only (a-z, A-Z, 0-9) + // with length 9. An id not matching this format originates from another provider. + private static readonly NATIVE_TOOL_CALL_ID = /^[a-zA-Z0-9]{9}$/; - private readonly SUPPORTED_MIMETYPES = [ + protected readonly SUPPORTED_MIMETYPES = [ MimeType.TEXT_PLAIN, MimeType.APPLICATION_PDF, MimeType.IMAGE_JPEG, @@ -38,42 +31,20 @@ export class Mistral extends BaseAIClass { private readonly agent: MistralAgent = new MistralAgent(); - // Accumulation state for streaming tool calls - private accumulatedToolCalls: Map = new Map(); - public constructor() { super(AIProvider.Mistral); } - public async* streamRequest(conversation: Conversation): AsyncGenerator { - const messages = await this.buildMessages(conversation); - const tools = this.getTools(); + protected get apiUrl(): string { + return AIProviderURL.Mistral; + } - const requestBody: Record = { - model: this.model(), - max_tokens: 16384, - messages: messages, - stream: true - }; + protected get supportedMimeTypes(): MimeType[] { + return this.SUPPORTED_MIMETYPES; + } - // Only include tools if there are definitions - if (tools.length > 0) { - requestBody.tools = tools; - requestBody.tool_choice = this.buildMistralToolChoice(); - } - - const headers = { - "Authorization": `Bearer ${this.apiKey}`, - "Content-Type": "application/json" - }; - - yield* this.streamingService.streamRequest( - AIProviderURL.Mistral, - requestBody, - (chunk: string) => this.parseStreamChunk(chunk), - headers, - (error) => this.extractRetryDelay(error) - ); + protected isNativeToolCallId(id: string): boolean { + return Mistral.NATIVE_TOOL_CALL_ID.test(id); } public async resolveToolCall(toolCall: AIToolCall): Promise { @@ -85,264 +56,8 @@ export class Mistral extends BaseAIClass { return new AIToolResponse(toolCall.name, new AIToolResponsePayload({ result }), toolCall.toolId); } - private async buildMessages(conversation: Conversation): Promise { - this.accumulatedToolCalls.clear(); - - // Refresh file cache only if conversation has attachments - if (conversation.hasAttachments()) { - await this.aiFileService.refreshCache(); - } - - const systemPrompt = `${this.systemPrompt}\n\n${this.userInstruction}`; - const messages = await this.extractContents(conversation.contents); - - return [ - { role: "system", content: systemPrompt }, - ...messages - ]; - } - - protected parseStreamChunk(chunk: string): IStreamChunk { - try { - // Mistral sends "[DONE]" as the final message - if (chunk.trim() === "[DONE]") { - return { content: "", isComplete: true }; - } - - const data = JSON.parse(chunk) as MistralStreamChunk; - - let text = ""; - let toolCall: AIToolCall | undefined = undefined; - let isComplete = false; - let shouldContinue = false; - let toolCallStarted: string | undefined = undefined; - - if (!data.choices || data.choices.length === 0) { - return { content: "", isComplete: false }; - } - - const choice = data.choices[0]; - - // Handle text content - if (choice.delta.content) { - text = choice.delta.content; - } - - // Handle tool call deltas - if (choice.delta.tool_calls) { - for (const tc of choice.delta.tool_calls) { - const index = tc.index ?? 0; - - if (!this.accumulatedToolCalls.has(index)) { - // New tool call starting - this.accumulatedToolCalls.set(index, { - id: tc.id || "", - name: tc.function?.name || "", - args: tc.function?.arguments || "" - }); - - if (tc.function?.name) { - toolCallStarted = tc.function.name; - } - } else { - // Accumulate arguments for existing tool call - const existing = this.accumulatedToolCalls.get(index)!; - if (tc.id) existing.id = tc.id; - if (tc.function?.name) existing.name += tc.function.name; - if (tc.function?.arguments) existing.args += tc.function.arguments; - } - } - } - - // Handle completion - if (choice.finish_reason) { - isComplete = true; - - if (choice.finish_reason === this.STOP_REASON_TOOL_CALLS) { - shouldContinue = true; - - // Finalize the first accumulated tool call - // (additional tool calls in a single response are not supported by this plugin's architecture) - const firstToolCall = this.accumulatedToolCalls.get(0); - if (firstToolCall && firstToolCall.name && firstToolCall.args) { - try { - const args = JSON.parse(firstToolCall.args) as Record; - toolCall = new AIToolCall( - aiToolFromString(firstToolCall.name), - args, - firstToolCall.id || undefined, - undefined - ); - } catch (error) { - Exception.log(error); - } - } - - this.accumulatedToolCalls.clear(); - } - } - - return { - content: text, - isComplete: isComplete, - toolCall: toolCall, - shouldContinue: shouldContinue, - toolCallStarted: toolCallStarted - }; - } catch (error) { - return this.createErrorChunk(error); - } - } - - protected async extractContents(conversationContent: ConversationContent[]): Promise { - const results: MistralMessage[] = []; - - for (const content of this.filterConversationContents(conversationContent)) { - const contentToExtract = content.content ?? ""; - - // Case 1: Assistant message with tool call - if (content.toolCall) { - const parsedContent = parseToolCall(content.toolCall); - - if (parsedContent) { - // Check if this is a cross-provider function call (has toolId in the stored format) - // Mistral requires IDs to be alphanumeric only (a-z, A-Z, 0-9) with length of 9 - // If the ID doesn't match this format, it's from another provider (Claude/OpenAI) - const mistralIdRegex = /^[a-zA-Z0-9]{9}$/; - const isCrossProvider = parsedContent.toolCall.id && - !mistralIdRegex.test(parsedContent.toolCall.id); - - if (!isCrossProvider && parsedContent.toolCall.id && parsedContent.toolCall.id.trim() !== "") { - // Native Mistral function call - use proper function call format - results.push({ - role: Role.Assistant, - content: contentToExtract || "", - tool_calls: [{ - id: parsedContent.toolCall.id, - type: "function", - function: { - name: parsedContent.toolCall.name, - arguments: JSON.stringify(parsedContent.toolCall.args) - } - }] - }); - } else { - // Cross-provider function call (from Claude/OpenAI) or no ID - use legacy text format - const legacyText = this.convertToolCallToText(parsedContent); - const combinedContent = contentToExtract.trim() !== "" - ? `${contentToExtract}\n\n${legacyText}` - : legacyText; - - results.push({ - role: content.role, - content: combinedContent - }); - } - } else { - results.push({ - role: content.role, - content: contentToExtract.trim() !== "" ? contentToExtract : "Error parsing function call" - }); - } - continue; - } - - // Case 2: Binary file attachments - if (content.attachments && content.attachments.length > 0) { - const { formattedParts, uploadErrors } = await this.processAttachments( - content.attachments, - (attachments) => this.formatBinaryFiles(attachments) - ); - - const contentParts: MistralContentPart[] = []; - - if (contentToExtract.trim() !== "") { - contentParts.push({ type: "text", text: contentToExtract }); - } - - contentParts.push(...formattedParts); - - for (const uploadError of uploadErrors) { - contentParts.push({ - type: "text", - text: Exception.messageFrom(uploadError) - }); - } - - if (contentParts.length > 0) { - results.push({ - role: content.role, - content: contentParts - }); - } - continue; - } - - // Case 3: Function call response (tool result) - if (content.functionResponse) { - const parsedContent = parseFunctionResponse(content.functionResponse); - - if (parsedContent) { - // Check if this is a cross-provider function response - // Mistral requires tool_call_id to be alphanumeric only (a-z, A-Z, 0-9) with length of 9 - const mistralIdRegex = /^[a-zA-Z0-9]{9}$/; - const isCrossProvider = parsedContent.id && - !mistralIdRegex.test(parsedContent.id); - - if (!isCrossProvider && parsedContent.id && parsedContent.id.trim() !== "") { - // Native Mistral function response - use proper format - results.push({ - role: "tool", - content: JSON.stringify(parsedContent.functionResponse.response), - tool_call_id: parsedContent.id, - name: parsedContent.functionResponse.name - }); - } else { - // Cross-provider function response (from Claude/OpenAI) or no ID - use legacy text format - const legacyText = this.convertFunctionResponseToText(parsedContent); - results.push({ - role: content.role, - content: legacyText - }); - } - } else { - results.push({ - role: content.role, - content: content.functionResponse - }); - } - continue; - } - - // Case 4: Regular text message - if (contentToExtract.trim() !== "") { - results.push({ - role: content.role, - content: contentToExtract - }); - } - } - - return results; - } - - protected mapFunctionDefinitions(aiToolDefinitions: IAIToolDefinition[]): MistralToolDefinition[] { - return aiToolDefinitions.map((functionDefinition) => ({ - type: "function" as const, - function: { - name: functionDefinition.name, - description: functionDefinition.description, - parameters: { - type: "object" as const, - properties: functionDefinition.parameters.properties, - required: functionDefinition.parameters.required - } - } - })); - } - protected formatBinaryFiles(attachments: Attachment[]): string { - const contentParts: MistralContentPart[] = []; + const contentParts: ChatCompletionContentPart[] = []; const fileService = this.aiFileService as MistralFileService; for (const attachment of attachments) { @@ -404,7 +119,7 @@ export class Mistral extends BaseAIClass { return JSON.stringify(contentParts); } - private getTools(): MistralToolDefinition[] { + protected getTools(): ChatCompletionToolDefinition[] { if (this.settingsService.settings.enableWebSearch) { return [ { @@ -426,45 +141,4 @@ export class Mistral extends BaseAIClass { } return this.mapFunctionDefinitions(this.aiToolDefinitions); } - - private buildMistralToolChoice(): string { - if (this.aiToolDefinitions.length === 0) { - return "auto"; - } - - switch (this.aiToolUsageMode) { - case AIToolUsageMode.Auto: - return "auto"; - case AIToolUsageMode.Enabled: - return "any"; - case AIToolUsageMode.Disabled: - return "none"; - } - } - - private extractRetryDelay(error: ApiError): number | undefined { - if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) { - return undefined; - } - - const retryAfter = error.info.responseHeaders.get('Retry-After') ?? - error.info.responseHeaders.get('retry-after'); - if (!retryAfter) return undefined; - - const seconds = parseInt(retryAfter, 10); - if (!isNaN(seconds)) return seconds; - - const date = new Date(retryAfter); - if (!isNaN(date.getTime())) { - const now = Date.now(); - const delayMs = date.getTime() - now; - return Math.max(0, Math.ceil(delayMs / 1000)); - } - - return undefined; - } - - private isSupportedMimeType(mimeType: MimeType): boolean { - return this.SUPPORTED_MIMETYPES.includes(mimeType); - } } diff --git a/AIClasses/Mistral/MistralConversationNamingService.ts b/AIClasses/Mistral/MistralConversationNamingService.ts index 6209e6b..36ab451 100644 --- a/AIClasses/Mistral/MistralConversationNamingService.ts +++ b/AIClasses/Mistral/MistralConversationNamingService.ts @@ -1,64 +1,17 @@ -import { Resolve } from "Services/DependencyService"; -import { Services } from "Services/Services"; -import type { IConversationNamingService } from "AIClasses/IConversationNamingService"; +import { ChatCompletionsConversationNamingService } from "AIClasses/ChatCompletions/ChatCompletionsConversationNamingService"; import { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider"; -import { Role } from "Enums/Role"; -import { NamePrompt } from "AIPrompts/NamePrompt"; -import type { SettingsService } from "Services/SettingsService"; -import { Exception } from "Helpers/Exception"; -import type { AbortService } from "Services/AbortService"; -import type { MistralChatResponse } from "./MistralTypes"; -export class MistralConversationNamingService implements IConversationNamingService { - - private readonly apiKey: string; - private readonly abortService: AbortService; +export class MistralConversationNamingService extends ChatCompletionsConversationNamingService { public constructor() { - const settingsService = Resolve(Services.SettingsService); - this.apiKey = settingsService.getApiKeyForProvider(AIProvider.Mistral); - this.abortService = Resolve(Services.AbortService); + super(AIProvider.Mistral); } - public async generateName(userPrompt: string): Promise { - return await this.abortService.abortableOperation(async () => { - const requestBody = { - model: AIProviderModel.MistralNamer, - max_tokens: 100, - messages: [ - { - role: "system", - content: NamePrompt - }, - { - role: Role.User, - content: userPrompt - } - ] - }; + protected get apiUrl(): string { + return AIProviderURL.Mistral; + } - const response = await fetch(AIProviderURL.Mistral, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal: this.abortService.signal() - }); - - if (!response.ok) { - Exception.throw(`Mistral API error: ${response.status} ${response.statusText} - ${await response.text()}`); - } - - const data = await response.json() as MistralChatResponse; - const firstChoice = data.choices?.[0]; - - if (!firstChoice || !firstChoice.message?.content) { - Exception.throw("Failed to generate conversation name"); - } - - return firstChoice.message.content.trim(); - }); + protected get namerModel(): string { + return AIProviderModel.MistralNamer; } } diff --git a/AIClasses/Mistral/MistralTypes.ts b/AIClasses/Mistral/MistralTypes.ts index 2fc74b3..83567dd 100644 --- a/AIClasses/Mistral/MistralTypes.ts +++ b/AIClasses/Mistral/MistralTypes.ts @@ -1,99 +1,7 @@ -// Mistral Chat Completions API types +// Mistral-specific API types. The generic Chat Completions request/response/stream +// types live in AIClasses/ChatCompletions/ChatCompletionsTypes.ts. -export interface MistralStreamChunk { - id: string; - object: "chat.completion.chunk"; - created: number; - model: string; - choices: MistralStreamChoice[]; -} - -export interface MistralStreamChoice { - index: number; - delta: MistralDelta; - finish_reason: string | null; -} - -export interface MistralDelta { - role?: string; - content?: string; - tool_calls?: MistralToolCallDelta[]; -} - -export interface MistralToolCallDelta { - id?: string; - type?: "function"; - function?: { - name?: string; - arguments?: string; - }; - index?: number; -} - -// Non-streaming response (used for conversation naming) -export interface MistralChatResponse { - id: string; - object: "chat.completion"; - created: number; - model: string; - choices: MistralChoice[]; - usage: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - }; -} - -export interface MistralChoice { - index: number; - message: { - role: string; - content: string | null; - tool_calls?: MistralToolCall[]; - }; - finish_reason: string; -} - -export interface MistralToolCall { - id: string; - type: "function"; - function: { - name: string; - arguments: string; - }; -} - -// Tool definition format -export interface MistralToolDefinition { - type: "function"; - function: { - name: string; - description: string; - parameters: { - type: "object"; - properties: Record; - required?: string[]; - }; - }; -} - -// Message types for request construction -export interface MistralMessage { - role: string; - content: string | MistralContentPart[]; - tool_calls?: MistralToolCall[]; - tool_call_id?: string; - name?: string; -} - -export interface MistralContentPart { - type: "text" | "image_url" | "document_url"; - text?: string; - image_url?: string; - document_url?: string; -} - -// Agents API types +// Agents API types (web search) export interface MistralAgentCreateRequest { model: string; name?: string; diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 62aa307..c840d98 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -9,18 +9,17 @@ import { fromString as aiToolFromString } from "Enums/AITool"; import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition"; import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemAdded, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIToolTool, ResponsesAPIInput } from "./OpenAITypes"; import { Exception } from "Helpers/Exception"; -import { ApiError, ApiErrorType } from "Types/ApiError"; +import { ApiErrorType } from "Types/ApiError"; import { MimeType, toMimeType } from "Enums/MimeType"; import { isTextFile } from "Enums/FileType"; import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping"; import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper"; -import { AIToolUsageMode } from "Enums/AIToolUsageMode"; import { replaceCopy } from 'Helpers/Helpers'; import { Copy } from "Enums/Copy"; export class OpenAI extends BaseAIClass { - private readonly SUPPORTED_MIMETYPES = [ + protected readonly SUPPORTED_MIMETYPES = [ MimeType.TEXT_PLAIN, MimeType.APPLICATION_PDF, MimeType.IMAGE_JPEG, @@ -32,6 +31,10 @@ export class OpenAI extends BaseAIClass { super(AIProvider.OpenAI); } + protected get supportedMimeTypes(): MimeType[] { + return this.SUPPORTED_MIMETYPES; + } + public async* streamRequest(conversation: Conversation): AsyncGenerator { // Refresh file cache only if conversation has attachments @@ -376,80 +379,10 @@ export class OpenAI extends BaseAIClass { } private buildOpenAIToolChoice(): string { - // If no tools defined, fall back to auto - if (this.aiToolDefinitions.length === 0) { - return "auto"; - } - - switch (this.aiToolUsageMode) { - case AIToolUsageMode.Auto: - return "auto"; - case AIToolUsageMode.Enabled: - return "required"; - case AIToolUsageMode.Disabled: - return "none"; - } - } - - private extractRetryDelay(error: ApiError): number | undefined { - if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) { - return undefined; - } - - const headers = error.info.responseHeaders; - - // 1. Prefer standard Retry-After header (seconds or HTTP-date) - const retryAfter = headers.get('retry-after'); - if (retryAfter) { - const seconds = Number(retryAfter); - if (!Number.isNaN(seconds)) { - return Math.max(0, seconds); - } - } - - // 2. Fallback to provider-specific headers (e.g., OpenAI) - const resetHeader = - headers.get('x-ratelimit-reset-requests') ?? - headers.get('x-ratelimit-reset-tokens'); - - if (resetHeader) { - return this.parseDurationToSeconds(resetHeader); - } - - return undefined; - } - - /** - * Parses duration strings (e.g., "15s", "600ms", "2m", "1h") into seconds. - * Returns undefined if parsing fails. - */ - private parseDurationToSeconds(value: string): number | undefined { - const trimmed = value.trim(); - const numericValue = parseFloat(trimmed); - - if (Number.isNaN(numericValue)) { - return undefined; - } - - // Parse based on suffix - if (trimmed.endsWith('ms')) { - return Math.max(0, Math.ceil(numericValue / 1000)); - } - if (trimmed.endsWith('s')) { - return Math.max(0, numericValue); - } - if (trimmed.endsWith('m')) { - return Math.max(0, numericValue * 60); - } - if (trimmed.endsWith('h')) { - return Math.max(0, numericValue * 3600); - } - - // Fallback: treat as raw seconds - return Math.max(0, numericValue); - } - - private isSupportedMimeType(mimeType: MimeType): boolean { - return this.SUPPORTED_MIMETYPES.includes(mimeType); + return this.buildToolChoice({ + auto: "auto", + enabled: "required", + disabled: "none" + }); } } \ No newline at end of file diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 7bc086f..6a10e86 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -23,6 +23,7 @@ chatAreaPaddingElement.style.padding = "0px"; } chatContainer.scroll({ top: 0, behavior: "instant" }); + tick().then(updateScrolledState); } export async function updateChatAreaLayout(behavior: ScrollBehavior | undefined = undefined, shouldSettle: boolean = false) { @@ -99,7 +100,11 @@ } function updateScrolledState() { - scrolledToBottom = chatContainer && Math.abs(chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 100; + if (!chatContainer) { + return; + } + const isScrollable = chatContainer.scrollHeight > chatContainer.clientHeight; + scrolledToBottom = !isScrollable || Math.abs(chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 100; } let scrolledToBottom: boolean = true; diff --git a/Views/VaultkeeperAISettingTab.ts b/Views/VaultkeeperAISettingTab.ts index 5f17109..3d8c532 100644 --- a/Views/VaultkeeperAISettingTab.ts +++ b/Views/VaultkeeperAISettingTab.ts @@ -354,9 +354,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { // Gemini models if (!providerFilter || providerFilter === AIProvider.Gemini) { const geminiGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderGemini } }); - geminiGroup.createEl("option", { value: AIProviderModel.GeminiFlash_3_1_Lite, text: Copy.GeminiPro_3_1_Preview }); - geminiGroup.createEl("option", { value: AIProviderModel.GeminiFlash_3_Flash, text: Copy.GeminiPro_3_1_Preview }); - geminiGroup.createEl("option", { value: AIProviderModel.GeminiFlash_3_5_Flash, text: Copy.GeminiPro_3_1_Preview }); + geminiGroup.createEl("option", { value: AIProviderModel.GeminiFlash_3_1_Lite, text: Copy.GeminiFlash_3_1_Lite }); + geminiGroup.createEl("option", { value: AIProviderModel.GeminiFlash_3_Flash, text: Copy.GeminiFlash_3_Flash }); + geminiGroup.createEl("option", { value: AIProviderModel.GeminiFlash_3_5_Flash, text: Copy.GeminiFlash_3_5_Flash }); geminiGroup.createEl("option", { value: AIProviderModel.GeminiPro_3_1_Preview, text: Copy.GeminiPro_3_1_Preview }); } diff --git a/__tests__/AIClasses/MistralConversationNamingService.test.ts b/__tests__/AIClasses/MistralConversationNamingService.test.ts index 18f3a4c..2747b9a 100644 --- a/__tests__/AIClasses/MistralConversationNamingService.test.ts +++ b/__tests__/AIClasses/MistralConversationNamingService.test.ts @@ -120,7 +120,7 @@ describe('MistralConversationNamingService', () => { }); await expect(service.generateName('Test')) - .rejects.toThrow('Mistral API error: 401 Unauthorized - Invalid API key'); + .rejects.toThrow('Chat Completions API error: 401 Unauthorized - Invalid API key'); }); it('should throw error when response has no content', async () => {