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');
});
});