mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
6f5f72952c
commit
33440d17bf
16 changed files with 171 additions and 60 deletions
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ export class VaultService {
|
|||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${file.path}:`, error);
|
||||
Exception.log(error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue