mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
refactor: extract shared Chat Completions logic and consolidate retry/tool-choice handling
Move common OpenAI-compatible API code to ChatCompletionsAIClass base, unify retry delay extraction and tool-choice mapping across providers (Claude, Gemini, Mistral, OpenAI), fix Gemini model labels, and correct scroll state when content is not scrollable Fix Gemini model naming in settings. Fix CHat area scroll to bottom state when clearing a conversation.
This commit is contained in:
parent
d500214e2e
commit
1390fafe86
13 changed files with 696 additions and 640 deletions
|
|
@ -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<T>(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:
|
||||
|
|
|
|||
353
AIClasses/ChatCompletions/ChatCompletionsAIClass.ts
Normal file
353
AIClasses/ChatCompletions/ChatCompletionsAIClass.ts
Normal file
|
|
@ -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<number, { id: string; name: string; args: string }> = new Map();
|
||||
|
||||
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||
const messages = await this.buildMessages(conversation);
|
||||
const tools = this.getTools();
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
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<ChatCompletionMessage[]> {
|
||||
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<string, unknown>;
|
||||
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<ChatCompletionMessage[]> {
|
||||
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<ChatCompletionContentPart>(
|
||||
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<string>({
|
||||
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() !== "";
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SettingsService>(Services.SettingsService);
|
||||
this.apiKey = settingsService.getApiKeyForProvider(provider);
|
||||
this.abortService = Resolve<AbortService>(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<string> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
97
AIClasses/ChatCompletions/ChatCompletionsTypes.ts
Normal file
97
AIClasses/ChatCompletions/ChatCompletionsTypes.ts
Normal file
|
|
@ -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<string, object>;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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" }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown> = {};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<number, { id: string; name: string; args: string }> = new Map();
|
||||
|
||||
public constructor() {
|
||||
super(AIProvider.Mistral);
|
||||
}
|
||||
|
||||
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||
const messages = await this.buildMessages(conversation);
|
||||
const tools = this.getTools();
|
||||
protected get apiUrl(): string {
|
||||
return AIProviderURL.Mistral;
|
||||
}
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
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<AIToolResponse | null> {
|
||||
|
|
@ -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<MistralMessage[]> {
|
||||
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<string, unknown>;
|
||||
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<MistralMessage[]> {
|
||||
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<MistralContentPart>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SettingsService>(Services.SettingsService);
|
||||
this.apiKey = settingsService.getApiKeyForProvider(AIProvider.Mistral);
|
||||
this.abortService = Resolve<AbortService>(Services.AbortService);
|
||||
super(AIProvider.Mistral);
|
||||
}
|
||||
|
||||
public async generateName(userPrompt: string): Promise<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, object>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<IStreamChunk, void, unknown> {
|
||||
|
||||
// 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<string>({
|
||||
auto: "auto",
|
||||
enabled: "required",
|
||||
disabled: "none"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue