refactor: standardize error handling and logging across codebase

Replace console.error/log with Exception.log helper, improve OpenAI error handling with retryable errors, add type-safe model validation, and add new AI models (Claude Opus 4.5, Gemini 3 Pro Preview, GPT-5.1)
This commit is contained in:
Andrew Beal 2025-12-06 14:09:46 +00:00
parent 6f5f72952c
commit 33440d17bf
16 changed files with 171 additions and 60 deletions

View file

@ -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
};
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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) || `<div>${message.content}</div>`;
} catch (err) {
console.error("HTML processing failed:", err);
} catch (error) {
Exception.log(error);
return `<div>${message.content}</div>`;
}
}

View file

@ -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",

View file

@ -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",

View file

@ -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 },

View file

@ -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 {

View file

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

View file

@ -72,22 +72,29 @@ export class StreamingService {
private async makeRequest(url: string, requestBody: unknown,
additionalHeaders?: Record<string, string>): Promise<Response> {
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<Uint8Array>,
@ -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,

View file

@ -291,7 +291,7 @@ export class VaultService {
return null;
} catch (error) {
console.error(`Error processing file ${file.path}:`, error);
Exception.log(error);
return null;
}
});

View file

@ -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;

View file

@ -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) => {

View file

@ -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', () => {

View file

@ -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 () => {

View file

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