diff --git a/AIClasses/BaseAIClass.ts b/AIClasses/BaseAIClass.ts index 868573b..72c9cf9 100644 --- a/AIClasses/BaseAIClass.ts +++ b/AIClasses/BaseAIClass.ts @@ -14,7 +14,7 @@ import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schem import { Role } from "Enums/Role"; import { StringTools } from "Helpers/StringTools"; import { Exception } from "Helpers/Exception"; -import { ApiErrorType } from "Types/ApiError"; +import { ApiError, ApiErrorType } from "Types/ApiError"; import type { AbortService } from "Services/AbortService"; export abstract class BaseAIClass implements IAIClass { @@ -94,13 +94,27 @@ export abstract class BaseAIClass implements IAIClass { } } - protected createErrorChunk(error: unknown): IStreamChunk { + 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. Retrying...", + 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: `Failed to parse chunk: ${Exception.messageFrom(error)}`, - errorType: ApiErrorType.UNKNOWN + error: userMessage || `Failed to parse chunk: ${Exception.messageFrom(error)}`, + errorType: errorType || ApiErrorType.UNKNOWN }; } diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index f12acc6..5d2d76d 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -2,12 +2,13 @@ import { BaseAIClass } from "AIClasses/BaseAIClass"; import type { IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; import type { ConversationContent } from "Conversations/ConversationContent"; -import { AIProvider, AIProviderURL } from "Enums/ApiProvider"; +import { AIProvider, AIProviderURL, toProviderModel } from "Enums/ApiProvider"; import { AIFunctionCall } from "AIClasses/AIFunctionCall"; import { fromString as aiFunctionFromString } from "Enums/AIFunction"; import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition"; -import type { ResponseEvent, ResponseOutputTextDelta, ResponseFunctionCallArgumentsDone, ResponseDone, OpenAIFunctionTool } from "./OpenAITypes"; +import type { ResponseEvent, ResponseOutputTextDelta, ResponseFunctionCallArgumentsDone, ResponseDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIFunctionTool } from "./OpenAITypes"; import { Exception } from "Helpers/Exception"; +import { ApiErrorType } from "Types/ApiError"; export class OpenAI extends BaseAIClass { @@ -30,7 +31,7 @@ export class OpenAI extends BaseAIClass { )]; const requestBody = { - model: this.settingsService.settings.model, + model: toProviderModel(this.settingsService.settings.model), instructions: systemPrompt, input: input, tools: tools, @@ -80,10 +81,24 @@ export class OpenAI extends BaseAIClass { break; } + case "error": case "response.error": { - // Error occurred during response generation - isComplete = true; - console.error("Response error:", event); + const errorEvent = event as ResponseErrorEvent; + this.throwRetryableError( + errorEvent.message, + errorEvent.code || undefined, + ApiErrorType.SERVER_ERROR + ); + break; + } + + case "response.failed": { + const errorEvent = event as ResponseFailedEvent; + this.throwRetryableError( + errorEvent.response?.error?.message || "Response failed", + errorEvent.response?.error?.code || undefined, + ApiErrorType.SERVER_ERROR + ); break; } @@ -133,13 +148,22 @@ export class OpenAI extends BaseAIClass { break; } + case "response.created": + case "response.in_progress": + case "response.content_part.added": + case "response.content_part.done": case "response.output_item.added": case "response.output_item.done": + case "response.output_text.done": + case "response.web_search_call.in_progress": + case "response.web_search_call.searching": + case "response.web_search_call.completed": // These events can be used for more granular tracking if needed // For now, we handle content through the delta events break; default: + // log in dev but just ignore unhandled cases in prod Exception.log(`Unknown event type: ${event.type}`); break; } diff --git a/AIClasses/OpenAI/OpenAITypes.ts b/AIClasses/OpenAI/OpenAITypes.ts index 37b4c2d..43496d0 100644 --- a/AIClasses/OpenAI/OpenAITypes.ts +++ b/AIClasses/OpenAI/OpenAITypes.ts @@ -50,6 +50,23 @@ export interface ResponseDone extends ResponseEvent { }; } +export interface ResponseErrorEvent extends ResponseEvent { + type: "error"; + code: string | null; + message: string; + param: string | null; +} + +export interface ResponseFailedEvent extends ResponseEvent { + type: "response.failed"; + response: { + error: { + code: string | null; + message: string; + } | null; + }; +} + export interface OpenAIFunctionTool { type: "function"; name: string; diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 3e35cc0..fab6d17 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -10,6 +10,7 @@ import type { ConversationContent } from "Conversations/ConversationContent"; import { tick } from "svelte"; import { Selector } from "Enums/Selector"; + import { Exception } from "Helpers/Exception"; export let cancelling: boolean = false; export let messages: ConversationContent[] = []; @@ -126,8 +127,8 @@ try { return streamingMarkdownService.formatText(message.content) || `
${message.content}
`; - } catch (err) { - console.error("HTML processing failed:", err); + } catch (error) { + Exception.log(error); return `
${message.content}
`; } } diff --git a/Enums/ApiProvider.ts b/Enums/ApiProvider.ts index d4c58c4..a346220 100644 --- a/Enums/ApiProvider.ts +++ b/Enums/ApiProvider.ts @@ -1,28 +1,55 @@ import { Exception } from "Helpers/Exception"; +export function fromModel(model: string): AIProvider { + if (!isValidProviderModel(model)) { + Exception.throw(`Invalid model: ${model}`); + } + if (isClaudeModel(model)) { + return AIProvider.Claude; + } else if (isGeminiModel(model)) { + return AIProvider.Gemini; + } else if (isOpenAIModel(model)) { + return AIProvider.OpenAI; + } else { + Exception.throw("Invalid model selection"); + } +} + +export function toProviderModel(model: string): AIProviderModel { + if (isValidProviderModel(model)) { + return model; + } + Exception.throw(`Invalid model: ${model}`); +} + +function isClaudeModel(model: string): boolean { + return isValidProviderModel(model) && model.startsWith("claude-"); +} + +function isGeminiModel(model: string): boolean { + return isValidProviderModel(model) && model.startsWith("gemini-"); +} + +function isOpenAIModel(model: string): boolean { + return isValidProviderModel(model) && model.startsWith("gpt-"); +} + +function isValidProviderModel(model: string): model is AIProviderModel { + return Object.values(AIProviderModel).includes(model as AIProviderModel); +} + export enum AIProvider { Claude = "Claude", Gemini = "Gemini", OpenAI = "OpenAI" } -export function fromModel(model: string): AIProvider { - if (model.startsWith("claude-")) { - return AIProvider.Claude; - } else if (model.startsWith("gemini-")) { - return AIProvider.Gemini; - } else if (model.startsWith("gpt-")) { - return AIProvider.OpenAI; - } else { - Exception.throw("Invalid Model Selection!"); - } -} - export enum AIProviderModel { // Claude models ClaudeSonnet_4_5 = "claude-sonnet-4-5-20250929", ClaudeSonnet_4 = "claude-sonnet-4-20250514", ClaudeSonnet_3_7 = "claude-3-7-sonnet-20250219", + ClaudeOpus_4_5 = "claude-opus-4-5-20251101", ClaudeOpus_4_1 = "claude-opus-4-1-20250805", ClaudeOpus_4 = "claude-opus-4-20250514", ClaudeHaiku_4_5 = "claude-haiku-4-5-20251001", @@ -31,8 +58,10 @@ export enum AIProviderModel { GeminiFlash_2_5_Lite = "gemini-2.5-flash-lite", GeminiFlash_2_5 = "gemini-2.5-flash", GeminiPro_2_5 = "gemini-2.5-pro", + GeminiPro_3_Preview = "gemini-3-pro-preview", // OpenAI models + GPT_5_1 = "gpt-5.1", GPT_5 = "gpt-5", GPT_5_Mini = "gpt-5-mini", GPT_5_Nano = "gpt-5-nano", diff --git a/Enums/Copy.ts b/Enums/Copy.ts index 903fd50..a5b09e3 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -9,6 +9,7 @@ export enum Copy { ClaudeSonnet_4_5 = "Claude Sonnet 4.5", ClaudeSonnet_4 = "Claude Sonnet 4", ClaudeSonnet_3_7 = "Claude Sonnet 3.7", + ClaudeOpus_4_5 = "Claude Opus 4.5", ClaudeOpus_4_1 = "Claude Opus 4.1", ClaudeOpus_4 = "Claude Opus 4", ClaudeHaiku_4_5 = "Claude Haiku 4.5", @@ -16,7 +17,9 @@ export enum Copy { GeminiFlash_2_5_Lite = "Gemini 2.5 Flash Lite", GeminiFlash_2_5 = "Gemini 2.5 Flash", GeminiPro_2_5 = "Gemini 2.5 Pro", + GeminiPro_3_Preview = "Gemini 3 Pro Preview", + GPT_5_1 = "GPT-5.1", GPT_5 = "GPT-5", GPT_5_Mini = "GPT-5 Mini", GPT_5_Nano = "GPT-5 Nano", diff --git a/Services/AIFunctionService.ts b/Services/AIFunctionService.ts index a105626..6e21ba8 100644 --- a/Services/AIFunctionService.ts +++ b/Services/AIFunctionService.ts @@ -7,6 +7,7 @@ import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; import type { ISearchMatch } from "../Helpers/SearchTypes"; import { AbortService } from "./AbortService"; import { normalizePath, TAbstractFile, TFile } from "obsidian"; +import { Exception } from "Helpers/Exception"; import { SearchVaultFilesArgsSchema, ReadVaultFilesArgsSchema, @@ -120,7 +121,7 @@ export class AIFunctionService { default: { const error = `Unknown function request ${functionCall.name as string}` - console.error(error); + Exception.log(error); return new AIFunctionResponse( functionCall.name, { error: error }, diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 0eb25dd..5e65837 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -16,6 +16,7 @@ import { Notice } from "obsidian"; import type { EventService } from "./EventService"; import { Event } from "Enums/Event"; import { AbortService } from "./AbortService"; +import { Exception } from "Helpers/Exception"; export interface IChatServiceCallbacks { onSubmit: () => void; @@ -108,6 +109,7 @@ export class ChatService { if (AbortService.isAbortError(error)) { callbacks.onCancel(); } else { + Exception.log(error); new Notice("Vaultkeeper AI encountered an error"); } } finally { diff --git a/Services/InputService.ts b/Services/InputService.ts index 7b32fa0..0e913d0 100644 --- a/Services/InputService.ts +++ b/Services/InputService.ts @@ -1,3 +1,4 @@ +import { Exception } from "Helpers/Exception"; import { isSearchTriggerElement } from "../Enums/SearchTrigger"; export class InputService { @@ -127,7 +128,7 @@ export class InputService { return false; } catch (error) { - console.error("Error setting cursor position:", error); + Exception.log(error); return false; } } @@ -334,7 +335,7 @@ export class InputService { this.ensureCursorNotInNonEditableElement(element); } catch (error) { - console.error("Error deleting text range:", error); + Exception.log(error); } } @@ -409,7 +410,7 @@ export class InputService { selection.addRange(range); container.focus(); } catch (error) { - console.error("Error positioning cursor:", error); + Exception.log(error); } } diff --git a/Services/StreamingService.ts b/Services/StreamingService.ts index 37f0eeb..a85613f 100644 --- a/Services/StreamingService.ts +++ b/Services/StreamingService.ts @@ -72,22 +72,29 @@ export class StreamingService { private async makeRequest(url: string, requestBody: unknown, additionalHeaders?: Record): Promise { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...additionalHeaders, - }, - body: JSON.stringify(requestBody), - signal: this.abortService.signal(), - }); + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...additionalHeaders, + }, + body: JSON.stringify(requestBody), + signal: this.abortService.signal(), + }); - if (!response.ok) { - const responseBody = await response.text(); - throw ApiError.fromResponse(response.status, response.statusText, responseBody); + if (!response.ok) { + const responseBody = await response.text(); + throw ApiError.fromResponse(response.status, response.statusText, responseBody); + } + + return response; + } catch (error) { + if (ApiError.isApiError(error) || AbortService.isAbortError(error)) { + throw error; + } + throw ApiError.fromNetworkError(Exception.new(error)); } - - return response; } private async* processStream(reader: ReadableStreamDefaultReader, @@ -116,10 +123,12 @@ export class StreamingService { lastChunkWasComplete = chunk.isComplete; yield chunk; } catch (error) { - if (AbortService.isAbortError(error)) { + if (AbortService.isAbortError(error) || ApiError.isApiError(error)) { throw error; } + Exception.log(error); + yield { content: "", isComplete: true, diff --git a/Services/VaultService.ts b/Services/VaultService.ts index 2d7969d..ade7ffe 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -291,7 +291,7 @@ export class VaultService { return null; } catch (error) { - console.error(`Error processing file ${file.path}:`, error); + Exception.log(error); return null; } }); diff --git a/Types/ApiError.ts b/Types/ApiError.ts index 70f35fe..88ccc2b 100644 --- a/Types/ApiError.ts +++ b/Types/ApiError.ts @@ -25,6 +25,10 @@ export class ApiError extends Error { this.name = "ApiError"; } + static isApiError(error: unknown): boolean { + return error instanceof ApiError; + } + static fromResponse(status: number, statusText: string, responseBody: string): ApiError { let type: ApiErrorType; let userMessage: string; diff --git a/VaultkeeperAISettingTab.ts b/VaultkeeperAISettingTab.ts index 3cde5da..d84f685 100644 --- a/VaultkeeperAISettingTab.ts +++ b/VaultkeeperAISettingTab.ts @@ -46,6 +46,10 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { value: AIProviderModel.ClaudeSonnet_3_7, text: Copy.ClaudeSonnet_3_7 }); + claudeGroup.createEl("option", { + value: AIProviderModel.ClaudeOpus_4_5, + text: Copy.ClaudeOpus_4_5 + }); claudeGroup.createEl("option", { value: AIProviderModel.ClaudeOpus_4_1, text: Copy.ClaudeOpus_4_1 @@ -61,6 +65,10 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { // OpenAI models group const openaiGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderOpenAI } }); + openaiGroup.createEl("option", { + value: AIProviderModel.GPT_5_1, + text: Copy.GPT_5_1 + }); openaiGroup.createEl("option", { value: AIProviderModel.GPT_5, text: Copy.GPT_5 @@ -108,6 +116,10 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { value: AIProviderModel.GeminiPro_2_5, text: Copy.GeminiPro_2_5 }); + geminiGroup.createEl("option", { + value: AIProviderModel.GeminiPro_3_Preview, + text: Copy.GeminiPro_3_Preview + }); dropdown.setValue(this.settingsService.settings.model); dropdown.onChange(async (value) => { diff --git a/__tests__/AIClasses/OpenAI.test.ts b/__tests__/AIClasses/OpenAI.test.ts index cb1641e..202b456 100644 --- a/__tests__/AIClasses/OpenAI.test.ts +++ b/__tests__/AIClasses/OpenAI.test.ts @@ -290,19 +290,16 @@ describe('OpenAI', () => { }); it('should handle response.error events', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const chunk = JSON.stringify({ type: 'response.error', - error: { message: 'Something went wrong' } + code: 'error_code', + message: 'Something went wrong', + param: null }); - const result = (openai as any).parseStreamChunk(chunk); - - expect(result.isComplete).toBe(true); - expect(consoleSpy).toHaveBeenCalled(); - - consoleSpy.mockRestore(); + expect(() => { + (openai as any).parseStreamChunk(chunk); + }).toThrow('Something went wrong (error_code)'); }); it('should handle response.completed event', () => { diff --git a/__tests__/Services/AIFunctionService.test.ts b/__tests__/Services/AIFunctionService.test.ts index 50e600d..f8851fe 100644 --- a/__tests__/Services/AIFunctionService.test.ts +++ b/__tests__/Services/AIFunctionService.test.ts @@ -783,8 +783,6 @@ describe('AIFunctionService - Integration Tests', () => { describe('performAIFunction - Unknown Function', () => { it('should return error for unknown function', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const result = await service.performAIFunction({ name: 'UnknownFunction' as any, arguments: {}, @@ -794,9 +792,6 @@ describe('AIFunctionService - Integration Tests', () => { expect(result.response).toEqual({ error: 'Unknown function request UnknownFunction' }); - expect(consoleSpy).toHaveBeenCalledWith('Unknown function request UnknownFunction'); - - consoleSpy.mockRestore(); }); it('should preserve toolId in error response', async () => { diff --git a/__tests__/Services/StreamingService.test.ts b/__tests__/Services/StreamingService.test.ts index fd0cc05..a6c00b4 100644 --- a/__tests__/Services/StreamingService.test.ts +++ b/__tests__/Services/StreamingService.test.ts @@ -534,7 +534,8 @@ describe('StreamingService', () => { expect(results).toHaveLength(1); expect(results[0].isComplete).toBe(true); - expect(results[0].error).toBe('Network connection failed'); + expect(results[0].error).toBe('Network error. Please check your connection.'); + expect(results[0].errorType).toBe('NETWORK_ERROR'); }); it('should handle unknown error type', async () => { @@ -551,7 +552,8 @@ describe('StreamingService', () => { expect(results).toHaveLength(1); expect(results[0].isComplete).toBe(true); - expect(results[0].error).toBe('String error'); + expect(results[0].error).toBe('Network error. Please check your connection.'); + expect(results[0].errorType).toBe('NETWORK_ERROR'); }); });