mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
330 lines
13 KiB
TypeScript
330 lines
13 KiB
TypeScript
import { Resolve } from "Services/DependencyService";
|
|
import { Services } from "Services/Services";
|
|
import type { IAIClass } from "AIClasses/IAIClass";
|
|
import { type IStreamChunk } from "Services/StreamingService";
|
|
import type { Conversation } from "Conversations/Conversation";
|
|
import type { AIProvider } from "Enums/ApiProvider";
|
|
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
|
|
import type { ConversationContent } from "Conversations/ConversationContent";
|
|
import type { Attachment } from "Conversations/Attachment";
|
|
import type { SettingsService } from "Services/SettingsService";
|
|
import type { StreamingService } from "Services/StreamingService";
|
|
import type { StoredToolCall, StoredFunctionResponse } from "AIClasses/Schemas/AIToolTypes";
|
|
import { Exception } from "Helpers/Exception";
|
|
import { ApiError, ApiErrorType } from "Types/ApiError";
|
|
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 {
|
|
|
|
protected apiKey: string;
|
|
|
|
protected readonly provider: AIProvider;
|
|
protected readonly abortService: AbortService;
|
|
protected readonly aiFileService: IAIFileService;
|
|
protected readonly settingsService: SettingsService;
|
|
protected readonly streamingService: StreamingService;
|
|
|
|
private _systemPrompt: string = "";
|
|
private _userInstruction: string = "";
|
|
private _agentType: AgentType = AgentType.Main;
|
|
private _aiToolDefinitions: IAIToolDefinition[] = [];
|
|
private _aiToolUsageMode: AIToolUsageMode = AIToolUsageMode.Auto;
|
|
|
|
protected constructor(provider: AIProvider) {
|
|
this.provider = provider;
|
|
this.abortService = Resolve<AbortService>(Services.AbortService);
|
|
this.aiFileService = Resolve<IAIFileService>(Services.IAIFileService);
|
|
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
|
|
this.streamingService = Resolve<StreamingService>(Services.StreamingService);
|
|
|
|
this.apiKey = this.settingsService.getApiKeyForProvider(provider);
|
|
}
|
|
|
|
public get currentProvider(): AIProvider {
|
|
return this.provider;
|
|
}
|
|
|
|
public set systemPrompt(systemPrompt: string) {
|
|
this._systemPrompt = systemPrompt;
|
|
}
|
|
|
|
public get systemPrompt(): string {
|
|
return this._systemPrompt;
|
|
}
|
|
|
|
public set userInstruction(userInstruction: string) {
|
|
this._userInstruction = userInstruction;
|
|
}
|
|
|
|
public get userInstruction(): string {
|
|
return this._userInstruction;
|
|
}
|
|
|
|
public get aiToolDefinitions(): IAIToolDefinition[] {
|
|
return this._aiToolDefinitions;
|
|
}
|
|
|
|
public set aiToolDefinitions(aiToolDefinitions: IAIToolDefinition[]) {
|
|
this._aiToolDefinitions = aiToolDefinitions;
|
|
}
|
|
|
|
public get agentType() {
|
|
return this._agentType;
|
|
}
|
|
|
|
public set agentType(agentType: AgentType) {
|
|
this._agentType = agentType;
|
|
}
|
|
|
|
public get aiToolUsageMode(): AIToolUsageMode {
|
|
return this._aiToolUsageMode;
|
|
}
|
|
|
|
public set aiToolUsageMode(mode: AIToolUsageMode) {
|
|
this._aiToolUsageMode = mode;
|
|
}
|
|
|
|
public abstract streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown>;
|
|
|
|
protected abstract formatBinaryFiles(attachments: Attachment[]): Promise<string>;
|
|
|
|
protected abstract parseStreamChunk(chunk: string): IStreamChunk;
|
|
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:
|
|
return this.settingsService.settings.model;
|
|
case AgentType.Orchestration:
|
|
return this.settingsService.settings.planningModel;
|
|
case AgentType.Planning:
|
|
return this.settingsService.settings.planningModel;
|
|
case AgentType.Execution:
|
|
return this.settingsService.settings.model;
|
|
case AgentType.QuickAction:
|
|
return this.settingsService.settings.quickActionModel;
|
|
}
|
|
}
|
|
|
|
protected filterConversationContents(conversationContent: ConversationContent[]): ConversationContent[] {
|
|
return conversationContent.filter((content, index, array) => {
|
|
if (!content.content && !content.toolCall && !content.functionResponse && (!content.attachments || content.attachments.length === 0)) {
|
|
return false; // Filter out empty content
|
|
}
|
|
|
|
if (content.functionResponse) { // Filter out 'lone' function responses
|
|
const previousItem = array[index - 1];
|
|
const hasValidCall = previousItem && previousItem.toolCall && content.toolId === previousItem.toolId;
|
|
if (!hasValidCall) {
|
|
Exception.warn(`[Filter Debug] Filtered orphaned function response at index ${index}/${array.length}:\n` +
|
|
` ToolId: ${content.toolId}\n` +
|
|
` Previous item has toolCall: ${previousItem?.toolCall ? 'yes' : 'no'}\n` +
|
|
` Previous item toolId: ${previousItem?.toolId}\n` +
|
|
` Response: ${content.functionResponse}`);
|
|
}
|
|
return hasValidCall;
|
|
}
|
|
|
|
if (!content.toolCall) {
|
|
return true; // Keep non-function-calls
|
|
}
|
|
|
|
if (index === array.length - 1) {
|
|
return true; // Keep if it's the last item (most recent)
|
|
}
|
|
|
|
// Keep if next item is a matched function response
|
|
const nextItem = array[index + 1];
|
|
const hasValidResponse = nextItem && nextItem.functionResponse && content.toolId === nextItem.toolId;
|
|
if (!hasValidResponse) {
|
|
Exception.warn(`[Filter Debug] Filtered orphaned function call at index ${index}/${array.length}:\n` +
|
|
` ToolId: ${content.toolId}\n` +
|
|
` Next item has functionResponse: ${nextItem?.functionResponse ? 'yes' : 'no'}\n` +
|
|
` Next item toolId: ${nextItem?.toolId}\n` +
|
|
` Call: ${content.toolCall}`);
|
|
}
|
|
return hasValidResponse;
|
|
});
|
|
}
|
|
|
|
protected throwRetryableError(message: string, code?: string, errorType?: ApiErrorType): never {
|
|
throw new ApiError({
|
|
type: errorType || ApiErrorType.SERVER_ERROR,
|
|
message: code ? `${message} (${code})` : message,
|
|
userMessage: "Service error.",
|
|
isRetryable: true
|
|
});
|
|
}
|
|
|
|
protected createErrorChunk(error: unknown, errorType?: ApiErrorType, userMessage?: string): IStreamChunk {
|
|
// let ApiError propagate
|
|
if (error instanceof ApiError) {
|
|
throw error;
|
|
}
|
|
|
|
Exception.log(error);
|
|
return {
|
|
content: "",
|
|
isComplete: true,
|
|
error: userMessage || `Failed to parse chunk: ${Exception.messageFrom(error)}`,
|
|
errorType: errorType || ApiErrorType.UNKNOWN
|
|
};
|
|
}
|
|
|
|
protected async processAttachments<T>(
|
|
attachments: Attachment[],
|
|
formatBinaryFiles: (attachments: Attachment[]) => Promise<string>
|
|
): Promise<{ formattedParts: T[], uploadErrors: Error[] }> {
|
|
const uploadErrors: Error[] = [];
|
|
|
|
for (const attachment of attachments) {
|
|
try {
|
|
if (attachment.base64.trim() === "") {
|
|
Exception.throw(`Failed to upload ${attachment.fileName}: File has no content`);
|
|
}
|
|
await this.aiFileService.uploadFile(attachment);
|
|
if (!attachment.getFileID(this.provider)) {
|
|
Exception.throw(`Failed to upload ${attachment.fileName}: File ID undefined after upload attempt`);
|
|
}
|
|
} catch (error) {
|
|
uploadErrors.push(Exception.new(error));
|
|
}
|
|
}
|
|
|
|
const formattedContent = await formatBinaryFiles(attachments);
|
|
const formattedParts = JSON.parse(formattedContent) as T[];
|
|
|
|
return { formattedParts, uploadErrors };
|
|
}
|
|
|
|
/**
|
|
* Converts a function call to legacy text format for cross-provider compatibility.
|
|
* Used when a provider doesn't have the required ID field (e.g., Gemini → Claude/OpenAI).
|
|
*/
|
|
protected convertToolCallToText(parsedContent: StoredToolCall): string {
|
|
const formattedJson = JSON.stringify({
|
|
name: parsedContent.toolCall.name,
|
|
args: parsedContent.toolCall.args
|
|
}, null, 2);
|
|
|
|
return `<!-- Historical tool call. This action was ALREADY COMPLETED.
|
|
Use your native function calling for any NEW operations. -->
|
|
${formattedJson}`;
|
|
}
|
|
|
|
/**
|
|
* Converts a function response to legacy text format for cross-provider compatibility.
|
|
* Used when a provider doesn't have the required ID field (e.g., Gemini → Claude/OpenAI).
|
|
*/
|
|
protected convertFunctionResponseToText(parsedContent: StoredFunctionResponse): string {
|
|
const formattedJson = JSON.stringify({
|
|
name: parsedContent.functionResponse.name,
|
|
response: parsedContent.functionResponse.response
|
|
}, null, 2);
|
|
|
|
return `<!-- Historical tool result. This action was ALREADY COMPLETED. -->
|
|
${formattedJson}`;
|
|
}
|
|
}
|